RESTlets are the backbone of custom API development in NetSuite. They let you expose NetSuite data and business logic through HTTP endpoints that external systems can consume. If you need to build an integration that goes beyond what standard web services offer, RESTlets are the tool for the job.
What Are RESTlets?
RESTlets are SuiteScript-powered HTTP endpoints deployed inside NetSuite. Unlike Suitelets (which serve HTML pages), RESTlets are designed specifically for machine-to-machine communication. They accept and return data in JSON or plain text, making them ideal for:
- External system integrations (e-commerce platforms, CRMs, shipping providers)
- Mobile application backends
- Custom API layers for third-party developers
- Webhook receivers from external services
- Middleware connectors that bridge NetSuite with other platforms
RESTlets vs. Other Integration Options
| Feature | RESTlets | SOAP Web Services | SuiteTalk REST | Suitelets |
|---|---|---|---|---|
| Custom logic | Full SuiteScript | Limited | Limited | Full SuiteScript |
| Response format | JSON / Text | XML (SOAP) | JSON | HTML / JSON |
| Authentication | TBA / OAuth | TBA / OAuth | OAuth 2.0 | Session / Token |
| Governance | 5,000 units | N/A | N/A | 1,000 units |
| Use case | Custom APIs | Standard CRUD | Standard CRUD | UI pages |
The higher governance limit (5,000 units vs. 1,000 for Suitelets) makes RESTlets well-suited for complex operations that touch multiple records.
Basic RESTlet Structure
A RESTlet can define up to four entry points, each corresponding to an HTTP method:
/**
* @NApiVersion 2.1
* @NScriptType Restlet
*/
define([], () => {
/**
* Handles GET requests - retrieve data
* @param {Object} requestParams - URL query parameters
* @returns {Object|string} Response data
*/
const get = (requestParams) => {
return { message: 'GET response', params: requestParams };
};
/**
* Handles POST requests - create new resources
* @param {Object} requestBody - Parsed JSON request body
* @returns {Object|string} Response data
*/
const post = (requestBody) => {
return { message: 'POST response', received: requestBody };
};
/**
* Handles PUT requests - update existing resources
* @param {Object} requestBody - Parsed JSON request body
* @returns {Object|string} Response data
*/
const put = (requestBody) => {
return { message: 'PUT response', updated: requestBody };
};
/**
* Handles DELETE requests - remove resources
* @param {Object} requestParams - URL query parameters
* @returns {Object|string} Response data
*/
const doDelete = (requestParams) => {
return { message: 'DELETE response', deleted: requestParams };
};
return { get, post, put, delete: doDelete };
});A few things to note about this structure:
- GET and DELETE receive URL query parameters as a flat object (e.g.,
{ id: '123' }) - POST and PUT receive the parsed JSON request body as an object
- Return values are automatically serialized to JSON when you return an object
- The
deletekeyword is reserved in JavaScript, so the convention is to name the functiondoDeleteand map it in the return statement
Authentication Methods
RESTlets require authentication on every request. NetSuite supports two primary methods.
Token-Based Authentication (TBA)
TBA is the recommended approach for server-to-server integrations. It uses OAuth 1.0 signatures with four tokens:
- Consumer Key and Consumer Secret (from the integration record)
- Token ID and Token Secret (from a user-specific access token)
To set up TBA:
- Navigate to Setup > Company > Enable Features > SuiteCloud and enable Token-Based Authentication
- Create an Integration Record at Setup > Integration > Manage Integrations > New
- Record the Consumer Key and Consumer Secret (shown only once)
- Create an Access Token at Setup > Users/Roles > Access Tokens > New
- Record the Token ID and Token Secret (shown only once)
The external client must sign each request with an OAuth 1.0 header:
Authorization: OAuth
realm="ACCOUNT_ID",
oauth_consumer_key="CONSUMER_KEY",
oauth_token="TOKEN_ID",
oauth_nonce="UNIQUE_NONCE",
oauth_timestamp="UNIX_TIMESTAMP",
oauth_signature_method="HMAC-SHA256",
oauth_version="1.0",
oauth_signature="COMPUTED_SIGNATURE"
OAuth 2.0 (Client Credentials)
NetSuite also supports OAuth 2.0 with the Client Credentials flow, which is simpler for machine-to-machine scenarios:
- Register an OAuth 2.0 client in NetSuite under Setup > Integration > Manage Integrations
- Request an access token from the token endpoint
- Include the Bearer token in subsequent requests
POST https://ACCOUNT_ID.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=CLIENT_ID
&client_secret=CLIENT_SECRET
Then use the access token:
Authorization: Bearer ACCESS_TOKEN
Request and Response Handling
Returning JSON Responses
RESTlets automatically serialize objects to JSON. Structure your responses consistently:
/**
* @NApiVersion 2.1
* @NScriptType Restlet
*/
define(['N/record', 'N/search', 'N/log'], (record, search, log) => {
/**
* Standard response wrapper for consistent API output
* @param {boolean} success - Whether the operation succeeded
* @param {Object|Array|null} data - Response payload
* @param {string|null} errorMessage - Error details if applicable
* @returns {Object} Formatted response
*/
const buildResponse = (success, data, errorMessage) => {
return {
success: success,
timestamp: new Date().toISOString(),
data: data || null,
error: errorMessage || null
};
};
/**
* GET: Retrieve a customer record by internal ID
*/
const get = (requestParams) => {
const { id, fields } = requestParams;
if (!id) {
return buildResponse(false, null, 'Missing required parameter: id');
}
try {
const customerRecord = record.load({
type: record.Type.CUSTOMER,
id: parseInt(id, 10),
isDynamic: false
});
// Return specific fields or defaults
const requestedFields = fields ? fields.split(',') : [
'companyname', 'email', 'phone', 'entitystatus'
];
const customerData = { id: customerRecord.id };
requestedFields.forEach((fieldId) => {
customerData[fieldId.trim()] = customerRecord.getValue({
fieldId: fieldId.trim()
});
});
return buildResponse(true, customerData);
} catch (e) {
log.error('GET Customer Error', e.message);
return buildResponse(false, null, e.message);
}
};
return { get };
});Handling POST Data
POST requests deliver the parsed JSON body directly to your function. Use this for creating records:
/**
* POST: Create a new sales order from external system data
* @param {Object} requestBody - Order data from external system
* @returns {Object} Created order details
*/
const post = (requestBody) => {
log.audit('POST Received', JSON.stringify(requestBody));
// Validate the incoming payload
const validation = validateOrderPayload(requestBody);
if (!validation.valid) {
return buildResponse(false, null, validation.errors.join('; '));
}
try {
const salesOrder = record.create({
type: record.Type.SALES_ORDER,
isDynamic: true
});
// Set header fields
salesOrder.setValue({ fieldId: 'entity', value: parseInt(requestBody.customerId, 10) });
salesOrder.setValue({ fieldId: 'memo', value: requestBody.memo || '' });
salesOrder.setValue({ fieldId: 'otherrefnum', value: requestBody.externalOrderId || '' });
// Add line items
requestBody.items.forEach((item) => {
salesOrder.selectNewLine({ sublistId: 'item' });
salesOrder.setCurrentSublistValue({
sublistId: 'item',
fieldId: 'item',
value: parseInt(item.itemId, 10)
});
salesOrder.setCurrentSublistValue({
sublistId: 'item',
fieldId: 'quantity',
value: item.quantity
});
if (item.rate) {
salesOrder.setCurrentSublistValue({
sublistId: 'item',
fieldId: 'rate',
value: item.rate
});
}
salesOrder.commitLine({ sublistId: 'item' });
});
const orderId = salesOrder.save();
log.audit('Sales Order Created', `ID: ${orderId}`);
return buildResponse(true, {
orderId: orderId,
externalOrderId: requestBody.externalOrderId,
status: 'created'
});
} catch (e) {
log.error('POST Order Error', e.message);
return buildResponse(false, null, `Failed to create order: ${e.message}`);
}
};Input Validation
Never trust incoming data. Validate everything before it touches your business logic:
/**
* Validate the incoming order payload structure and data types
* @param {Object} payload - Request body to validate
* @returns {Object} Validation result { valid: boolean, errors: string[] }
*/
const validateOrderPayload = (payload) => {
const errors = [];
// Required fields
if (!payload.customerId) {
errors.push('customerId is required');
} else if (isNaN(parseInt(payload.customerId, 10))) {
errors.push('customerId must be a valid number');
}
if (!payload.items || !Array.isArray(payload.items)) {
errors.push('items must be a non-empty array');
} else if (payload.items.length === 0) {
errors.push('At least one line item is required');
} else {
payload.items.forEach((item, index) => {
if (!item.itemId) {
errors.push(`items[${index}].itemId is required`);
}
if (!item.quantity || item.quantity <= 0) {
errors.push(`items[${index}].quantity must be greater than 0`);
}
if (item.rate !== undefined && item.rate < 0) {
errors.push(`items[${index}].rate cannot be negative`);
}
});
}
// Optional field validation
if (payload.memo && typeof payload.memo !== 'string') {
errors.push('memo must be a string');
}
if (payload.memo && payload.memo.length > 999) {
errors.push('memo cannot exceed 999 characters');
}
return {
valid: errors.length === 0,
errors: errors
};
};Sanitizing Input
Beyond structural validation, sanitize values to prevent injection and data corruption:
/**
* Sanitize a string value for safe use in NetSuite fields
* @param {string} value - Raw input string
* @param {number} maxLength - Maximum allowed length
* @returns {string} Sanitized string
*/
const sanitizeString = (value, maxLength) => {
if (typeof value !== 'string') return '';
return value
.replace(/<[^>]*>/g, '') // Strip HTML tags
.replace(/[\x00-\x1F]/g, '') // Remove control characters
.trim()
.substring(0, maxLength);
};Error Handling Patterns
Robust error handling is critical for any production API. RESTlets should never return unhandled exceptions to callers.
Structured Error Handling
/**
* @NApiVersion 2.1
* @NScriptType Restlet
*/
define(['N/record', 'N/error', 'N/log'], (record, error, log) => {
/**
* Custom error class for API-specific errors
* @param {string} code - Machine-readable error code
* @param {string} message - Human-readable error message
* @param {number} httpStatus - Suggested HTTP status
*/
const ApiError = function(code, message, httpStatus) {
this.code = code;
this.message = message;
this.httpStatus = httpStatus || 400;
};
/**
* Wrap entry point logic with consistent error handling
* @param {Function} handler - The business logic function
* @returns {Function} Wrapped handler with error catching
*/
const withErrorHandling = (handler) => {
return (input) => {
try {
return handler(input);
} catch (e) {
// Handle our custom API errors
if (e instanceof ApiError) {
log.error(`API Error [${e.code}]`, e.message);
return {
success: false,
error: {
code: e.code,
message: e.message
}
};
}
// Handle NetSuite-specific errors
if (e.name && e.id) {
log.error(`NetSuite Error [${e.name}]`, e.message);
return {
success: false,
error: {
code: e.name,
message: e.message,
details: e.id
}
};
}
// Handle unexpected errors - don't expose internals
log.error('Unexpected Error', {
message: e.message,
stack: e.stack
});
return {
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred. Please contact support.'
}
};
}
};
};
const getHandler = (requestParams) => {
const { id } = requestParams;
if (!id) {
throw new ApiError('MISSING_PARAMETER', 'The id parameter is required');
}
const rec = record.load({
type: record.Type.CUSTOMER,
id: parseInt(id, 10)
});
return {
success: true,
data: {
id: rec.id,
name: rec.getValue({ fieldId: 'companyname' }),
email: rec.getValue({ fieldId: 'email' })
}
};
};
const deleteHandler = (requestParams) => {
const { id, type } = requestParams;
if (!id || !type) {
throw new ApiError('MISSING_PARAMETER', 'Both id and type parameters are required');
}
// Whitelist allowed record types for safety
const allowedTypes = ['customrecord_api_log', 'customrecord_temp_data'];
if (!allowedTypes.includes(type)) {
throw new ApiError(
'FORBIDDEN_TYPE',
`Deletion of record type '${type}' is not permitted through this API`
);
}
record.delete({ type: type, id: parseInt(id, 10) });
return {
success: true,
data: { id: id, type: type, status: 'deleted' }
};
};
return {
get: withErrorHandling(getHandler),
delete: withErrorHandling(deleteHandler)
};
});HTTP Status Codes
By default, a RESTlet returns 200 for any response and throws a 400-series error only on unhandled exceptions. To signal errors to callers within your JSON response, use a consistent success boolean and error object pattern as shown above. The calling system should check response.success rather than relying solely on the HTTP status code.
If you need the RESTlet to return an actual non-200 HTTP status, throw a standard N/error with a specific error code:
// This causes NetSuite to return a 400-level response
throw error.create({
name: 'INVALID_REQUEST',
message: 'The request payload is malformed',
notifyOff: true
});Practical Example: External Integration API
Here is a complete RESTlet that serves as an integration layer between an external e-commerce platform and NetSuite. It supports looking up items, creating orders, updating order status, and cancelling orders.
/**
* @NApiVersion 2.1
* @NScriptType Restlet
* @NModuleScope SameAccount
*
* E-Commerce Integration RESTlet
* Provides endpoints for external platforms to interact with
* NetSuite orders and inventory.
*/
define(['N/record', 'N/search', 'N/log', 'N/error', 'N/runtime'],
(record, search, log, error, runtime) => {
// ─── Configuration ────────────────────────────────────────
const CONFIG = {
MAX_ITEMS_PER_REQUEST: 50,
ALLOWED_ORDER_STATUSES: ['pendingApproval', 'pendingFulfillment', 'closed'],
LOG_PREFIX: 'EcomIntegration'
};
// ─── Helpers ──────────────────────────────────────────────
/**
* Build a consistent API response
*/
const respond = (success, data, errorInfo) => {
return {
success: success,
timestamp: new Date().toISOString(),
scriptUsage: runtime.getCurrentScript().getRemainingUsage(),
data: data || null,
error: errorInfo || null
};
};
/**
* Look up a customer by external ID
* @param {string} externalId - External system customer ID
* @returns {number|null} NetSuite internal ID
*/
const findCustomerByExternalId = (externalId) => {
const results = search.create({
type: search.Type.CUSTOMER,
filters: [
['externalid', 'is', externalId]
],
columns: ['internalid']
}).run().getRange({ start: 0, end: 1 });
return results.length > 0
? results[0].getValue({ name: 'internalid' })
: null;
};
/**
* Look up an item by SKU
* @param {string} sku - Item SKU
* @returns {Object|null} Item details
*/
const findItemBySku = (sku) => {
const results = search.create({
type: search.Type.ITEM,
filters: [
['itemid', 'is', sku],
'AND',
['isinactive', 'is', 'F']
],
columns: [
'internalid', 'itemid', 'displayname',
'baseprice', 'quantityavailable'
]
}).run().getRange({ start: 0, end: 1 });
if (results.length === 0) return null;
const result = results[0];
return {
internalId: result.getValue({ name: 'internalid' }),
sku: result.getValue({ name: 'itemid' }),
name: result.getValue({ name: 'displayname' }),
price: parseFloat(result.getValue({ name: 'baseprice' })) || 0,
quantityAvailable: parseInt(result.getValue({ name: 'quantityavailable' }), 10) || 0
};
};
// ─── GET: Retrieve inventory or order data ────────────────
/**
* Handle GET requests
* Supported actions:
* ?action=inventory&sku=ITEM-SKU
* ?action=order&id=ORDER_ID
* ?action=inventory_bulk&skus=SKU1,SKU2,SKU3
*/
const get = (requestParams) => {
const { action } = requestParams;
log.audit(`${CONFIG.LOG_PREFIX} GET`, `Action: ${action}`);
switch (action) {
case 'inventory': {
const { sku } = requestParams;
if (!sku) return respond(false, null, 'Parameter sku is required');
const item = findItemBySku(sku);
if (!item) return respond(false, null, `Item not found: ${sku}`);
return respond(true, item);
}
case 'inventory_bulk': {
const { skus } = requestParams;
if (!skus) return respond(false, null, 'Parameter skus is required');
const skuList = skus.split(',').slice(0, CONFIG.MAX_ITEMS_PER_REQUEST);
const items = skuList.map((sku) => {
const item = findItemBySku(sku.trim());
return item || { sku: sku.trim(), error: 'Not found' };
});
return respond(true, { items: items, count: items.length });
}
case 'order': {
const { id } = requestParams;
if (!id) return respond(false, null, 'Parameter id is required');
try {
const so = record.load({
type: record.Type.SALES_ORDER,
id: parseInt(id, 10)
});
const lineCount = so.getLineCount({ sublistId: 'item' });
const lines = [];
for (let i = 0; i < lineCount; i++) {
lines.push({
item: so.getSublistText({ sublistId: 'item', fieldId: 'item', line: i }),
quantity: so.getSublistValue({ sublistId: 'item', fieldId: 'quantity', line: i }),
rate: so.getSublistValue({ sublistId: 'item', fieldId: 'rate', line: i }),
amount: so.getSublistValue({ sublistId: 'item', fieldId: 'amount', line: i })
});
}
return respond(true, {
orderId: so.id,
status: so.getText({ fieldId: 'orderstatus' }),
customer: so.getText({ fieldId: 'entity' }),
total: so.getValue({ fieldId: 'total' }),
lines: lines
});
} catch (e) {
return respond(false, null, `Order not found: ${e.message}`);
}
}
default:
return respond(false, null, `Unknown action: ${action}. Supported: inventory, inventory_bulk, order`);
}
};
// ─── POST: Create new orders ──────────────────────────────
/**
* Handle POST requests - create a sales order
* Expected body:
* {
* "externalOrderId": "EXT-12345",
* "customerExternalId": "CUST-001",
* "items": [
* { "sku": "WIDGET-A", "quantity": 2, "rate": 29.99 },
* { "sku": "WIDGET-B", "quantity": 1 }
* ],
* "memo": "Order from web store",
* "shippingMethod": "fedexground"
* }
*/
const post = (requestBody) => {
log.audit(`${CONFIG.LOG_PREFIX} POST`, JSON.stringify(requestBody));
// Validate payload
if (!requestBody.customerExternalId) {
return respond(false, null, 'customerExternalId is required');
}
if (!requestBody.items || requestBody.items.length === 0) {
return respond(false, null, 'At least one item is required');
}
// Resolve customer
const customerId = findCustomerByExternalId(requestBody.customerExternalId);
if (!customerId) {
return respond(false, null, `Customer not found: ${requestBody.customerExternalId}`);
}
try {
const salesOrder = record.create({
type: record.Type.SALES_ORDER,
isDynamic: true
});
salesOrder.setValue({ fieldId: 'entity', value: parseInt(customerId, 10) });
salesOrder.setValue({ fieldId: 'otherrefnum', value: requestBody.externalOrderId || '' });
salesOrder.setValue({ fieldId: 'memo', value: requestBody.memo || '' });
// Resolve and add each line item by SKU
const lineErrors = [];
requestBody.items.forEach((lineItem, index) => {
const item = findItemBySku(lineItem.sku);
if (!item) {
lineErrors.push(`Line ${index}: SKU '${lineItem.sku}' not found`);
return;
}
salesOrder.selectNewLine({ sublistId: 'item' });
salesOrder.setCurrentSublistValue({
sublistId: 'item',
fieldId: 'item',
value: parseInt(item.internalId, 10)
});
salesOrder.setCurrentSublistValue({
sublistId: 'item',
fieldId: 'quantity',
value: lineItem.quantity
});
if (lineItem.rate) {
salesOrder.setCurrentSublistValue({
sublistId: 'item',
fieldId: 'rate',
value: lineItem.rate
});
}
salesOrder.commitLine({ sublistId: 'item' });
});
if (lineErrors.length > 0) {
return respond(false, null, lineErrors.join('; '));
}
const orderId = salesOrder.save();
log.audit(`${CONFIG.LOG_PREFIX} Order Created`, `ID: ${orderId}`);
return respond(true, {
orderId: orderId,
externalOrderId: requestBody.externalOrderId,
status: 'created'
});
} catch (e) {
log.error(`${CONFIG.LOG_PREFIX} POST Error`, e.message);
return respond(false, null, `Order creation failed: ${e.message}`);
}
};
// ─── PUT: Update order status ─────────────────────────────
/**
* Handle PUT requests - update order fields
* Expected body:
* {
* "orderId": 12345,
* "status": "pendingFulfillment",
* "memo": "Updated by integration"
* }
*/
const put = (requestBody) => {
log.audit(`${CONFIG.LOG_PREFIX} PUT`, JSON.stringify(requestBody));
if (!requestBody.orderId) {
return respond(false, null, 'orderId is required');
}
try {
const values = {};
if (requestBody.status) {
if (!CONFIG.ALLOWED_ORDER_STATUSES.includes(requestBody.status)) {
return respond(false, null,
`Invalid status. Allowed: ${CONFIG.ALLOWED_ORDER_STATUSES.join(', ')}`);
}
values.orderstatus = requestBody.status;
}
if (requestBody.memo) {
values.memo = requestBody.memo;
}
record.submitFields({
type: record.Type.SALES_ORDER,
id: parseInt(requestBody.orderId, 10),
values: values
});
return respond(true, {
orderId: requestBody.orderId,
updatedFields: Object.keys(values),
status: 'updated'
});
} catch (e) {
log.error(`${CONFIG.LOG_PREFIX} PUT Error`, e.message);
return respond(false, null, `Update failed: ${e.message}`);
}
};
// ─── DELETE: Close/cancel an order ────────────────────────
/**
* Handle DELETE requests - close a sales order
* ?id=12345&reason=customer_cancelled
*/
const doDelete = (requestParams) => {
const { id, reason } = requestParams;
log.audit(`${CONFIG.LOG_PREFIX} DELETE`, `Order: ${id}, Reason: ${reason}`);
if (!id) {
return respond(false, null, 'Parameter id is required');
}
try {
// Load and close the order rather than deleting it
const salesOrder = record.load({
type: record.Type.SALES_ORDER,
id: parseInt(id, 10),
isDynamic: true
});
salesOrder.setValue({
fieldId: 'orderstatus',
value: 'C' // Closed
});
salesOrder.setValue({
fieldId: 'memo',
value: `Closed via API. Reason: ${reason || 'Not specified'}`
});
salesOrder.save();
return respond(true, {
orderId: id,
status: 'closed',
reason: reason || 'Not specified'
});
} catch (e) {
log.error(`${CONFIG.LOG_PREFIX} DELETE Error`, e.message);
return respond(false, null, `Close failed: ${e.message}`);
}
};
return { get, post, put, delete: doDelete };
});Governance and Performance
RESTlets have a 5,000-unit governance limit per execution. Every API call you make within the script consumes units. Understanding the cost of common operations helps you stay within budget.
Governance Usage by Operation
| Operation | Governance Cost |
|---|---|
record.load() | 5 units |
record.create() + .save() | 10 units |
record.submitFields() | 2 units |
record.delete() | 4 units |
search.create() + .run() | 5 units |
search.lookupFields() | 1 unit |
N/log calls | 0 units |
Performance Optimization Tips
Use search.lookupFields() instead of record.load() when you only need a few field values:
// Expensive: 5 governance units
const rec = record.load({ type: 'customer', id: 123 });
const name = rec.getValue({ fieldId: 'companyname' });
// Cheaper: 1 governance unit
const fields = search.lookupFields({
type: 'customer',
id: 123,
columns: ['companyname', 'email']
});
const name = fields.companyname;Use record.submitFields() instead of load-modify-save for field updates:
// Expensive: 5 (load) + 10 (save) = 15 units
const rec = record.load({ type: 'salesorder', id: 456 });
rec.setValue({ fieldId: 'memo', value: 'Updated' });
rec.save();
// Cheaper: 2 units
record.submitFields({
type: 'salesorder',
id: 456,
values: { memo: 'Updated' }
});Monitor governance at runtime:
const script = runtime.getCurrentScript();
const remaining = script.getRemainingUsage();
log.debug('Governance', `Remaining units: ${remaining}`);
if (remaining < 500) {
log.warning('Low Governance', 'Less than 500 units remaining');
// Consider returning partial results
}Batch search results efficiently:
// Process search results in pages to control governance
const mySearch = search.create({
type: 'salesorder',
filters: [['mainline', 'is', 'T']],
columns: ['tranid', 'entity', 'total']
});
const pagedResults = mySearch.runPaged({ pageSize: 100 });
const allResults = [];
pagedResults.pageRanges.forEach((pageRange) => {
const page = pagedResults.fetch({ index: pageRange.index });
page.data.forEach((result) => {
allResults.push({
id: result.id,
tranId: result.getValue({ name: 'tranid' }),
customer: result.getText({ name: 'entity' }),
total: result.getValue({ name: 'total' })
});
});
});Deployment and Testing
Creating the Script Record
- Upload your RESTlet file to the File Cabinet under SuiteScripts (e.g.,
SuiteScripts/restlets/ecom_integration_rl.js) - Navigate to Customization > Scripting > Scripts > New
- Select your file and set the Script Type to RESTlet
- Map the entry points: GET, POST, PUT, DELETE to their corresponding function names
- Save the script record
Creating a Deployment
- From the Script record, click Deploy Script
- Set Status to Released
- Set the Audience (roles that can access this endpoint)
- Note the External URL - this is your API endpoint:
https://ACCOUNT_ID.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=SCRIPT_ID&deploy=DEPLOY_ID
Testing with Postman
Postman makes it straightforward to test RESTlets with TBA authentication.
Setting up authentication in Postman:
- Create a new request and set the URL to your RESTlet's External URL
- Go to the Authorization tab
- Select OAuth 1.0 as the type
- Fill in:
- Consumer Key
- Consumer Secret
- Access Token
- Token Secret
- Signature Method: HMAC-SHA256
- Add empty string to realm or your Account ID
- Under Advanced, set "Add authorization data to" to Request Headers
Example GET request:
GET https://1234567.restlets.api.netsuite.com/app/site/hosting/restlet.nl
?script=123&deploy=1&action=inventory&sku=WIDGET-A
Example POST request:
Set the body type to raw JSON:
{
"externalOrderId": "WEB-99001",
"customerExternalId": "CUST-001",
"items": [
{ "sku": "WIDGET-A", "quantity": 2, "rate": 29.99 },
{ "sku": "WIDGET-B", "quantity": 1 }
],
"memo": "Test order from Postman"
}Headers to include:
Content-Type: application/json
Debugging Tips
- Check the Execution Log on the Script Deployment page for
log.audit()andlog.error()entries - Use the Script Debugger in NetSuite for step-through debugging during development
- Monitor governance usage in logs to catch performance issues early
- Test with minimal permissions to verify role-based access works correctly
Security Best Practices
Principle of Least Privilege
Deploy your RESTlet with the most restrictive role possible. Create a dedicated integration role that only has access to the record types your API touches:
- Create a custom role at Setup > Users/Roles > Manage Roles > New
- Grant only the specific record permissions needed (e.g., Sales Order: Create/Edit, Customer: View)
- Assign this role to the integration user
- Set the deployment audience to this role only
Rate Limiting and Abuse Prevention
NetSuite does not provide built-in rate limiting for RESTlets. Implement your own tracking:
/**
* Simple request tracking using a custom record
* Check if the caller has exceeded their request quota
* @param {string} clientId - Identifier for the calling system
* @param {number} maxRequestsPerHour - Threshold
* @returns {boolean} Whether the request is allowed
*/
const checkRateLimit = (clientId, maxRequestsPerHour) => {
const oneHourAgo = new Date();
oneHourAgo.setHours(oneHourAgo.getHours() - 1);
const results = search.create({
type: 'customrecord_api_request_log',
filters: [
['custrecord_api_client_id', 'is', clientId],
'AND',
['created', 'onorafter', oneHourAgo.toISOString()]
],
columns: [search.createColumn({ name: 'internalid', summary: 'COUNT' })]
}).run().getRange({ start: 0, end: 1 });
const count = parseInt(results[0].getValue({
name: 'internalid',
summary: 'COUNT'
}), 10) || 0;
return count < maxRequestsPerHour;
};Additional Security Measures
- Whitelist record types - Never allow callers to specify arbitrary record types in API parameters. Maintain an explicit allowlist of supported types.
- Validate external IDs - When accepting external system identifiers, always resolve them to internal IDs and verify the record exists before operating on it.
- Audit all write operations - Use
log.audit()for every create, update, and delete. Include the caller identity, timestamp, and affected records. - Avoid exposing internal errors - Return generic error messages to external callers. Log the full stack trace internally for debugging.
- Use
@NModuleScope SameAccount- Prevent your RESTlet from being accessed across NetSuite accounts in multi-account environments. - Rotate tokens periodically - Establish a token rotation schedule and revoke old tokens promptly.
- Never log sensitive data - Avoid logging full request bodies that might contain credentials, payment data, or PII. Log only what you need for debugging.
Common Pitfalls
- Forgetting
Content-Type: application/json- Without this header, NetSuite may not parse the request body correctly on POST/PUT requests. - Returning undefined - If your function does not explicitly return a value, the caller receives an empty response. Always return an object or string.
- String vs. number IDs - NetSuite internal IDs are numbers, but they often arrive as strings from external systems. Always parse with
parseInt(). - Governance exhaustion on bulk operations - If your RESTlet processes many records in a single call, monitor
getRemainingUsage()and bail out gracefully before hitting the limit. - Testing only with admin roles - An admin role has access to everything. Test with the actual integration role to catch permission issues before production.
Next Steps
Now that you can build RESTlets, explore related SuiteScript capabilities:
- Creating Custom Suitelets for custom UI pages
- User Event Scripts for record-level automation
- Map/Reduce Scripts for bulk processing
Need custom API development for your NetSuite instance? Contact our development team for a consultation.