The Embedded Finance Index is live: 6,292 companies and 1,383 providers, mapped across the stack. Explore the Index →

NetSuite REST API Integration Guide: OAuth 2.0 and SuiteQL

NetSuite REST API guide: OAuth 2.0 and M2M auth, SuiteQL, concurrency limits and 429s, and Oracle's 2027.1 TBA and 2028.2 SOAP cutoffs.

Saurabh RaiSaurabh Rai

Saurabh Rai · Developer Relations Engineer, Apideck

14 min readView as .md
NetSuite REST API Integration Guide: OAuth 2.0 and SuiteQL

Starting with NetSuite 2027.1, Oracle will no longer let you create a new integration that authenticates with token-based authentication (TBA), per its TBA end-of-support notice, and with 2028.2 SOAP web services leave NetSuite entirely, per the SOAP overview. The first version of this NetSuite REST API guide opened with a TBA consumer key and an OAuth 1.0 signature. If you start an integration today, build it on the NetSuite REST API with OAuth 2.0 and query bulk data with SuiteQL. That is the path Oracle now prescribes (updated October 2026).

The integration surfaces Oracle supports

External code reaches NetSuite through the REST API or through RESTlets, with SOAP on its way out. Oracle's comparison of integration options lays them side by side, and all of them draw on one concurrency limit.

The REST record service is the default. It reads and writes standard and custom records and needs nothing deployed inside the customer's account. Records live at https://<accountID>.suitetalk.api.netsuite.com/services/rest/record/v1/<recordType>, per the URL schema page.

SuiteQL is the query side of the same REST API. You POST SQL to /services/rest/query/v1/suiteql and get rows back as JSON, which is how you read in bulk and join records the record service can only link to.

RESTlets are SuiteScript functions you deploy into the customer's account and call over HTTPS. Oracle's SOAP removal FAQ says to use them "when an object or method is not available in REST web services." The cost is that you now ship code into every customer's NetSuite and maintain it there.

SOAP web services still run, and many older integrations depend on them, but Oracle calls REST "the technology intended to replace SOAP."

Oracle's timeline for SOAP and TBA

The two cutoffs go together. SOAP web services only accept TBA (or user credentials), and OAuth 2.0 "cannot be used with SOAP web services," per Oracle's TBA overview. Closing TBA to new integrations therefore closes SOAP to them too.

Timeline of Oracle's NetSuite SOAP and token-based authentication wind-down, from the last planned SOAP endpoint in release 2025.2 to SOAP removal and the tentative end of TBA support in 2028.2

The FAQ spells out the sequence. "Starting with the 2026.1 NetSuite release, all newly built integrations should use REST web services with OAuth 2.0," and with 2027.1 "you will no longer be able to build new integrations using SOAP web services." Existing integrations keep working, and their integration records can still be edited, until SOAP is removed in 2028.2. The 2025.2 endpoint is "the last planned SOAP endpoint."

Oracle's own pages disagree on one date. The SOAP overview says only the 2025.2 endpoint is supported from 2027.1, while the FAQ table keeps 2025.1 and 2024.2 supported through 2027.1, ending at 2027.2. If you run SOAP on an older endpoint, plan for the earlier date.

TBA ends on the same schedule. The 2026.2 release notes confirm that from 2027.1 neither the IssueToken endpoint nor the three-step TBA flow can be used for a new integration, with end of support for existing ones "tentatively planned for the 2028.2 release." The same notes make PKCE mandatory for new OAuth 2.0 authorization code integrations in 2027.1 and stop every integration that still uses NLAuth, the RESTlet authentication method that predates TBA. Our NetSuite TBA to OAuth 2.0 migration guide covers what the 2027.1 cutoff means for an integration record you distribute to many customer accounts, and our SuiteWorld 2026 roundup puts these cutoffs next to the SuiteScript 1.0 deadline in the same releases.

Authenticating with OAuth 2.0

