RESTlet Development
RESTlets are server-side scripts that expose custom REST endpoints for external system integration.
When to Use RESTlets
| Use Case | Example |
|---|---|
| External integrations | Connect with third-party systems |
| Mobile apps | Provide data to mobile applications |
| Custom APIs | Create specific data endpoints |
| Webhooks | Receive notifications from external services |
| Automation | Allow external systems to trigger actions |
RESTlet Execution Flow
┌─────────────────────────────────────────────────────────────────────────────┐
│ RESTLET EXECUTION FLOW │
└─────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ EXTERNAL SYSTEM │
│ (Mobile App, Website, Integration Platform) │
└──────────────────────────────┬───────────────────────────────────┘
│
│ HTTPS Request
│ + Authentication
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ NETSUITE AUTHENTICATION │
│ ────────────────────────────────────────────────────────────────│
│ Token-Based Authentication (TBA) │
│ • Consumer Key/Secret │
│ • Token ID/Secret │
│ • OAuth 1.0 signature │
└──────────────────────────────┬───────────────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
│ Auth Auth Auth
│ Failed Success Success
▼ │ │
┌─────────────────┐ │ │
│ 401 │ │ │
│ Unauthorized │ │ │
└─────────────────┘ │ │
│ │
┌────────────────────┴─────────────────────┤
│ │
▼ ▼
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
│ HTTP METHOD ROUTING │ │ Content-Type Header │
└─────────────────┬───────────────────┘ │ Determines data format │
│ └─────────────────────────────────────┘
┌───────────────┼───────────────┬───────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ GET │ │ POST │ │ PUT │ │ DELETE │
│ ───────── │ │ ───────── │ │ ───────── │ │ ───────── │
│ Retrieve │ │ Create │ │ Update │ │ Remove │
│ data │ │ data │ │ data │ │ data │
│ │ │ │ │ │ │ │
│ get() │ │ post() │ │ put() │ │ delete() │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │ │
└───────────────┴───────────────┴───────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ SCRIPT EXECUTION │
│ ────────────────────────────────────────────────────────────────│
│ • Process request data │
│ • Execute business logic │
│ • Query/Create/Update records │
│ • Return response object │
│ • Governance: 5,000 units │
└──────────────────────────────┬───────────────────────────────────┘
│
│ Return object/array/string
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ RESPONSE │
│ ────────────────────────────────────────────────────────────────│
│ • Automatic JSON/XML serialization │
│ • HTTP status codes │
│ • Response headers │
└──────────────────────────────────────────────────────────────────┘
HTTP Methods
| Method | Function | Purpose | Request Body |
|---|---|---|---|
| GET | get(context) | Retrieve data | Query params only |
| POST | post(context) | Create data | Yes |
| PUT | put(context) | Update data | Yes |
| DELETE | delete(context) | Delete data | Optional |
Basic RESTlet Structure
/**
* @NApiVersion 2.1
* @NScriptType Restlet
* @NModuleScope SameAccount
*/
define(['N/record', 'N/search', 'N/log', 'N/error'],
(record, search, log, error) => {
/**
* GET - Retrieve data
* @param {Object} context - Request parameters
* @returns {Object|Array} - Response data
*/
const get = (context) => {
log.debug('GET Request', JSON.stringify(context));
// context contains query parameters
const recordId = context.id;
const recordType = context.type;
if (!recordId || !recordType) {
throw error.create({
name: 'MISSING_PARAMS',
message: 'id and type parameters are required'
});
}
// Load and return record data
const rec = record.load({
type: recordType,
id: recordId
});
return {
success: true,
data: {
id: rec.id,
type: rec.type,
name: rec.getValue({ fieldId: 'name' })
}
};
};
/**
* POST - Create data
* @param {Object} context - Request body
* @returns {Object} - Response with created ID
*/
const post = (context) => {
log.debug('POST Request', JSON.stringify(context));
// context is the parsed request body
const { type, values } = context;
if (!type || !values) {
throw error.create({
name: 'INVALID_REQUEST',
message: 'type and values are required'
});
}
// Create record
const newRecord = record.create({
type: type,
isDynamic: true
});
// Set field values
Object.keys(values).forEach(fieldId => {
newRecord.setValue({
fieldId: fieldId,
value: values[fieldId]
});
});
const recordId = newRecord.save();
return {
success: true,
id: recordId,
message: `${type} created successfully`
};
};
/**
* PUT - Update data
* @param {Object} context - Request body
* @returns {Object} - Response
*/
const put = (context) => {
log.debug('PUT Request', JSON.stringify(context));
const { type, id, values } = context;
if (!type || !id || !values) {
throw error.create({
name: 'INVALID_REQUEST',
message: 'type, id, and values are required'
});
}
// Update record
record.submitFields({
type: type,
id: id,
values: values
});
return {
success: true,
id: id,
message: `${type} updated successfully`
};
};
/**
* DELETE - Remove data
* @param {Object} context - Request parameters
* @returns {Object} - Response
*/
const doDelete = (context) => {
log.debug('DELETE Request', JSON.stringify(context));
const { type, id } = context;
if (!type || !id) {
throw error.create({
name: 'INVALID_REQUEST',
message: 'type and id are required'
});
}
// Delete record
record.delete({
type: type,
id: id
});
return {
success: true,
id: id,
message: `${type} deleted successfully`
};
};
return {
get,
post,
put,
'delete': doDelete // 'delete' is reserved word
};
});
Request/Response Flow
┌─────────────────────────────────────────────────────────────────────────────┐
│ REQUEST CONTENT TYPES │
└─────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Content-Type: application/json │
│ ────────────────────────────────────────────────────────────────│
│ Request: {"name": "Acme Corp", "email": "info@acme.com"} │
│ context = parsed JSON object │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Content-Type: text/plain │
│ ────────────────────────────────────────────────────────────────│
│ Request: Plain text content │
│ context = string │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ GET Request (Query Parameters) │
│ ────────────────────────────────────────────────────────────────│
│ URL: ?type=customer&id=123&fields=name,email │
│ context = { type: 'customer', id: '123', fields: 'name,email' } │
└──────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ RESPONSE HANDLING │
└─────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Return Object → Automatic JSON │
│ ────────────────────────────────────────────────────────────────│
│ return { success: true, data: {...} }; │
│ Response: {"success":true,"data":{...}} │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Return Array → Automatic JSON │
│ ────────────────────────────────────────────────────────────────│
│ return [{ id: 1 }, { id: 2 }]; │
│ Response: [{"id":1},{"id":2}] │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Return String → Plain text │
│ ────────────────────────────────────────────────────────────────│
│ return "Operation successful"; │
│ Response: Operation successful │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Throw Error → HTTP 4xx/5xx │
│ ────────────────────────────────────────────────────────────────│
│ throw error.create({ name: 'NOT_FOUND', message: '...' }); │
│ Response: 400 Bad Request with error details │
└──────────────────────────────────────────────────────────────────┘
Complete Example: Customer API
/**
* @NApiVersion 2.1
* @NScriptType Restlet
* @NModuleScope SameAccount
* @description Customer CRUD API
*/
define(['N/record', 'N/search', 'N/log', 'N/error'],
(record, search, log, error) => {
/**
* GET - Retrieve customer(s)
*/
const get = (context) => {
log.audit('Customer API', `GET: ${JSON.stringify(context)}`);
try {
// Single customer lookup
if (context.id) {
return getCustomerById(context.id);
}
// Search customers
return searchCustomers(context);
} catch (e) {
log.error('GET Error', e.message);
return createErrorResponse(e);
}
};
/**
* Get single customer by ID
*/
const getCustomerById = (customerId) => {
const customerRecord = record.load({
type: record.Type.CUSTOMER,
id: customerId
});
return {
success: true,
data: mapCustomerRecord(customerRecord)
};
};
/**
* Search customers with filters
*/
const searchCustomers = (params) => {
const filters = [['isinactive', 'is', 'F']];
// Add optional filters
if (params.email) {
filters.push('AND', ['email', 'contains', params.email]);
}
if (params.name) {
filters.push('AND', ['entityid', 'contains', params.name]);
}
if (params.subsidiary) {
filters.push('AND', ['subsidiary', 'anyof', params.subsidiary]);
}
const customerSearch = search.create({
type: search.Type.CUSTOMER,
filters: filters,
columns: [
'internalid',
'entityid',
'companyname',
'email',
'phone',
'salesrep',
'balance'
]
});
const results = [];
const limit = parseInt(params.limit) || 100;
const offset = parseInt(params.offset) || 0;
customerSearch.run().getRange({
start: offset,
end: offset + limit
}).forEach(result => {
results.push({
id: result.id,
entityId: result.getValue('entityid'),
companyName: result.getValue('companyname'),
email: result.getValue('email'),
phone: result.getValue('phone'),
salesRep: result.getText('salesrep'),
balance: result.getValue('balance')
});
});
return {
success: true,
count: results.length,
offset: offset,
limit: limit,
data: results
};
};
/**
* POST - Create customer
*/
const post = (context) => {
log.audit('Customer API', `POST: ${JSON.stringify(context)}`);
try {
// Validate required fields
validateCustomerData(context);
// Create customer record
const customer = record.create({
type: record.Type.CUSTOMER,
isDynamic: true
});
// Set fields
setCustomerFields(customer, context);
// Add addresses if provided
if (context.addresses && context.addresses.length > 0) {
addAddresses(customer, context.addresses);
}
// Add contacts if provided
if (context.contacts && context.contacts.length > 0) {
addContacts(customer, context.contacts);
}
const customerId = customer.save();
return {
success: true,
id: customerId,
message: 'Customer created successfully'
};
} catch (e) {
log.error('POST Error', e.message);
return createErrorResponse(e);
}
};
/**
* PUT - Update customer
*/
const put = (context) => {
log.audit('Customer API', `PUT: ${JSON.stringify(context)}`);
try {
if (!context.id) {
throw error.create({
name: 'MISSING_ID',
message: 'Customer ID is required for update'
});
}
const customer = record.load({
type: record.Type.CUSTOMER,
id: context.id,
isDynamic: true
});
// Update fields
setCustomerFields(customer, context);
// Update addresses if provided
if (context.addresses) {
updateAddresses(customer, context.addresses);
}
customer.save();
return {
success: true,
id: context.id,
message: 'Customer updated successfully'
};
} catch (e) {
log.error('PUT Error', e.message);
return createErrorResponse(e);
}
};
/**
* DELETE - Deactivate customer
*/
const doDelete = (context) => {
log.audit('Customer API', `DELETE: ${JSON.stringify(context)}`);
try {
if (!context.id) {
throw error.create({
name: 'MISSING_ID',
message: 'Customer ID is required for deletion'
});
}
// Soft delete - set inactive
record.submitFields({
type: record.Type.CUSTOMER,
id: context.id,
values: {
'isinactive': true
}
});
return {
success: true,
id: context.id,
message: 'Customer deactivated successfully'
};
} catch (e) {
log.error('DELETE Error', e.message);
return createErrorResponse(e);
}
};
// Helper Functions
/**
* Validate required customer fields
*/
const validateCustomerData = (data) => {
const required = ['companyName', 'email'];
const missing = required.filter(field => !data[field]);
if (missing.length > 0) {
throw error.create({
name: 'VALIDATION_ERROR',
message: `Missing required fields: ${missing.join(', ')}`
});
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(data.email)) {
throw error.create({
name: 'VALIDATION_ERROR',
message: 'Invalid email format'
});
}
};
/**
* Set customer record fields
*/
const setCustomerFields = (customer, data) => {
const fieldMapping = {
'companyName': 'companyname',
'email': 'email',
'phone': 'phone',
'fax': 'fax',
'subsidiary': 'subsidiary',
'salesRep': 'salesrep',
'terms': 'terms',
'creditLimit': 'creditlimit',
'category': 'category',
'comments': 'comments'
};
Object.keys(fieldMapping).forEach(apiField => {
if (data[apiField] !== undefined) {
customer.setValue({
fieldId: fieldMapping[apiField],
value: data[apiField]
});
}
});
};
/**
* Add addresses to customer
*/
const addAddresses = (customer, addresses) => {
addresses.forEach(addr => {
customer.selectNewLine({ sublistId: 'addressbook' });
const addressSubrecord = customer.getCurrentSublistSubrecord({
sublistId: 'addressbook',
fieldId: 'addressbookaddress'
});
addressSubrecord.setValue({ fieldId: 'addr1', value: addr.address1 || '' });
addressSubrecord.setValue({ fieldId: 'addr2', value: addr.address2 || '' });
addressSubrecord.setValue({ fieldId: 'city', value: addr.city || '' });
addressSubrecord.setValue({ fieldId: 'state', value: addr.state || '' });
addressSubrecord.setValue({ fieldId: 'zip', value: addr.zip || '' });
addressSubrecord.setValue({ fieldId: 'country', value: addr.country || 'US' });
customer.setCurrentSublistValue({
sublistId: 'addressbook',
fieldId: 'label',
value: addr.label || 'Main Address'
});
if (addr.isDefaultBilling) {
customer.setCurrentSublistValue({
sublistId: 'addressbook',
fieldId: 'defaultbilling',
value: true
});
}
if (addr.isDefaultShipping) {
customer.setCurrentSublistValue({
sublistId: 'addressbook',
fieldId: 'defaultshipping',
value: true
});
}
customer.commitLine({ sublistId: 'addressbook' });
});
};
/**
* Add contacts to customer
*/
const addContacts = (customer, contacts) => {
contacts.forEach(contact => {
// Create contact record
const contactRecord = record.create({
type: record.Type.CONTACT
});
contactRecord.setValue({ fieldId: 'firstname', value: contact.firstName || '' });
contactRecord.setValue({ fieldId: 'lastname', value: contact.lastName || '' });
contactRecord.setValue({ fieldId: 'email', value: contact.email || '' });
contactRecord.setValue({ fieldId: 'phone', value: contact.phone || '' });
contactRecord.setValue({ fieldId: 'title', value: contact.title || '' });
// Link to company will be set automatically when saved
contactRecord.setValue({ fieldId: 'company', value: customer.id });
contactRecord.save();
});
};
/**
* Update existing addresses
*/
const updateAddresses = (customer, addresses) => {
// Remove existing addresses
const lineCount = customer.getLineCount({ sublistId: 'addressbook' });
for (let i = lineCount - 1; i >= 0; i--) {
customer.removeLine({ sublistId: 'addressbook', line: i });
}
// Add new addresses
addAddresses(customer, addresses);
};
/**
* Map customer record to API response
*/
const mapCustomerRecord = (customer) => {
return {
id: customer.id,
entityId: customer.getValue('entityid'),
companyName: customer.getValue('companyname'),
email: customer.getValue('email'),
phone: customer.getValue('phone'),
fax: customer.getValue('fax'),
subsidiary: customer.getText('subsidiary'),
salesRep: customer.getText('salesrep'),
terms: customer.getText('terms'),
creditLimit: customer.getValue('creditlimit'),
balance: customer.getValue('balance'),
isInactive: customer.getValue('isinactive'),
dateCreated: customer.getValue('datecreated')
};
};
/**
* Create standardized error response
*/
const createErrorResponse = (err) => {
return {
success: false,
error: {
name: err.name || 'UNKNOWN_ERROR',
message: err.message || 'An unexpected error occurred'
}
};
};
return {
get,
post,
put,
'delete': doDelete
};
});
Authentication
Token-Based Authentication (TBA)
┌─────────────────────────────────────────────────────────────────────────────┐
│ TBA AUTHENTICATION SETUP │
└─────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ 1. CREATE INTEGRATION RECORD │
│ ────────────────────────────────────────────────────────────────│
│ Setup → Integration → Manage Integrations → New │
│ ✓ Token-Based Authentication │
│ │
│ Get: Consumer Key + Consumer Secret │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ 2. CREATE ACCESS TOKEN │
│ ────────────────────────────────────────────────────────────────│
│ Setup → Users/Roles → Access Tokens → New │
│ Select: Application, User, Role │
│ │
│ Get: Token ID + Token Secret │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ 3. BUILD OAUTH SIGNATURE │
│ ────────────────────────────────────────────────────────────────│
│ Required Headers: │
│ • Authorization: OAuth realm="ACCOUNT_ID", │
│ oauth_consumer_key="...", │
│ oauth_token="...", │
│ oauth_signature_method="HMAC-SHA256", │
│ oauth_timestamp="...", │
│ oauth_nonce="...", │
│ oauth_version="1.0", │
│ oauth_signature="..." │
└──────────────────────────────────────────────────────────────────┘
RESTlet URL Format
https://{ACCOUNT_ID}.restlets.api.netsuite.com/app/site/hosting/restlet.nl
?script={SCRIPT_ID}
&deploy={DEPLOYMENT_ID}
Example:
https://1234567.restlets.api.netsuite.com/app/site/hosting/restlet.nl
?script=customscript_customer_api
&deploy=customdeploy_customer_api
Calling RESTlet from External Code
JavaScript (Node.js) Example
const crypto = require('crypto');
const OAuth = require('oauth-1.0a');
const config = {
accountId: 'YOUR_ACCOUNT_ID',
consumerKey: 'YOUR_CONSUMER_KEY',
consumerSecret: 'YOUR_CONSUMER_SECRET',
tokenId: 'YOUR_TOKEN_ID',
tokenSecret: 'YOUR_TOKEN_SECRET',
scriptId: 'customscript_customer_api',
deployId: 'customdeploy_customer_api'
};
const oauth = OAuth({
consumer: {
key: config.consumerKey,
secret: config.consumerSecret
},
signature_method: 'HMAC-SHA256',
hash_function(base_string, key) {
return crypto.createHmac('sha256', key)
.update(base_string)
.digest('base64');
}
});
const url = `https://${config.accountId}.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=${config.scriptId}&deploy=${config.deployId}`;
const token = {
key: config.tokenId,
secret: config.tokenSecret
};
// GET Request
const getCustomer = async (customerId) => {
const requestData = {
url: `${url}&id=${customerId}`,
method: 'GET'
};
const headers = oauth.toHeader(oauth.authorize(requestData, token));
headers['Content-Type'] = 'application/json';
const response = await fetch(requestData.url, {
method: 'GET',
headers: headers
});
return response.json();
};
// POST Request
const createCustomer = async (customerData) => {
const requestData = {
url: url,
method: 'POST'
};
const headers = oauth.toHeader(oauth.authorize(requestData, token));
headers['Content-Type'] = 'application/json';
const response = await fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(customerData)
});
return response.json();
};
// Usage
getCustomer('123').then(console.log);
createCustomer({
companyName: 'New Company',
email: 'info@newcompany.com'
}).then(console.log);
Python Example
import requests
from requests_oauthlib import OAuth1
config = {
'account_id': 'YOUR_ACCOUNT_ID',
'consumer_key': 'YOUR_CONSUMER_KEY',
'consumer_secret': 'YOUR_CONSUMER_SECRET',
'token_id': 'YOUR_TOKEN_ID',
'token_secret': 'YOUR_TOKEN_SECRET',
'script_id': 'customscript_customer_api',
'deploy_id': 'customdeploy_customer_api'
}
url = f"https://{config['account_id']}.restlets.api.netsuite.com/app/site/hosting/restlet.nl"
auth = OAuth1(
config['consumer_key'],
config['consumer_secret'],
config['token_id'],
config['token_secret'],
signature_method='HMAC-SHA256',
realm=config['account_id']
)
# GET Request
def get_customer(customer_id):
params = {
'script': config['script_id'],
'deploy': config['deploy_id'],
'id': customer_id
}
response = requests.get(
url,
params=params,
auth=auth,
headers={'Content-Type': 'application/json'}
)
return response.json()
# POST Request
def create_customer(customer_data):
params = {
'script': config['script_id'],
'deploy': config['deploy_id']
}
response = requests.post(
url,
params=params,
auth=auth,
headers={'Content-Type': 'application/json'},
json=customer_data
)
return response.json()
# Usage
customer = get_customer('123')
print(customer)
new_customer = create_customer({
'companyName': 'New Company',
'email': 'info@newcompany.com'
})
print(new_customer)
Script Deployment XML
<?xml version="1.0" encoding="UTF-8"?>
<restlet scriptid="customscript_customer_api">
<name>Customer API</name>
<scriptfile>[/SuiteScripts/RESTlets/customer_api_rl.js]</scriptfile>
<description>REST API for customer CRUD operations</description>
<isinactive>F</isinactive>
<notifyowner>T</notifyowner>
<scriptdeployments>
<scriptdeployment scriptid="customdeploy_customer_api">
<status>RELEASED</status>
<title>Customer API v1</title>
<isdeployed>T</isdeployed>
<loglevel>DEBUG</loglevel>
<allroles>F</allroles>
<audslctrole>
<role>[Administrator]</role>
<role>[Sales Manager]</role>
</audslctrole>
</scriptdeployment>
</scriptdeployments>
</restlet>
Error Handling Best Practices
/**
* Standardized error handling for RESTlets
*/
const createResponse = (success, data, errorInfo = null) => {
const response = {
success: success,
timestamp: new Date().toISOString()
};
if (success) {
response.data = data;
} else {
response.error = {
code: errorInfo?.name || 'UNKNOWN_ERROR',
message: errorInfo?.message || 'An error occurred',
details: errorInfo?.details || null
};
}
return response;
};
const get = (context) => {
try {
// Validate request
if (!context.id) {
return createResponse(false, null, {
name: 'MISSING_PARAMETER',
message: 'id parameter is required'
});
}
// Process request
const data = getRecord(context.id);
return createResponse(true, data);
} catch (e) {
log.error('RESTlet Error', e);
// Handle specific error types
if (e.name === 'RCRD_DSNT_EXIST') {
return createResponse(false, null, {
name: 'NOT_FOUND',
message: `Record with ID ${context.id} not found`
});
}
return createResponse(false, null, {
name: e.name,
message: e.message
});
}
};
Best Practices
| Practice | Description |
|---|---|
| Validate input | Check all required fields |
| Return consistent format | Use standard response structure |
| Log requests | Audit log all API calls |
| Handle errors gracefully | Return meaningful error messages |
| Use HTTPS only | Never expose over HTTP |
| Rate limiting | Implement in calling code |
| Version your API | Use deployment names for versions |
Security Considerations
┌─────────────────────────────────────────────────────────────────────────────┐
│ SECURITY CHECKLIST │
└─────────────────────────────────────────────────────────────────────────────┘
☑ Use Token-Based Authentication (never basic auth)
☑ Validate all input parameters
☑ Sanitize data before database operations
☑ Limit deployment roles to necessary users
☑ Log all API access for auditing
☑ Never expose internal IDs in error messages
☑ Implement rate limiting externally
☑ Use HTTPS exclusively
☑ Rotate tokens periodically
☑ Monitor for unusual access patterns
Next Steps
- Workflow Action - Workflow script actions
- Customer Portal Scenario - Real-world example
- Security Best Practices - Secure your APIs