When you need to connect an external system to NetSuite, whether it's a web app, a mobile application, an eCommerce platform, or a third-party service, you'll quickly discover that NetSuite offers more than one way to expose data over HTTP. The two most common options are SuiteTalk REST (NetSuite's built-in REST API) and RESTlets (custom HTTP endpoints you build with SuiteScript).
Most developers reach for SuiteTalk REST first because it sounds like the obvious choice. It's a native REST API, it requires no custom code, and it's well-documented. But experienced NetSuite developers often end up building RESTlets instead, and for good reasons.
This guide explains both options, makes the case for when RESTlets are the better choice, and walks you through building a production-ready RESTlet from scratch.
What Is SuiteTalk REST?
SuiteTalk REST is NetSuite's out-of-the-box REST API. It follows a standard resource-based design where each NetSuite record type maps to a URL endpoint, and you interact with records using standard HTTP verbs.
GET /services/rest/record/v1/salesOrder/12345 → load a Sales Order
POST /services/rest/record/v1/salesOrder → create a Sales Order
PATCH /services/rest/record/v1/salesOrder/12345 → update a Sales Order
DELETE /services/rest/record/v1/salesOrder/12345 → delete a Sales Order
It supports OAuth 2.0, returns JSON, and handles many standard record operations without any custom code on the NetSuite side. If you need a simple CRUD integration against a single, well-supported record type, it works.
The problem is that real-world integrations are almost never that simple.
What Is a RESTlet?
A RESTlet is a SuiteScript script that you write and deploy as an HTTP endpoint inside NetSuite. It responds to GET, POST, PUT, and DELETE requests, and you control everything that happens when each request arrives: the business logic, the data transformation, the response shape, and the error handling.
From the outside, a RESTlet looks like any REST API. The caller sends an HTTP request to a URL and gets a JSON response back. From the inside, it's a SuiteScript 2.1 file that has access to the full N/* module library: records, searches, email, file cabinet, and everything else NetSuite exposes to scripts.
Here's what the simplest possible RESTlet looks like:
/**
* @NApiVersion 2.1
* @NScriptType Restlet
* @NModuleScope SameAccount
*/
define(['N/log'], (log) => {
const get = (requestParams) => {
log.debug({ title: 'GET request', details: JSON.stringify(requestParams) });
return { message: 'Hello from NetSuite' };
};
return { get };
});
Deploy this as a RESTlet, make a GET request to its URL, and you'll get back { "message": "Hello from NetSuite" }. Everything from here is just building on that foundation.
Why SuiteTalk REST Falls Short in Practice
SuiteTalk REST works well for simple scenarios. But as soon as your integration has any real business requirements, you'll start hitting its walls.
You're stuck with NetSuite's data model. SuiteTalk REST exposes records exactly as NetSuite structures them internally. If the external system needs data in a different shape (and it almost always does) you have two choices: transform the data in the external system, or use a RESTlet that does the transformation inside NetSuite.
Transforming on the external side sounds reasonable until you realize how complex NetSuite's data model is. A Sales Order has body fields, sublist lines, subrecords, linked records (customer, items, tax codes), and dynamic computed fields. Reconstructing a clean, business-friendly payload from raw SuiteTalk REST responses often requires multiple API calls and significant transformation code on the caller's side.
A RESTlet lets you fetch all the data you need in one shot, shape it exactly the way the caller expects, and return it in a single response.
Creating or updating anything complex requires multiple requests. With SuiteTalk REST, creating a Sales Order with line items means constructing a deeply nested JSON payload that exactly matches NetSuite's internal record schema, including all required fields, correct sublist structures, and proper internal IDs for all referenced records. If any part of the structure is wrong, you get a vague error.
With a RESTlet, the caller sends you whatever payload makes sense for the integration. Maybe that's a simple order object with SKUs instead of item internal IDs. Your RESTlet handles looking up item IDs, validating data, building the record, and returning a clean success or error response.
There's no business logic layer. SuiteTalk REST is purely data access. It has no concept of business rules. If creating a Sales Order should trigger a credit check, send a notification email, validate inventory, or stamp custom fields based on customer type, none of that happens through SuiteTalk REST unless you separately implement User Event scripts (which fire on record saves, including API-triggered saves, but add complexity and can cause unintended side effects in other contexts).
A RESTlet is code. You put your business logic directly in the endpoint. The caller sends a request, the RESTlet validates, processes, and responds, all in one controlled execution.
Searching and querying is cumbersome. SuiteTalk REST exposes a query endpoint, but building complex searches (multi-field filters, joins across record types, aggregations) is verbose and limited. Saved searches are one of NetSuite's most powerful native tools, and SuiteTalk REST doesn't give you natural access to them.
A RESTlet can execute any saved search or SuiteQL query, process the results, and return exactly what the caller needs. One HTTP call, the data you want, the shape you want.
Error handling is opaque. SuiteTalk REST errors are generic. You'll get HTTP 4xx responses with NetSuite's internal error codes, which can be difficult to map to actionable messages for the calling system. There's no opportunity to intercept an error, log additional context, attempt recovery, or translate it into a domain-specific message.
In a RESTlet, error handling is entirely in your hands. You can catch specific exceptions, log rich diagnostic information, return custom error codes and messages, and structure error responses so the caller can act on them intelligently.
The Case for RESTlets: A Direct Comparison
Here's how the two approaches stack up across the dimensions that matter in real integration work:
- Data shape: SuiteTalk REST gives you NetSuite's internal schema. A RESTlet lets you define your own.
- Business logic: SuiteTalk REST has none. A RESTlet gives you full SuiteScript access.
- Multi-record operations: SuiteTalk REST requires multiple requests. A RESTlet handles it in one request, one transaction.
- Search and query flexibility: SuiteTalk REST is limited. A RESTlet gives you full access to
N/searchand SuiteQL. - Error handling: SuiteTalk REST returns generic platform errors. A RESTlet returns custom, structured, logged errors.
- Versioning: SuiteTalk REST is platform-controlled. With a RESTlet, you control it.
- Custom record support: SuiteTalk REST offers partial support. A RESTlet gives you full support.
- Performance with complex payloads: SuiteTalk REST requires multiple round trips. A RESTlet executes server-side in a single call.
- Governance visibility: SuiteTalk REST offers none. A RESTlet gives you full
N/runtimeaccess.
The pattern is clear. SuiteTalk REST is optimized for simplicity. RESTlets are optimized for control. In real integration work, you almost always need control.
When SuiteTalk REST Is the Right Choice
To be fair, there are scenarios where SuiteTalk REST is genuinely the better option:
- Simple CRUD on standard records. If you're building a UI that needs to read and write straightforward records (customer addresses, contact information) without any business logic, SuiteTalk REST is faster to set up.
- Third-party iPaaS tools. Platforms like Celigo, Boomi, or MuleSoft have pre-built SuiteTalk REST connectors. If you're using one of these and the integration maps cleanly to standard record operations, the native connector may be faster than building a custom RESTlet.
- Prototyping. When you're exploring the data model and just need to make quick read calls, SuiteTalk REST is convenient.
- Your team isn't comfortable with SuiteScript. A RESTlet requires maintained custom code. If no one on your team owns SuiteScript, a RESTlet is a liability, not an asset.
Outside of these scenarios, RESTlets will serve you better in almost every production integration.
Building a Production-Ready RESTlet
Let's build a RESTlet that a real integration might use. This one accepts an order payload from an external eCommerce platform, validates it, creates a Sales Order in NetSuite, and returns a structured response.
The example demonstrates the full pattern: request parsing, validation, business logic, error handling, and response shaping.
The Request Contract
The external system will POST a payload like this:
{
"externalOrderId": "ORD-9981",
"customerEmail": "buyer@example.com",
"items": [
{ "sku": "WIDGET-A", "quantity": 2, "unitPrice": 49.99 },
{ "sku": "GADGET-B", "quantity": 1, "unitPrice": 129.00 }
],
"shippingMethod": "GROUND"
}
The RESTlet
/**
* @NApiVersion 2.1
* @NScriptType Restlet
* @NModuleScope SameAccount
*
* Endpoint: Inbound order creation from eCommerce platform.
* Accepts a simplified order payload, resolves internal IDs,
* creates a Sales Order, and returns a structured result.
*/
define(['N/record', 'N/search', 'N/log'], (record, search, log) => {
// -------------------------------------------------------------------------
// POST handler — create a Sales Order from an inbound order payload
// -------------------------------------------------------------------------
const post = (requestBody) => {
log.audit({
title: 'POST /createOrder — entry',
details: JSON.stringify({
externalOrderId: requestBody.externalOrderId,
customerEmail: requestBody.customerEmail,
itemCount: requestBody.items?.length
})
});
try {
// --- Step 1: Validate the incoming payload ---
const validationError = validatePayload(requestBody);
if (validationError) {
log.error({ title: 'Validation failed', details: validationError });
return errorResponse(400, 'VALIDATION_ERROR', validationError);
}
// --- Step 2: Resolve customer internal ID from email ---
const customerId = lookupCustomerByEmail(requestBody.customerEmail);
if (!customerId) {
return errorResponse(
404,
'CUSTOMER_NOT_FOUND',
`No customer found with email: ${requestBody.customerEmail}`
);
}
// --- Step 3: Resolve item internal IDs from SKUs ---
const resolvedItems = resolveItems(requestBody.items);
const unresolvedSkus = resolvedItems
.filter(i => !i.itemId)
.map(i => i.sku);
if (unresolvedSkus.length > 0) {
return errorResponse(
404,
'ITEMS_NOT_FOUND',
`Could not resolve SKUs: ${unresolvedSkus.join(', ')}`
);
}
// --- Step 4: Create the Sales Order ---
const soId = createSalesOrder({
customerId,
externalOrderId: requestBody.externalOrderId,
items: resolvedItems,
shippingMethod: requestBody.shippingMethod
});
log.audit({
title: 'POST /createOrder — success',
details: `Created SO ${soId} for external order ${requestBody.externalOrderId}`
});
return {
success: true,
netsuiteOrderId: soId,
externalOrderId: requestBody.externalOrderId
};
} catch (e) {
log.error({ title: e.name, details: `${e.message}\n${e.stack}` });
return errorResponse(500, 'INTERNAL_ERROR', 'An unexpected error occurred.');
}
};
// -------------------------------------------------------------------------
// GET handler — retrieve a Sales Order by external order ID
// -------------------------------------------------------------------------
const get = (requestParams) => {
log.audit({
title: 'GET /order — entry',
details: JSON.stringify(requestParams)
});
try {
const externalOrderId = requestParams.externalOrderId;
if (!externalOrderId) {
return errorResponse(400, 'MISSING_PARAM', 'externalOrderId is required');
}
// Search for the Sales Order by the external order ID stored in a custom field
const soSearch = search.create({
type: search.Type.SALES_ORDER,
filters: [
// VERIFY: 'custbody_external_order_id' is your custom field's Script ID
search.createFilter({
name: 'custbody_external_order_id',
operator: search.Operator.IS,
values: externalOrderId
})
],
columns: [
search.createColumn({ name: 'internalid' }),
search.createColumn({ name: 'tranid' }), // VERIFY
search.createColumn({ name: 'status' }), // VERIFY
search.createColumn({ name: 'total' }), // VERIFY
search.createColumn({ name: 'trandate' }) // VERIFY
]
});
const results = soSearch.run().getRange({ start: 0, end: 1 });
if (!results || results.length === 0) {
return errorResponse(
404,
'ORDER_NOT_FOUND',
`No order found for externalOrderId: ${externalOrderId}`
);
}
const result = results[0];
return {
success: true,
order: {
netsuiteOrderId: result.getValue({ name: 'internalid' }),
orderNumber: result.getValue({ name: 'tranid' }),
status: result.getText({ name: 'status' }),
total: result.getValue({ name: 'total' }),
date: result.getValue({ name: 'trandate' }),
externalOrderId
}
};
} catch (e) {
log.error({ title: e.name, details: `${e.message}\n${e.stack}` });
return errorResponse(500, 'INTERNAL_ERROR', 'An unexpected error occurred.');
}
};
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Validates the inbound POST payload.
* Returns an error message string if invalid, or null if valid.
*/
const validatePayload = (body) => {
if (!body.externalOrderId) return 'externalOrderId is required';
if (!body.customerEmail) return 'customerEmail is required';
if (!body.items || !Array.isArray(body.items) || body.items.length === 0) {
return 'items must be a non-empty array';
}
for (const item of body.items) {
if (!item.sku) return `Item is missing sku`;
if (!item.quantity || item.quantity <= 0) return `Item ${item.sku} has invalid quantity`;
if (!item.unitPrice || item.unitPrice < 0) return `Item ${item.sku} has invalid unitPrice`;
}
return null;
};
/**
* Looks up a Customer internal ID by email address.
* Returns the internal ID string, or null if not found.
* Governance: 10 units for the search run.
*/
const lookupCustomerByEmail = (email) => {
const results = search.create({
type: search.Type.CUSTOMER,
filters: [
search.createFilter({ name: 'email', operator: search.Operator.IS, values: email })
],
columns: [ search.createColumn({ name: 'internalid' }) ]
}).run().getRange({ start: 0, end: 1 });
return results.length > 0 ? results[0].getValue({ name: 'internalid' }) : null;
};
/**
* Resolves item internal IDs from SKUs using a single bulk search.
* Returns the original items array with itemId added to each.
* Governance: 10 units for the search run.
*/
const resolveItems = (items) => {
const skus = items.map(i => i.sku);
const results = search.create({
type: search.Type.ITEM,
filters: [
// VERIFY: 'itemid' is the SKU/name field for your item type
search.createFilter({
name: 'itemid',
operator: search.Operator.ANY_OF,
values: skus
})
],
columns: [
search.createColumn({ name: 'internalid' }),
search.createColumn({ name: 'itemid' }) // VERIFY
]
}).run().getRange({ start: 0, end: skus.length });
// Build a SKU → internal ID map from the search results
const skuToId = {};
for (const result of results) {
const sku = result.getValue({ name: 'itemid' });
const id = result.getValue({ name: 'internalid' });
skuToId[sku] = id;
}
// Attach the resolved internal ID to each item
return items.map(item => ({
...item,
itemId: skuToId[item.sku] || null
}));
};
/**
* Creates a Sales Order record and returns its internal ID.
* Governance: 10 units to create, 20 units to save.
*/
const createSalesOrder = ({ customerId, externalOrderId, items, shippingMethod }) => {
const so = record.create({
type: record.Type.SALES_ORDER,
isDynamic: true
});
// Set body fields
so.setValue({ fieldId: 'entity', value: customerId }); // VERIFY
so.setValue({ fieldId: 'memo', value: `eCommerce: ${externalOrderId}` }); // VERIFY
// VERIFY: 'custbody_external_order_id' is your custom field's Script ID
so.setValue({ fieldId: 'custbody_external_order_id', value: externalOrderId });
// Map shipping method string to NetSuite ship method internal ID
// VERIFY: replace with your actual shipping method internal IDs
const shippingMethodMap = {
'GROUND': 1, // Replace with real internal IDs
'EXPRESS': 2,
'OVERNIGHT': 3
};
const shipMethodId = shippingMethodMap[shippingMethod];
if (shipMethodId) {
so.setValue({ fieldId: 'shipmethod', value: shipMethodId }); // VERIFY
}
// Add line items
for (const item of items) {
so.selectNewLine({ sublistId: 'item' });
so.setCurrentSublistValue({ sublistId: 'item', fieldId: 'item', value: item.itemId });
so.setCurrentSublistValue({ sublistId: 'item', fieldId: 'quantity', value: item.quantity });
so.setCurrentSublistValue({ sublistId: 'item', fieldId: 'rate', value: item.unitPrice }); // VERIFY
so.commitLine({ sublistId: 'item' });
}
return so.save({ enableSourcing: true, ignoreMandatoryFields: false });
};
/**
* Builds a consistent error response object.
*/
const errorResponse = (httpStatus, code, message) => ({
success: false,
error: { code, message }
// Note: RESTlets always return HTTP 200. The httpStatus param here
// is included in the response body for the caller to interpret.
// See "HTTP Status Codes" section below for more on this.
});
// -------------------------------------------------------------------------
// Export handlers
// -------------------------------------------------------------------------
return { get, post };
});
That's a good amount of code, so let's walk through what's happening.
The POST handler follows a clear pipeline. First, it validates the incoming payload, checking that required fields are present and that item data is well-formed. Then it resolves the customer's email address to a NetSuite internal ID using a search. Next, it resolves all SKUs to item internal IDs in a single bulk search (not one search per SKU, which would burn through your governance budget). Finally, it creates the Sales Order and returns a structured response.
The GET handler lets the calling system retrieve an order by its external order ID. This is a common pattern: the external system creates an order, stores the NetSuite order ID from the response, and later queries for status updates using its own identifier.
Notice the errorResponse helper function. Every failure path returns a consistent structure with a success flag, an error code, and a human-readable message. This matters more than you might think. I'll explain why in a moment.
Deploying a RESTlet
Once your script file is written and uploaded to the File Cabinet, you deploy it like any other SuiteScript:
- Go to Customization > Scripting > Scripts > New
- Select your script file
- NetSuite detects the
@NScriptType Restletannotation and sets the type automatically - Set a Script ID (e.g.,
customscript_ecomm_order_endpoint) - Save, then click Deploy Script
- On the deployment record, set:
- Status: Released
- Log Level: DEBUG during development, AUDIT in production
- Roles: Which roles have access to call this endpoint
- Save the deployment
After saving, NetSuite generates the External URL for the RESTlet. It'll look something like:
https://[accountId].restlets.api.netsuite.com/app/site/hosting/restlet.nl
?script=[scriptId]&deploy=[deployId]
This is the URL your external system will call.
Authentication
RESTlets support two authentication mechanisms: Token-Based Authentication (TBA) and OAuth 2.0. For most server-to-server integrations, TBA is the simpler and more common choice.
Token-Based Authentication (TBA)
TBA uses an integration record in NetSuite plus a set of access tokens to authenticate API calls. The caller includes OAuth 1.0a authorization headers with every request.
Setting it up involves three steps:
- Create an Integration record: Go to Customization > Integrations > Manage Integrations > New. Enable Token-Based Authentication and save. NetSuite generates a Consumer Key and Consumer Secret. Copy these immediately. They're only shown once.
- Create an Access Token: Go to Setup > Users/Roles > Access Tokens > New. Select the Integration you just created, then select the User and Role that the integration will run as. Save. NetSuite generates a Token ID and Token Secret. Again, copy these immediately.
- Build the Authorization header in your external system using OAuth 1.0a with the Consumer Key, Consumer Secret, Token ID, Token Secret, and HMAC-SHA256 as the signature method.
Most HTTP client libraries have OAuth 1.0a support built in. You can test this quickly with Postman, which has native OAuth 1.0 support. The header looks like:
Authorization: OAuth
realm="[accountId]",
oauth_consumer_key="[consumerKey]",
oauth_token="[tokenId]",
oauth_signature_method="HMAC-SHA256",
oauth_timestamp="[timestamp]",
oauth_nonce="[nonce]",
oauth_version="1.0",
oauth_signature="[signature]"
OAuth 2.0
OAuth 2.0 is preferred for scenarios where end users authenticate interactively, for example, when a user grants your app access to their NetSuite data. For server-to-server integrations without a user in the loop, TBA is simpler and equally secure.
HTTP Status Codes: An Important Nuance
Here's something that trips up almost every developer building their first RESTlet: RESTlets always return HTTP 200, regardless of what happened during execution. If your logic throws an unhandled error, NetSuite returns HTTP 200 with an error payload in the body, not a 4xx or 5xx.
This means the calling system can't rely on HTTP status codes alone to detect failures. You need to build a consistent response contract that the caller can inspect:
// Success
{ "success": true, "netsuiteOrderId": 12345, "externalOrderId": "ORD-9981" }
// Failure
{ "success": false, "error": { "code": "CUSTOMER_NOT_FOUND", "message": "..." } }
The caller should always check success first, not the HTTP status code. Document this clearly for anyone consuming your RESTlet.
There is one exception. If your RESTlet throws an unhandled exception (one that escapes your try/catch), NetSuite will return an HTTP 500 with its own error format. This is why every entry point should have a top-level try/catch that catches everything and returns a structured error response rather than letting exceptions propagate.
Versioning Your RESTlet
Unlike SuiteTalk REST, where versioning is controlled by NetSuite, your RESTlet's API contract is entirely your responsibility. I've found it's worth planning for versioning from the start, even if you think you won't need it.
A practical approach is to include a version in your request payload:
const post = (requestBody) => {
const version = requestBody.apiVersion || 'v1';
if (version === 'v1') {
return handleV1Post(requestBody);
} else if (version === 'v2') {
return handleV2Post(requestBody);
} else {
return errorResponse(400, 'UNSUPPORTED_VERSION', `API version '${version}' is not supported`);
}
};
Alternatively, create separate script deployments for each major version. This keeps the code cleaner but requires managing multiple deployments.
Either way, never change your RESTlet's request or response contract without a migration plan. External systems that call your endpoint will break silently if you change field names, remove fields, or alter response structures without notice.
Governance Considerations
RESTlets run with the same governance limits as other SuiteScript types: 1,000 units per execution by default. For most single-record operations this is plenty, but there are patterns to watch for.
Bulk operations via RESTlet. If your endpoint accepts a batch of records to create or update, each record load costs 10 units and each save costs 20 units. A batch of 30 records is 900 units, almost the full budget, with nothing left for searches or other operations.
For batch imports, consider these approaches:
- Accept the batch via RESTlet and queue the work to a Map/Reduce script for processing
- Limit batch size in your validation logic
- Return a job ID to the caller that they can poll for completion status
Search costs. Each search.run() costs 10 units. Multiple lookups (customer by email, items by SKU) add up. In the example above, we resolved all SKUs in a single bulk search rather than one search per SKU. Always batch your lookups.
Log remaining governance at key points during development:
const script = runtime.getCurrentScript();
log.debug({
title: 'Governance remaining',
details: script.getRemainingUsage()
});
Security Best Practices
Always validate inputs. Never trust the payload from an external system. Validate required fields, data types, value ranges, and string lengths before touching any NetSuite records. The validatePayload function in the example above shows the pattern. Expand it to cover every field your RESTlet accepts.
Use the principle of least privilege for your integration user. The Access Token you create for TBA runs as a specific user with a specific role. That role should have only the permissions the integration actually needs, nothing more. If the integration only creates Sales Orders, its role shouldn't have permission to delete records or access payroll data.
Don't log sensitive data. Avoid logging full request payloads if they contain PII, payment data, or credentials. Log enough to diagnose problems (IDs, counts, status codes) without logging things that would be a liability in audit logs.
Rate limit awareness. NetSuite enforces concurrency limits on RESTlet calls (the exact limits depend on your license tier). If your external system can fire many concurrent requests, build retry logic with exponential backoff on the caller side.
A Complete Request/Response Example
Putting it all together, here's what a full interaction with the RESTlet looks like.
Request (from eCommerce platform):
POST https://[accountId].restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=123&deploy=1
Content-Type: application/json
Authorization: OAuth realm="[accountId]", ...
{
"externalOrderId": "ORD-9981",
"customerEmail": "buyer@example.com",
"items": [
{ "sku": "WIDGET-A", "quantity": 2, "unitPrice": 49.99 },
{ "sku": "GADGET-B", "quantity": 1, "unitPrice": 129.00 }
],
"shippingMethod": "GROUND"
}
Success Response:
HTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
"netsuiteOrderId": 45678,
"externalOrderId": "ORD-9981"
}
Failure Response (customer not found):
HTTP/1.1 200 OK
Content-Type: application/json
{
"success": false,
"error": {
"code": "CUSTOMER_NOT_FOUND",
"message": "No customer found with email: buyer@example.com"
}
}
Note again: both responses return HTTP 200. The caller must inspect success to determine the outcome.
When to Split One RESTlet Into Multiple
As your integration grows, resist the temptation to handle every operation in a single massive RESTlet. In practice, I've found it works best to create purpose-built RESTlets for distinct integration domains:
customscript_integration_ordersfor order creation, retrieval, and status updatescustomscript_integration_inventoryfor inventory queries and adjustmentscustomscript_integration_customersfor customer lookup and creationcustomscript_integration_fulfillmentfor fulfillment and shipping updates
Each RESTlet is focused, easier to test, easier to debug (the execution logs are separated by script), and easier to manage permissions for. The URLs are different, so the calling system routes requests to the right endpoint for the right operation.
The alternative, one giant RESTlet with a type field in the payload that dispatches to different handlers, is harder to maintain, harder to permission, and produces intermingled execution logs that are difficult to read.
Making the Decision: RESTlet vs. SuiteTalk REST
If you're building a new integration between NetSuite and an external system, here's a straightforward way to think about it:
- Is it simple CRUD on a single, standard record type with no business logic? SuiteTalk REST is probably fine.
- Is a third-party iPaaS (Celigo, Boomi, MuleSoft) handling the integration? Use the native connector. It wraps SuiteTalk REST already.
- Everything else? Build a RESTlet. Complex payloads that need transformation, business logic like validation and lookups and computed fields, multi-record operations in a single call, custom records, specific error handling and structured responses: these all point to a RESTlet.
Where to Go From Here
Once you're building RESTlets, these topics become immediately relevant:
N/searchin depth. How to write efficient searches, handle large result sets, and use SuiteQL for complex queries.- Map/Reduce for batch processing. How to queue bulk work from a RESTlet and process it asynchronously.
N/httpsfor outbound calls. Making HTTP requests from SuiteScript, useful when your RESTlet needs to call back to an external system.- User Event context guards. If your RESTlet creates or modifies records that also have User Event scripts, you need to understand how those scripts interact with API-triggered saves.
RESTlets are one of the most powerful tools in the SuiteScript toolkit. Once you've built one, you'll find yourself reaching for them every time an integration requirement comes up, because the control they give you is simply unmatched by any out-of-the-box alternative.
All field IDs, script IDs, and internal IDs in this guide are marked // VERIFY or noted as placeholders. Always confirm values against your account's Records Browser and configuration before deploying to production.