OAuth 2.0 in NetSuite starts with an integration record in the customer's account (Setup > Integration > Manage Integrations > New). On it you tick the grant types and scopes you need and enter your redirect URIs, which must use https:// or a custom scheme. NetSuite shows the client ID and secret once, when the record is first saved, per the integration record docs.

Authorization code grant

Send the user to the account's authorize endpoint. This is the request format from Oracle's step one page:

https://<accountID>.app.netsuite.com/app/login/oauth2/authorize.nl
  ?response_type=code
  &client_id=<client_id>
  &redirect_uri=https%3A%2F%2Fmyapplication.com%2Fnetsuite%2Foauth2callback
  &scope=rest_webservices
  &state=<22 to 1024 characters>
  &code_challenge=<S256 hash of your code_verifier>
  &code_challenge_method=S256

The redirect back to you carries the code plus NetSuite-specific role and entity parameters that tell you which user and role granted access. Exchange the code at the token endpoint with HTTP Basic client authentication, per step two:

curl -X POST "https://<accountID>.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token" \
  -u "<client_id>:<client_secret>" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=<code>" \
  --data-urlencode "redirect_uri=https://myapplication.com/netsuite/oauth2callback" \
  --data-urlencode "code_verifier=<code_verifier>"

The access token is valid for 60 minutes and the refresh token for seven days. A refresh returns a new access token but no new refresh token, so once the refresh token expires, the token endpoint returns invalid_grant and the refresh docs send you back to step one. For a product that syncs in the background, that means a user has to re-authorize at least once a week. Public clients are stricter still, with a one-time refresh token valid for 48 hours by default.

Client credentials (M2M) for background sync

For server-to-server work, the client credentials grant avoids the weekly consent. You generate a certificate, and an administrator in the customer's account maps it to your integration record plus an entity and role under Setup > Integration > Manage Authentication > OAuth 2.0 Client Credentials (M2M) Setup, per the M2M setup page. Your app then signs a JWT with the private key and trades it for an access token, using the parameters from the token request docs:

curl -X POST "https://<accountID>.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=client_credentials" \
  --data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
  --data-urlencode "client_assertion=<JWT signed with your certificate's private key>"

Access tokens last 60 minutes and there is no refresh token: you sign a new assertion each time. Certificates must use RSA keys of 3072 or 4096 bits or EC keys, are valid for at most two years, and each one covers one combination of integration record, role and entity, per the certificate conditions. Track expiry dates per customer, because an expired certificate stops the integration until someone uploads a new one.

Every REST call then carries Authorization: Bearer <access token>.

TBA for integrations that already exist

If you maintain a TBA integration, it keeps working until the end of support. Requests are signed OAuth 1.0 style with HMAC-SHA256, and the header includes a realm set to the account ID, per Oracle's authorization header page. Password rotation policies don't apply to the tokens, which is why TBA was popular for background sync. Oracle's guidance is still to move existing REST and RESTlet integrations to OAuth 2.0 before 2028.2.

Account IDs and sandbox URLs

Every NetSuite customer has an account ID, and every REST URL is built on it. Oracle tells you to treat the service URL as configuration and to read it from Setup > Company > Company Information, on the Company URLs subtab. Sandbox accounts reuse the production ID with a suffix, so 345678 has sandboxes 345678_SB1 and 345678_SB2, per the sandbox docs. In hostnames the underscore becomes a hyphen and the letters go lowercase, so 123456_SB1 is reached at 123456-sb1.suitetalk.api.netsuite.com, per the account-specific domains page.

A sandbox refresh wipes your access. Oracle's docs state that TBA tokens and OAuth 2.0 authorizations are not copied into sandboxes and that M2M mappings are cleared on refresh, so plan a re-authorization step after every refresh.

Permissions are the other per-customer variable. Apideck's NetSuite connection guide lists the REST Web Services, SOAP Web Services and access-token login permissions, SuiteAnalytics Workbook for SuiteQL, and view access on each record type, and notes that SuiteQL checks permissions on every table a query joins. A missing permission doesn't always produce an error: a filtered list call without the per-record permission returns 200 with an empty result.

