🔹 What is a RESTlet?
A RESTlet is a SuiteScript that exposes endpoints (GET/POST/PUT/DELETE) so external systems can read/write NetSuite data over HTTPS. Think of it as your custom NetSuite API.
Typical use cases
- Pull customers, items, balances to an external app
- Create/update orders, invoices, or custom records from outside NetSuite
- Integrate with storefronts, ERPs, WMS, billing gateways, data pipelines
Auth options
- Token-Based Authentication (TBA) – recommended (HMAC, least privilege, revocable)
- NLAuth (User/Pass/Role) – legacy; only if you must
- OAuth 2.0 – supported via REST Web Services (not RESTlets directly)
🔹 RESTlet Structure (SuiteScript 2.1)
A RESTlet exports named handlers:
get(context)
post(context)
put(context)
delete(context)
Each handler returns JSON (object/array/primitive) → NetSuite serializes to JSON HTTP response.
🔹 Example 1 — Read: GET Customer by ID (returns JSON)
/**
* @NApiVersion 2.1
* @NScriptType Restlet
*/
define(['N/record'], (record) => {
// GET /restlet?customerId=123
const get = (requestParams) => {
try {
// 1) Extract query param from URL
const customerId = parseInt(requestParams.customerId || '0', 10);
if (!customerId) return { ok: false, error: 'Missing customerId' };
// 2) Load record
const cust = record.load({ type: record.Type.CUSTOMER, id: customerId });
// 3) Return only the fields you want to expose (never dump entire record blindly)
return {
ok: true,
data: {
id: customerId,
entityid: cust.getValue('entityid'),
email: cust.getValue('email'),
phone: cust.getValue('phone'),
isinactive: !!cust.getValue('isinactive')
}
};
} catch (e) {
// 4) Log internally, return redacted message externally
log.error('RESTlet GET error', JSON.stringify(e));
return { ok: false, error: e.message || 'Unexpected error' };
}
};
return { get };
});
Notes
- Keep responses explicit (whitelist fields).
- Always try–catch and log full errors on server; return safe messages to caller.
🔹 Example 2 — Search: GET with Filters + Pagination (runPaged)
/**
* @NApiVersion 2.1
* @NScriptType Restlet
*/
define(['N/search'], (search) => {
// GET /restlet?emailContains=@example.com&page=0&pageSize=50
const get = (params) => {
try {
const emailContains = (params.emailContains || '').trim();
const page = Math.max(parseInt(params.page || '0', 10), 0);
const pageSize = Math.min(Math.max(parseInt(params.pageSize || '50', 10), 1), 1000);
const filters = [['isinactive', 'is', 'F']];
if (emailContains) filters.push('AND', ['email', 'contains', emailContains]);
const s = search.create({
type: search.Type.CUSTOMER,
filters,
columns: ['internalid', 'entityid', 'email']
});
// 1) Paged results for large sets
const paged = s.runPaged({ pageSize });
if (page >= paged.pageRanges.length) {
return { ok: true, page, pageSize, totalPages: paged.pageRanges.length, data: [] };
}
// 2) Load requested page
const pageData = paged.fetch({ index: page });
const rows = pageData.data.map(r => ({
id: r.id,
entityid: r.getValue('entityid'),
email: r.getValue('email')
}));
return { ok: true, page, pageSize, totalPages: paged.pageRanges.length, data: rows };
} catch (e) {
log.error('RESTlet search error', JSON.stringify(e));
return { ok: false, error: e.message || 'Search failed' };
}
};
return { get };
});
Notes
runPaged
handles very large result sets.- Provide
page
andpageSize
to clients for reliable pagination.
🔹 Example 3 — Create: POST Customer (validate input; return new ID)
/**
* @NApiVersion 2.1
* @NScriptType Restlet
*/
define(['N/record'], (record) => {
// POST /restlet body = { entityid, email, phone }
const post = (body) => {
try {
// 1) Basic validation
const entityid = (body.entityid || '').trim();
const email = (body.email || '').trim();
if (!entityid) return { ok: false, error: 'entityid is required' };
// 2) Create record
const rec = record.create({ type: record.Type.CUSTOMER, isDynamic: true });
rec.setValue({ fieldId: 'entityid', value: entityid });
if (email) rec.setValue({ fieldId: 'email', value: email });
if (body.phone) rec.setValue({ fieldId: 'phone', value: String(body.phone) });
// 3) Save and return ID
const id = rec.save();
return { ok: true, id };
} catch (e) {
log.error('RESTlet POST error', JSON.stringify(e));
// Optionally map certain errors to 409/400 semantics in the payload
return { ok: false, error: e.message || 'Create failed' };
}
};
return { post };
});
Notes
- Validate required fields.
- Consider de-dupe checks before create (e.g., search existing by email).
🔹 Example 4 — Update: PUT Customer (partial update with submitFields)
/**
* @NApiVersion 2.1
* @NScriptType Restlet
*/
define(['N/record'], (record) => {
// PUT /restlet body = { id, email?, phone?, isinactive? }
const put = (body) => {
try {
const id = parseInt(body.id || '0', 10);
if (!id) return { ok: false, error: 'id is required' };
const values = {};
if (body.email !== undefined) values.email = String(body.email);
if (body.phone !== undefined) values.phone = String(body.phone);
if (body.isinactive !== undefined) values.isinactive = !!body.isinactive;
if (Object.keys(values).length === 0) {
return { ok: false, error: 'No updatable fields provided' };
}
// submitFields = faster governance than load/save
record.submitFields({ type: record.Type.CUSTOMER, id, values });
return { ok: true, id };
} catch (e) {
log.error('RESTlet PUT error', JSON.stringify(e));
return { ok: false, error: e.message || 'Update failed' };
}
};
return { put };
});
Notes
- Use
submitFields
for fast partial updates. - Treat PUT as idempotent (safe to retry).
🔹 Example 5 — Delete: soft vs hard
Most integrations prefer soft delete (e.g., set isinactive = true
) instead of physical delete.
/**
* @NApiVersion 2.1
* @NScriptType Restlet
*/
define(['N/record'], (record) => {
// DELETE /restlet body = { id, softDelete: true }
const doDelete = (body) => {
try {
const id = parseInt(body.id || '0', 10);
const soft = body.softDelete !== false; // default true
if (!id) return { ok: false, error: 'id is required' };
if (soft) {
record.submitFields({
type: record.Type.CUSTOMER,
id,
values: { isinactive: true }
});
} else {
record.delete({ type: record.Type.CUSTOMER, id });
}
return { ok: true, id, softDeleted: soft };
} catch (e) {
log.error('RESTlet DELETE error', JSON.stringify(e));
return { ok: false, error: e.message || 'Delete failed' };
}
};
// Expose as "delete" handler name
return { delete: doDelete };
});
🔹 Calling Your RESTlet from Outside (cURL / Postman)
Token-Based Auth (recommended) – cURL sample
curl -X GET "https://<account>.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=customscript_my_rl&deploy=1&customerId=123" \
-H "Authorization: NLAuth nlauth_account=<ACCOUNT_ID>, nlauth_consumer_key=<CK>, nlauth_token=<TK>, nlauth_signature=<SIG>, nlauth_version=2" \
-H "Content-Type: application/json" \
-H "Accept: application/json"
Build the OAuth 1.0 signature (
nlauth_signature
) with your Consumer/Token secrets. Most teams use Postman or an OAuth library to generate headers. (In Postman, choose OAuth 1.0 and set the NetSuite values.)
Legacy NLAuth (user/pass/role) – only if you must
curl -X POST "https://<account>.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=customscript_my_rl&deploy=1" \
-H "Authorization: NLAuth nlauth_account=<ACCOUNT_ID>, nlauth_email=<EMAIL>, nlauth_signature=<PASSWORD>, nlauth_role=<ROLE_ID>" \
-H "Content-Type: application/json" \
-d '{"entityid":"ACME-1001","email":"ops@acme.com"}'
Security reminders
- Prefer TBA with least-privilege role.
- Whitelist the script/deployment permissions to only the records/fields needed.
- Never hardcode secrets in code; store in Script Parameters (or NetSuite Secrets Manager if available).
- Use HTTPS only; restrict IPs if possible.
🔹 Making Outbound Calls from NetSuite (to external APIs)
Use N/https
for integrations from NetSuite (e.g., push order to WMS).
/**
* @NApiVersion 2.1
* @NScriptType Restlet
*/
define(['N/https', 'N/runtime'], (https, runtime) => {
// POST /restlet to forward payload to external API
const post = (body) => {
try {
// 1) Read endpoint/key from Script Parameters (never hardcode)
const script = runtime.getCurrentScript();
const endpoint = script.getParameter({ name: 'custscript_ext_api_url' });
const apiKey = script.getParameter({ name: 'custscript_ext_api_key' });
// 2) Forward the incoming payload to the external API
const resp = https.post({
url: endpoint,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify(body || {})
});
// 3) Return external response to caller (or transform it)
return {
ok: resp.code >= 200 && resp.code < 300,
status: resp.code,
response: resp.body
};
} catch (e) {
log.error('Outbound API error', JSON.stringify(e));
return { ok: false, error: e.message || 'Outbound failed' };
}
};
return { post };
});
Notes
- Keep timeouts and retries in mind (idempotency!).
- For large payloads, consider file staging + IDs, not big JSON blobs.
🔹 CORS & Browsers
- RESTlets are for server-to-server calls.
- Directly calling from a browser app often hits CORS issues.
- Solution: call your own server → your server calls the RESTlet.
🔹 Error Handling & Contracts
- Always return a consistent JSON envelope:
{ ok, data?, error? }
. - Log internal details, return safe messages.
- Define clear status semantics in payload (e.g.,
ok:false, code:'VALIDATION'
). - For bulk endpoints, return per-item results to avoid all-or-nothing failures.
🔹 Governance & Performance
- Prefer
submitFields
for small updates. - Use searches with
runPaged
for large reads. - For heavy processes triggered via RESTlet, enqueue a Map/Reduce (via
N/task
) and return a job ID. - Cache reference data (e.g., accounts/items) when possible to reduce repeated loads.
✅ Key Takeaway
RESTlets are your custom NetSuite API. With secure auth, clear request/response contracts, robust error handling, and attention to governance, you can integrate NetSuite with virtually any system—safely and at scale.