Suite Utils
Back to Blog
Ecosystem InsightsSep 24, 2026 • 6 min read

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.

Arav SharmaArav SharmaCore SuiteScript & Integration Engineer
How to Integrate with NetSuite REST API Using OAuth 2.0
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:

FieldValue
NameYourApp_REST_Connection
StateEnabled
AuthenticationOAuth 2.0 (select Client Credentials for server-to-server)
ScopesCheck 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/suiteql

Example 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

SymptomCauseFix
401 Invalid JWTClock skew > 5 minSync server time via NTP
403 Forbidden on SuiteQLIntegration role lacks Lists > Customers > ViewEdit the role under Setup > Users/Roles > Manage Roles
429 Too Many RequestsExceeded concurrency governanceImplement exponential backoff; batch writes
totalResults missingQuery returned > 1000 rows without Prefer: transientAlways send Prefer: transient header

Migration Checklist for Existing TBA Integrations

  1. Inventory every integration using Consumer Key/Secret + Token/Token Secret pairs
  2. Create new OAuth 2.0 integration records for each (Client Credentials for servers)
  3. Rotate credentials, revoke old TBA tokens in Setup > Users/Roles > Access Tokens
  4. Update token endpoints, same endpoint, different grant type
  5. Test SuiteQL queries, auth header format changes from TBA signature to Bearer token
  6. 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.

About the author

Put these ideas to work.

Suite Utils builds small NetSuite tools that fix the specific thing breaking your day. Each one runs as a native SuiteScript SuiteApp inside your account. No sales call, no onboarding.

Browse the Tools

Enjoyed this one?

Get NetSuite tips like this in your inbox. No spam. Practical guides only.

Keep reading