Reading records with the NetSuite REST API

A single record is a GET on its type and internal ID. Ask for only the fields you need with fields, per the get record docs:

curl "https://<accountID>.suitetalk.api.netsuite.com/services/rest/record/v1/customer/107?fields=companyName,email,entityId" \
  -H "Authorization: Bearer <access_token>"

Collections behave differently from what most REST APIs return. A list or q filter query returns only IDs and links, not record bodies, according to the REST limitations page, so reading 500 customers through the record service costs one list call plus 500 GETs, or five asynchronous batch GETs with expandRecords=true. Paging uses limit and offset, and the offset must be divisible by the limit. Pages hold 1,000 results by default, up to 1,000 pages, per the paging docs. Filters go in q, for example ?q=email START_WITH barbara, and only fields marked x-ns-filterable in the metadata can be used.

The same page has a second surprise: REST loads every record in edit mode, even on GET. A role without Administrator access gets an error when it reads an administrator's employee record.

Bulk reads with SuiteQL

For anything beyond a handful of records, query with SuiteQL. The request needs the Prefer: transient header, and the query goes in q, per Oracle's SuiteQL over REST page:

POST https://<accountID>.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql?limit=1000&offset=0
Authorization: Bearer <access_token>
Prefer: transient
Content-Type: application/json

{"q": "SELECT id, tranid, trandate FROM transaction WHERE trandate >= TO_DATE('10/01/2026', 'MM/DD/YYYY')"}

Bound parameters keep user input out of the query string: send ? placeholders in q and the values in a params array. A single query returns at most 100,000 results. Above that, Oracle points you to SuiteAnalytics Connect, its ODBC and JDBC data source, or you split the query by date or ID range.

SuiteQL output can also shift under you after an upgrade. Column name casing in results "may not always be consistent and can change" across releases, so normalize keys before you map them, and Oracle recommends Oracle SQL syntax over SQL-92 to avoid timeouts, per the SuiteQL limitations page.

Writing records

Creating a record is a POST of JSON that matches the record's metadata. This is Oracle's example from the create record page:

curl -i -X POST "https://<accountID>.suitetalk.api.netsuite.com/services/rest/record/v1/customer" \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{"entityid": "New Customer", "companyname": "My Company", "subsidiary": {"id": "1"}}'

A successful create returns HTTP 204 with no body. The new record's URL, and with it the internal ID, is in the Location header, so code that parses the response body for an ID gets nothing.

For sync jobs, write with external IDs. Store your own key in externalId, then use PUT /record/v1/customer/eid:<your-id>, which creates the record if it doesn't exist and updates it if it does, per the upsert docs. A retried job then can't create duplicates, and you never need a lookup before a write.

Batch operations handle up to 100 records of one type per request. They run asynchronously (send Prefer: respond-async) and return a job URL you poll. Add an X-NetSuite-idempotency-key header, and a resubmitted batch comes back with an IDEMPOTENCY_ERROR that points at the original job instead of running twice.

Customer configuration can reject writes that worked in testing. A customer's own user event scripts run on records created through REST web services, per Oracle's user event docs, so their validation logic applies to your POSTs. And Apideck's connector docs record that a mandatory custom field on a standard form makes API writes to that record type fail.

Change notifications without webhooks

Oracle's feature list for REST web services includes no outbound webhooks, so you either poll or run code inside the customer's account. Polling means a SuiteQL query on a date field at an interval you choose. The in-account route is a user event script whose afterSubmit entry point fires when a record is saved and calls your endpoint with the N/https module. Oracle lists "real-time data synchronization" among the uses of user event scripts, but the script is SuiteScript you deploy and maintain per customer, and user event scripts don't trigger other user event scripts.

Most teams poll. Apideck's virtual webhooks do the polling for you and turn changes into created, updated and deleted events. They check each enabled resource every 24 hours by default, so they suit nightly sync and not near-real-time triggers.

Concurrency limits and 429 errors

