How to Integrate with NetSuite REST API Using OAuth 2.0
> The Client Secret is your only chance to capture it. Store it in a secrets manager, not a config file.

On this page
NetSuite plans to end support for Token-Based Authentication (TBA) starting with the 2027.1 release, you will no longer be able to create new TBA integrations for SOAP, REST Web Services, or RESTlets after that point. Existing TBA integrations have a tentative deadline of 2028.1. If you're still generating Consumer Keys and Access Tokens under Setup > Integration > Managing Integrations, you're building on a foundation with a firm expiration date. This guide walks through the current OAuth 2.0 Machine-to-Machine flow for REST Web Services, including the exact SuiteQL endpoint you'll query and the pagination pattern the API returns.
Step 1: Create the Integration Record
Navigate to Setup > Integration > Managing Integrations > New. Fill in:
| Field | Value |
|---|---|
| Name | YourApp_REST_Connection |
| State | Enabled |
| Authentication | OAuth 2.0 (select Client Credentials for server-to-server) |
| Scopes | Check REST Web Services |
For server-to-server integrations, choose the Client Credentials grant type (Machine-to-Machine). This uses a JWT assertion, no user consent screen required. Save the record. NetSuite displays a Client ID and Client Secret exactly once. Copy both immediately; they cannot be retrieved again.
The Client Secret is your only chance to capture it. Store it in a secrets manager, not a config file.
You'll also upload a public certificate in the integration record's Certificate field. The corresponding private key signs your JWT assertions.
Step 2: Generate a JWT Assertion
Machine-to-Machine auth requires a signed JWT sent to the token endpoint. The payload must include:
// Node.js example using jsonwebtoken
const jwt = require('jsonwebtoken');
const fs = require('fs');
const privateKey = fs.readFileSync('private.pem', 'utf8'); // PKCS#8 format
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: 'YOUR_CLIENT_ID', // Client ID from Step 1
scope: 'rest_webservices', // Must match scope exactly
aud: 'https://YOUR_ACCOUNT_ID.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token',
iat: now,
exp: now + 3600 // 1 hour max
};
const assertion = jwt.sign(payload, privateKey, { algorithm: 'RS256' });Replace YOUR_ACCOUNT_ID with your NetSuite account ID (lowercase, no hyphens, e.g., 1234567). The private key corresponds to the public certificate you uploaded in the integration record.
Step 3: Exchange JWT for Access Token
POST the assertion to the token endpoint:
curl -X POST \
'https://1234567.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials' \
-d 'client_id=YOUR_CLIENT_ID' \
-d 'client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer' \
-d 'client_assertion=YOUR_JWT_ASSERTION'Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600
}Cache the access_token and refresh before expires_in seconds elapse. Do not request a new token for every API call, that burns governance and triggers rate limits.
Step 4: Query Data with SuiteQL
The REST Web Services SuiteQL endpoint accepts POST requests with a SQL body. Base URL pattern:
https://{accountId}.suitetalk.api.netsuite.com/services/rest/query/v1/suiteqlExample request:
curl -X POST \
'https://1234567.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql' \
-H 'Authorization: Bearer ACCESS_TOKEN' \
-H 'Content-Type: application/json' \
-H 'Prefer: transient' \
-d '{"q": "SELECT id, entityid, email, subsidiary FROM customer WHERE isinactive = false ORDER BY id"}'Key headers:
Prefer: transient, prevents NetSuite from creating a saved search for the query (saves governance)Content-Type: application/json, required
Response structure:
{
"items": [
{ "id": "1001", "entityid": "ABC Corp", "email": "[email protected]", "subsidiary": "1" }
],
"totalResults": 1,
"links": [
{ "rel": "next", "href": "/services/rest/query/v1/suiteql?offset=1000&limit=1000" }
]
}Field naming note: REST Web Services uses SuiteTalk field IDs (camelCase), not SuiteScript field IDs (lowercase). For example, the customer record's entityid field appears as entityId in SuiteTalk responses. This trips people up when moving between SuiteScript and REST Web Services.
Step 5: Handle Pagination Correctly
The links array contains a next relation when more rows exist. Follow it until links is empty or absent:
async function fetchAllCustomers(accessToken, accountId) {
const baseUrl = `https://${accountId}.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql`;
let url = baseUrl;
const allItems = [];
while (url) {
const res = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'Prefer': 'transient'
},
body: JSON.stringify({
q: "SELECT id, entityid, email FROM customer WHERE isinactive = false ORDER BY id"
})
});
if (!res.ok) {
const err = await res.json();
throw new Error(`SuiteQL error ${res.status}: ${err['o:errorDetails'][0]?.detail}`);
}
const data = await res.json();
allItems.push(...data.items);
const nextLink = data.links?.find(l => l.rel === 'next');
url = nextLink ? `https://${accountId}.suitetalk.api.netsuite.com${nextLink.href}` : null;
}
return allItems;
}The offset and limit parameters in the next href are managed by NetSuite, don't construct them manually.
Step 6: Write Data via REST Web Services
For creates and updates, use the record-specific endpoints under /services/rest/record/v1/{recordType}. Example: create a vendor bill.
curl -X POST \
'https://1234567.suitetalk.api.netsuite.com/services/rest/record/v1/vendorbill' \
-H 'Authorization: Bearer ACCESS_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"entity": { "id": "456" },
"subsidiary": { "id": "1" },
"trandate": "2025-01-15",
"duedate": "2025-02-14",
"expenseList": {
"expenses": [
{ "account": { "id": "167" }, "amount": 250.00, "memo": "Consulting" }
]
}
}'Field names match the REST Web Services schema, not SuiteScript field IDs. entity takes an object with id (internal ID), not entityid. trandate and duedate use ISO 8601 dates. The expenseList wrapper is required; you cannot post a flat array.
A 400 response with
"o:errorCode": "INVALID_KEY_OR_REF"means a referenced record (vendor, account, subsidiary) doesn't exist or isn't accessible to the integration's role.
Common Failure Points
| Symptom | Cause | Fix |
|---|---|---|
401 Invalid JWT | Clock skew > 5 min | Sync server time via NTP |
403 Forbidden on SuiteQL | Integration role lacks Lists > Customers > View | Edit the role under Setup > Users/Roles > Manage Roles |
429 Too Many Requests | Exceeded concurrency governance | Implement exponential backoff; batch writes |
totalResults missing | Query returned > 1000 rows without Prefer: transient | Always send Prefer: transient header |
Migration Checklist for Existing TBA Integrations
- Inventory every integration using Consumer Key/Secret + Token/Token Secret pairs
- Create new OAuth 2.0 integration records for each (Client Credentials for servers)
- Rotate credentials, revoke old TBA tokens in Setup > Users/Roles > Access Tokens
- Update token endpoints, same endpoint, different grant type
- Test SuiteQL queries, auth header format changes from TBA signature to Bearer token
- Monitor governance, OAuth 2.0 tokens expire hourly; cache aggressively
What About RESTlets?
RESTlets remain fully supported and use the same OAuth 2.0 Client Credentials flow. Deploy a SuiteScript 2.1 RESTlet, then call its external URL with the same Bearer token. Use RESTlets when you need custom business logic, multi-record transactions, or SuiteScript-only APIs (e.g., N/render, N/format). Use native REST Web Services for standard CRUD on supported record types, it's faster to develop and governed at a lower unit cost.
Next time you spin up a NetSuite connection, start at Setup > Integration > Managing Integrations with OAuth 2.0 Client Credentials. The TBA path closes for new work in 2027.1, and the SOAP endpoint follows in 2028.1.


