NetSuite RESTlets are server-side SuiteScript scripts that expose custom RESTful endpoints within Oracle NetSuite. They support GET, POST, PUT, and DELETE HTTP methods, and can return JSON or plain text responses. RESTlets operate within SuiteScript 2.x and have complete access to all NetSuite APIs including N/record, N/search, N/query, N/http, and N/file modules. This card covers all NetSuite editions that include SuiteCloud licensing (required for custom scripts). [src2]
RESTlets are distinct from NetSuite's built-in REST Web Services (record-based CRUD via SuiteTalk REST) and from Suitelets (which can serve HTML pages to browsers). RESTlets are purpose-built for machine-to-machine integration where custom server-side logic is required. [src6]
| Property | Value |
|---|---|
| Vendor | Oracle |
| System | Oracle NetSuite (SuiteScript 2.x) |
| API Surface | RESTlet (custom REST endpoints via SuiteScript) |
| Current API Version | SuiteScript 2.x (2025.1+) |
| Editions Covered | All editions with SuiteCloud license (Standard, Mid-Market, Enterprise) |
| Deployment | Cloud |
| API Docs | SuiteScript 2.x RESTlet Reference |
| Status | GA (Generally Available) |
RESTlets are one of several integration surfaces available in NetSuite. This table compares all major options so agents can route users to the correct card. [src6]
| API Surface | Protocol | Best For | Max Records/Request | Governance Limit | Real-time? | Custom Logic? |
|---|---|---|---|---|---|---|
| RESTlet | HTTPS/JSON or text | Custom integration logic, complex multi-step operations | Unlimited (governance-limited) | 5,000 units/execution | Yes | Full SuiteScript access |
| REST Web Services | HTTPS/JSON | Standard record CRUD, no custom code needed | 1,000 records/page | Account-level concurrency | Yes | No (record-based only) |
| SuiteTalk SOAP | HTTPS/XML | Legacy integrations, metadata operations | 1,000 records/page | Account-level concurrency | Yes | No (record-based only) |
| Suitelet | HTTPS/HTML or JSON | Custom UI pages, portals, internal tools | Unlimited (governance-limited) | 1,000 units/execution | Yes | Full SuiteScript access |
| Scheduled Script | N/A (cron-based) | Batch processing, nightly ETL | Unlimited (governance-limited) | 10,000 units/execution | No (scheduled) | Full SuiteScript access |
| Map/Reduce | N/A (async) | High-volume data processing, parallelized | Unlimited (governance-limited) | 10,000 units/stage | No (async) | Full SuiteScript access |
| CSV Import | HTTPS/CSV | Bulk data loading, manual or scripted imports | 25,000 rows per file | N/A | No (batch) | Limited (transform scripts) |
| Limit Type | Value | Applies To | Notes |
|---|---|---|---|
| Governance units per execution | 5,000 | Each RESTlet invocation | 5x more than Suitelets (1,000), same as User Event (all phases combined) [src1] |
| Max input payload size | 10 MB | Request body (JSON or text) | Guaranteed minimum; larger payloads may occasionally succeed but not reliably [src1] |
| Max output payload size | 10 MB | Response body (JSON or text) | Same 10 MB guarantee applies to output [src1] |
| Max URL length | ~2,048 chars | GET requests | URL parameters for GET requests are size-limited |
| Service Tier | Base Concurrency | + Per SuiteCloud Plus License | Example (3 SC+ licenses) |
|---|---|---|---|
| Standard | 5 | +10 each | 35 |
| Premium | 10 | +10 each | 40 |
| Enterprise | 20 | +10 each | 50 |
| Ultimate | 40 | +10 each | 70 |
Formula: Total Concurrency = Base Tier Limit + (SuiteCloud Plus Licenses x 10). This pool is shared across ALL web services + RESTlets since 2017.2. [src4, src7]
| Limit Type | Value | Window | Notes |
|---|---|---|---|
| Default concurrency per integration record | 5 threads | Concurrent | Configurable per integration record; max is (account limit - 1) [src4] |
| Per-user RESTlet concurrency | 5 concurrent executions | Concurrent | Individual users face a ceiling of 5 concurrent RESTlet executions [src7] |
| SuiteCloud Plus license | +10 concurrent threads each | Concurrent | Purchased add-on; does NOT always auto-provision to sandbox [src4] |
NetSuite tracks governance usage per SuiteScript API call within each RESTlet execution. Exceeding 5,000 units terminates the script immediately. [src1]
| Operation | Governance Cost | Notes |
|---|---|---|
| record.load() -- Transaction record | 10 units | Sales orders, invoices, purchase orders |
| record.load() -- Non-transaction record | 5 units | Customers, items, vendors |
| record.load() -- Custom record | 2 units | Custom record types |
| record.submit() -- Transaction | 20 units | Creates/updates transaction records |
| record.submit() -- Non-transaction | 10 units | Creates/updates standard records |
| record.submit() -- Custom record | 4 units | Creates/updates custom records |
| search.create() + run | 10 units | Saved search execution |
| search.lookupFields() | 1 unit | Lightweight alternative to record.load() |
| http.request() (external callout) | 10 units | External HTTP calls from within RESTlet |
| email.send() | 10 units | Sending emails from RESTlet |
| file.load() | 10 units | Loading files from File Cabinet |
Check remaining governance at runtime with script.getRemainingUsage(). [src1]
RESTlets always require authentication for external calls. Internal calls (from other SuiteScript) are authenticated automatically. [src2]
| Flow | Use When | Token Lifetime | Refresh? | Notes |
|---|---|---|---|---|
| Token-Based Authentication (TBA) | Server-to-server integrations, most common | Does not expire (until revoked) | N/A -- tokens are permanent | Uses OAuth 1.0 signature; requires integration record + token pair [src2] |
| OAuth 2.0 | Modern integrations, recommended for new dev | Access token: configurable (default 60 min) | Yes -- refresh token flow | Requires connected app setup; preferred over TBA for new integrations [src2] |
| User Credentials (NLAuth) | Legacy/testing only | Session-based | No | Deprecated -- do NOT use in production; no MFA support |
START -- User needs to integrate with NetSuite via custom REST endpoints
|-- What's the integration pattern?
| |-- Real-time (individual records, <1s latency)
| | |-- Needs custom business logic?
| | | |-- YES --> RESTlet (this card)
| | | |-- NO --> REST Web Services (standard CRUD)
| | |-- Data volume per call?
| | | |-- < 50 records --> Single RESTlet call
| | | |-- 50-500 records --> RESTlet with batched operations (watch governance)
| | | |-- > 500 records --> Split into multiple RESTlet calls or use Map/Reduce
| | |-- Need to combine multiple operations atomically?
| | |-- YES --> RESTlet (can wrap in single transaction)
| | |-- NO --> REST Web Services (simpler, no code to maintain)
| |-- Batch/Bulk (scheduled, high volume)
| | |-- < 500 records per batch?
| | | |-- YES --> RESTlet called on schedule (external scheduler)
| | | |-- NO --> Scheduled Script or Map/Reduce (10,000 units)
| | |-- Need external trigger?
| | |-- YES --> RESTlet (external system calls on schedule)
| | |-- NO --> Scheduled Script (internal NetSuite scheduler)
| |-- Event-driven
| | |-- External system needs to push to NetSuite?
| | | |-- YES --> RESTlet as webhook receiver
| | | |-- NO --> User Event Script (internal triggers)
| | |-- NetSuite needs to push to external system?
| | |-- YES --> User Event Script + N/http callout
| | |-- NO --> N/A
| |-- File-based (CSV/XML)
| |-- Use CSV Import or SuiteTalk SOAP (not RESTlet)
|-- Which direction?
| |-- Inbound (writing to NetSuite) --> RESTlet POST/PUT entry points
| |-- Outbound (reading from NetSuite) --> RESTlet GET entry point
| |-- Bidirectional --> Design conflict resolution strategy FIRST
|-- Error tolerance?
|-- Zero-loss required --> Implement idempotency keys + retry logic in caller
|-- Best-effort --> Fire-and-forget with exponential backoff retry
| Entry Point | HTTP Method | Parameter Type | Typical Use | Notes |
|---|---|---|---|---|
get(requestParams) | GET | URL query parameters (parsed as object) | Retrieve records, run searches | Parameters auto-parsed to key-value object [src2] |
post(requestBody) | POST | JSON request body (parsed as object) | Create records, execute operations | Body must be valid JSON [src2] |
put(requestBody) | PUT | JSON request body (parsed as object) | Update/upsert records | Body must be valid JSON [src2] |
delete(requestParams) | DELETE | URL query parameters (parsed as object) | Delete records | Parameters auto-parsed to key-value object [src2] |
https://{accountId}.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script={scriptId}&deploy={deployId}
The URL is provided on the Script Deployment record after deployment. [src5]
Upload a SuiteScript 2.x file to the File Cabinet. The script exports entry point functions for each HTTP method. [src2, src5]
/**
* @NApiVersion 2.x
* @NScriptType Restlet
* @NModuleScope SameAccount
*/
define(['N/record', 'N/search', 'N/log'], function(record, search, log) {
function get(requestParams) {
log.debug('GET params', JSON.stringify(requestParams));
var customerId = requestParams.customerId;
if (!customerId) throw new Error('Missing required parameter: customerId');
var rec = record.load({ type: record.Type.CUSTOMER, id: customerId });
return {
id: rec.id,
companyName: rec.getValue('companyname'),
email: rec.getValue('email'),
phone: rec.getValue('phone')
};
}
function post(requestBody) {
log.debug('POST body', JSON.stringify(requestBody));
var rec = record.create({ type: record.Type.CUSTOMER });
rec.setValue('companyname', requestBody.companyName);
rec.setValue('email', requestBody.email);
var id = rec.save();
return { success: true, id: id };
}
return { get: get, post: post };
});
Verify: Upload file to File Cabinet > SuiteScripts folder. Confirm file appears in File Cabinet listing.
Navigate to Customization > Scripting > Scripts > New, select the uploaded file, save the Script Record, then create a Deployment with Status = Released. [src5]
Script Record:
Name: "Customer RESTlet"
Script File: SuiteScripts/customer_restlet.js
Script ID: _customer_restlet
Deployment Record:
Title: "Customer RESTlet Deployment"
Deployed: Yes
Status: Released <-- MUST be Released for external access
Execute As: <Integration Role>
Log Level: Debug (development) / Error (production)
Verify: After saving the deployment, the External URL field shows the endpoint URL.
Create an Integration Record and generate token credentials. [src2]
1. Setup > Integration > Manage Integrations > New
- Name: "My Integration"
- State: Enabled
- Token-Based Authentication: checked
-> Save. Copy Consumer Key + Consumer Secret (shown only once).
2. Setup > Integration > Manage Tokens > New Access Token
- Application: "My Integration"
- User: <integration user>
- Role: <integration role>
-> Save. Copy Token ID + Token Secret (shown only once).
Verify: Attempt an authenticated GET request (see cURL example below).
Use the OAuth 1.0 (TBA) signed request to invoke the RESTlet. [src2]
curl -X GET \
"https://ACCOUNT_ID.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=SCRIPT_ID&deploy=DEPLOY_ID&customerId=12345" \
-H "Authorization: OAuth realm=\"ACCOUNT_ID\",oauth_consumer_key=\"CK\",oauth_token=\"TK\",oauth_signature_method=\"HMAC-SHA256\",oauth_timestamp=\"TS\",oauth_nonce=\"NONCE\",oauth_version=\"1.0\",oauth_signature=\"SIG\"" \
-H "Content-Type: application/json"
Verify: Response is 200 OK with JSON body containing customer data.
# Input: NetSuite account ID, consumer/token credentials, RESTlet script/deploy IDs
# Output: JSON response from RESTlet
import requests
from requests_oauthlib import OAuth1
# Pin: requests==2.31.0, requests-oauthlib==1.3.1
auth = OAuth1(
client_key="CONSUMER_KEY",
client_secret="CONSUMER_SECRET",
resource_owner_key="TOKEN_ID",
resource_owner_secret="TOKEN_SECRET",
realm="ACCOUNT_ID",
signature_method="HMAC-SHA256",
)
url = "https://ACCOUNT_ID.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=123&deploy=1"
response = requests.get(url + "&customerId=12345", auth=auth,
headers={"Content-Type": "application/json"}, timeout=30)
print(response.json())
// Input: NetSuite credentials, RESTlet URL
// Output: JSON response from RESTlet
// Pin: [email protected]
const OAuth = require('oauth-1.0a');
const crypto = require('crypto');
const oauth = OAuth({
consumer: { key: 'CONSUMER_KEY', secret: 'CONSUMER_SECRET' },
signature_method: 'HMAC-SHA256',
hash_function(base_string, key) {
return crypto.createHmac('sha256', key).update(base_string).digest('base64');
},
});
const token = { key: 'TOKEN_ID', secret: 'TOKEN_SECRET' };
const url = 'https://ACCOUNT_ID.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=123&deploy=1&customerId=12345';
const authHeader = oauth.toHeader(oauth.authorize({ url, method: 'GET' }, token));
authHeader.Authorization += ', realm="ACCOUNT_ID"';
fetch(url, { headers: { ...authHeader, 'Content-Type': 'application/json' } })
.then(r => r.json()).then(console.log);
# Input: OAuth 2.0 client credentials
# Output: RESTlet JSON response
# Step 1: Obtain access token
ACCESS_TOKEN=$(curl -s -X POST \
"https://ACCOUNT_ID.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token" \
-d "grant_type=client_credentials&client_id=CLIENT_ID&client_secret=CLIENT_SECRET" \
| jq -r '.access_token')
# Step 2: Call RESTlet with Bearer token
curl -X GET \
"https://ACCOUNT_ID.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=123&deploy=1&customerId=12345" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json"
| SuiteScript Type | JSON Representation | Input Parsing | Output Serialization | Gotcha |
|---|---|---|---|---|
| String | "value" | Direct | Direct | NetSuite internally trims whitespace on some fields |
| Integer (internal ID) | 12345 | Auto-parsed from JSON | Returns as number | Must be numeric -- string IDs cause silent failures on record.load() |
| Boolean | true/false | NetSuite uses "T"/"F" internally | RESTlet can return native booleans | getValue() returns "T" not true |
| Date | "3/1/2026" | Must match user date format preference | Returns as formatted string | Date format depends on execution user's preferences -- not ISO 8601 |
| Currency | 99.99 | Auto-parsed | Returns as number | Precision depends on currency setup; multi-currency needs explicit conversion |
| Multi-select | [1, 2, 3] | Array of internal IDs | Returns array | Must use setValue with array; individual calls overwrite |
| Sublist line items | Nested array | Must iterate with setSublistValue() | Must iterate with getSublistValue() | No bulk sublist set -- each line costs governance units |
N/format.parse() to ensure correct parsing. [src2]parseInt() before passing to record.load(). [src2]99.999 for USD silently rounds to 100.00. [src7]| HTTP Code | SuiteScript Error | Meaning | Cause | Resolution |
|---|---|---|---|---|
| 400 | UNEXPECTED_ERROR | Unhandled runtime error | Script threw uncaught exception | Wrap in try/catch; return structured error in body [src3] |
| 400 | SSS_INVALID_SCRIPTLET_ID | RESTlet not found | Wrong script/deploy ID, or deployment not Released | Verify script ID, deploy ID, status = Released [src3] |
| 401 | INVALID_LOGIN_ATTEMPT | Authentication failed | Bad OAuth signature, expired token, wrong account ID | Regenerate OAuth signature; verify credentials [src3] |
| 403 | FORBIDDEN | Wrong domain | RESTlet URL pointed at wrong data center | Use dynamic URL from deployment record [src3] |
| 405 | METHOD_NOT_ALLOWED | Unsupported HTTP method | RESTlet doesn't export entry point for method used | Add the missing entry point function [src3] |
| 415 | UNSUPPORTED_MEDIA_TYPE | Wrong Content-Type | Content-Type not application/json or text/plain | Set Content-Type: application/json [src3] |
| 302 | MOVED_TEMPORARILY | Wrong data center | Request routed to incorrect NetSuite data center | Regenerate URL; follow redirect with re-signed request [src3] |
| 503 | SERVICE_UNAVAILABLE | Database offline | Maintenance window or connectivity issue | Retry with backoff; check status.netsuite.com [src3] |
Pre-calculate governance cost; split large batches into multiple RESTlet calls. [src1]Chunk into <5 MB batches with pagination tokens. [src1]Client-side concurrency throttling; monitor via Web Services Usage Log. [src4]Always use N/format.parse() and N/format.format(); standardize user preferences. [src2]Use dedicated integration user; monitor for 401 errors. [src7]After any bundle install, verify all RESTlet deployments are Released. [src5]// BAD -- you CANNOT control HTTP status codes in RESTlets
function post(requestBody) {
var rec = record.load({ type: 'customer', id: requestBody.id });
if (!rec) {
return { status: 404, message: 'Customer not found' }; // Still returns HTTP 200!
}
return rec;
}
// GOOD -- embed error semantics in a consistent response envelope
function post(requestBody) {
try {
var rec = record.load({ type: record.Type.CUSTOMER, id: requestBody.id });
return { success: true, data: { id: rec.id, name: rec.getValue('companyname') } };
} catch (e) {
return { success: false, error: { code: e.name, message: e.message } };
}
}
// BAD -- record.load() costs 5-10 governance units; 500 records = 2,500-5,000 units
function get(requestParams) {
var results = [];
var ids = requestParams.ids.split(',');
ids.forEach(function(id) {
var rec = record.load({ type: record.Type.CUSTOMER, id: id }); // 5 units each
results.push({ id: rec.id, name: rec.getValue('companyname') });
});
return results;
}
// GOOD -- search.lookupFields = 1 unit; search.create = 10 units total
function get(requestParams) {
var ids = requestParams.ids.split(',');
var s = search.create({
type: search.Type.CUSTOMER,
filters: [['internalid', 'anyof', ids]],
columns: ['companyname', 'email']
});
var results = [];
s.run().each(function(result) {
results.push({ id: result.id, name: result.getValue('companyname') });
return true;
});
return results; // 10 units total regardless of result count
}
// BAD -- duplicate POSTs create duplicate records
function post(requestBody) {
var rec = record.create({ type: record.Type.SALES_ORDER });
rec.setValue('entity', requestBody.customerId);
var id = rec.save(); // Creates NEW record every time
return { id: id };
}
// GOOD -- check for existing record using external ID first
function post(requestBody) {
var externalId = requestBody.externalOrderId;
var existing = search.lookupFields({
type: 'salesorder', id: externalId, columns: ['internalid']
});
if (existing && existing.internalid) {
return { success: true, id: existing.internalid, created: false };
}
var rec = record.create({ type: record.Type.SALES_ORDER });
rec.setValue('externalid', externalId);
rec.setValue('entity', requestBody.customerId);
var id = rec.save();
return { success: true, id: id, created: true };
}
Use the URL from the deployment record's External URL field. Implement 302 redirect following. [src3]Pre-calculate governance cost. Use search.lookupFields (1 unit) instead of record.load (5-10 units) when you only need a few fields. [src1]Load-test with production-scale data volumes. Use script.getRemainingUsage() to monitor. [src1]Disable auto-redirect; re-sign the request with the new URL and retry. [src3]Verify @NApiVersion annotation is 2.x. Use N/ module imports only. [src2]Always include Content-Type: application/json. [src3]# Check account-level API/RESTlet usage (from within NetSuite)
# Navigate to: Setup > Integration > Web Services Usage Log
# Filters: Script Type = RESTlet, Date range, Status
# Test TBA authentication from command line
curl -v -X GET \
"https://ACCOUNT_ID.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=ID&deploy=ID" \
-H "Authorization: OAuth realm=\"ACCOUNT_ID\",...,oauth_signature=\"SIG\"" \
-H "Content-Type: application/json"
# Expected: 200 OK (or 401 if credentials wrong)
# Check remaining governance (add to SuiteScript)
# log.debug('Remaining', runtime.getCurrentScript().getRemainingUsage());
# View: Customization > Scripting > Script Execution Log
# Verify deployment status
# Navigate to: Customization > Scripting > Script Deployments
# Filter: Script Type = RESTlet, Status = Released
# Monitor concurrency
# Setup > Integration > Web Services Usage Log > Concurrency tab
# Check NetSuite system status
# https://status.netsuite.com
| SuiteScript Version | Release | Status | Breaking Changes | Migration Notes |
|---|---|---|---|---|
| SuiteScript 2.1 | 2020.1 | Current | ES2019 syntax supported (async/await, arrow functions) | Use @NApiVersion 2.1; all 2.0 code is compatible |
| SuiteScript 2.0 | 2015.2 | Supported | Module-based architecture (define/require) | N/ module imports replace nlapiXxx functions |
| SuiteScript 1.0 | Legacy | Maintenance-only | Different entry point signatures, different governance | Not recommended; 1.0 has recovery points (2.x does not) |
Account-level concurrency governance introduced in 2017.2 -- the most significant change affecting RESTlet design patterns. [src4]
Oracle NetSuite supports SuiteScript APIs indefinitely but marks older patterns as "not recommended." SuiteScript 1.0 is still functional but receives no new features. API changes are communicated via quarterly release notes (two releases per year: .1 and .2). Breaking changes are rare and announced 2 releases in advance. [src2]
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Custom business logic in NetSuite's context | Standard record CRUD only | NetSuite REST Web Services |
| Multiple operations in single atomic call | >500 records per operation | Scheduled Script or Map/Reduce |
| External system pushes data in real-time | HTML pages or forms for end users | Suitelets |
| Custom response formats (aggregated/computed) | File-based bulk import (CSV, XML) | CSV Import or SuiteTalk SOAP |
| Webhook receiver for external events | Long-running operations (>30 seconds) | Scheduled Script with status tracking |
| Expose NetSuite data to non-NetSuite apps | Real-time change notifications FROM NetSuite | User Event Scripts + N/http callout |
@NApiVersion 2.1 annotation -- 2.0 scripts cannot use ES2019 syntax. [src2]