NetSuite limits how many requests run at the same time, not how many you send per minute. The limit belongs to the account and is shared by every web services and RESTlet integration the customer runs. The base limit depends on the service tier, and each SuiteCloud Plus license adds 10, per Oracle's concurrency limits page.

Bar chart of NetSuite account concurrency limits by service tier, from 5 concurrent requests on Standard to 20 on Enterprise and Ultimate, plus 10 for each SuiteCloud Plus license

When you exceed it, REST returns HTTP 429 with the error code CONCURRENCY_LIMIT_EXCEEDED and the detail "Concurrent request limit exceeded. Request blocked.", per the REST error handling page. RESTlets report the same condition as HTTP 400 with SSS_REQUEST_LIMIT_EXCEEDED. Oracle's guidance is to retry. A separate limit applies to duration: a REST request that runs longer than 15 minutes times out.

Size your worker pool from the customer's actual limit. An administrator can read it at Setup > Integration > Integration Management > Integration Governance, or you can call GET /services/rest/system/v1/governanceLimits, which returns the account limit and the unallocated limit when you are logged in as an administrator, per the governanceLimits docs. Customers can also reserve part of the pool for one integration record. Oracle advises doing that only for an integration that misbehaves, since every reservation shrinks the pool for everything else.

A Standard-tier customer without SuiteCloud Plus has 5 concurrent requests for all of their integrations combined. Your sync shares that pool with every other integration they run, so stay well below the limit and back off on every 429.

Subsidiaries in OneWorld accounts

OneWorld accounts add subsidiaries, and Oracle's own create example sets subsidiary on a new customer. Resolve subsidiary IDs per account with SuiteQL instead of hard-coding them; Oracle's SuiteCloud SDK finance query patterns start from the subsidiary table and filter out elimination subsidiaries.

The same file warns about reporting across entities. "SuiteQL cannot replicate NetSuite's consolidation engine," and "a SuiteQL SUM across all subsidiaries is not a consolidated figure; it is a gross aggregation before eliminations and FX translation." If your product shows group-level revenue or balances, label SuiteQL totals as pre-elimination, or take consolidated numbers from NetSuite's own reports.

Building on Apideck instead

A direct integration built today means OAuth 2.0 with per-customer certificates or weekly consent, plus retry logic sized to each account's concurrency pool. Apideck's NetSuite API integration puts NetSuite behind the same Accounting API as the other ledgers it supports. According to the connector page, it covers 35 resources and emulates webhooks for 14 of them. Requests over NetSuite's concurrency limit come back as HTTP 429, so retry with backoff. The connection uses TBA today. Oracle's switch to OAuth 2.0, and its move from SOAP to REST, is work Apideck does once, in the connector, for every connected account. A customer may need to re-authorize their NetSuite connection or have an administrator approve the new OAuth 2.0 access, and we'll tell connected customers before anything changes for them. Customers install Apideck's SuiteBundle and paste three values into Vault, the account ID plus a token ID and secret.

Incremental reads go through one call, and Apideck routes filtered invoice, bill and credit note lists to SuiteQL:

curl --globoff "https://unify.apideck.com/accounting/invoices?filter[updated_since]=2026-10-01T00:00:00Z&limit=200" \
  -H "Authorization: Bearer <APIDECK_API_KEY>" \
  -H "x-apideck-app-id: <APP_ID>" \
  -H "x-apideck-consumer-id: <CONSUMER_ID>" \
  -H "x-apideck-service-id: netsuite"

If NetSuite is the only ERP on your roadmap and you need records the unified model doesn't cover, build directly on REST with OAuth 2.0 client credentials and keep SOAP out of the design. If NetSuite is one of several ledgers your customers use, start with Apideck and keep direct NetSuite code for the records your product needs beyond the unified model.

Ready to get started?

Scale your integration strategy and deliver the integrations your customers need in record time.

Ready to get started?
Talk to an expert

Frequently asked questions

Trusted by fast-moving product & engineering teams

Bonsai by Zoom
JobNimbus
Blue Zinc
Exact
Drata