NetSuite API With Python: REST Web Services and RESTlet Examples
Connect Python to NetSuite using OAuth 2.0, REST Web Services, SuiteQL and RESTlets, with pagination, retries, error handling and secure configuration.
Python can connect to NetSuite through two closely related HTTP interfaces:
- SuiteTalk REST Web Services, NetSuite’s standard record and SuiteQL APIs.
- RESTlets, custom SuiteScript endpoints that you design for a specific workflow.
For a new integration, start with REST Web Services. Add a RESTlet only when the standard record and query APIs do not express the required business operation cleanly.
Oracle identifies OAuth 2.0 as the preferred authentication method for REST Web Services and RESTlets. This guide therefore uses an OAuth 2.0 bearer token in its Python requests rather than treating OAuth 1.0 token-based authentication as the default.
REST Web Services or a RESTlet?
| Requirement | Better starting point |
|---|---|
| Read, create, update or delete a supported record | REST Web Services record API |
| Query analytical data with joins | SuiteQL through REST Web Services |
| Use a stable, NetSuite-managed contract | REST Web Services |
| Run custom validation or several NetSuite operations as one action | RESTlet |
| Expose a narrowly tailored request and response | RESTlet |
| Reuse existing SuiteScript business logic | RESTlet |
REST Web Services reduces the amount of server-side code you own. A RESTlet gives you more control, but you also own its deployment, governance, logging, permissions, validation and backward compatibility.
Authentication choice in 2026
NetSuite supports OAuth 2.0 for both REST Web Services and RESTlets. Two common flows are:
- Authorization code grant for an application acting with an interactive user’s consent. It provides access and refresh tokens.
- Client credentials grant for unattended machine-to-machine integrations. NetSuite uses a certificate and a signed JWT assertion to obtain an access token.
Oracle’s NetSuite 2026.1 release notes state that support for creating new token-based authentication integrations is planned to end in NetSuite 2027.1. Existing TBA integrations are expected to continue working, but new development should use OAuth 2.0 unless a specific supported constraint requires otherwise.
Do not use a NetSuite email address and password in a new RESTlet integration. NetSuite has not supported user-credential authentication for newly created RESTlets since 2021.1.
NetSuite setup checklist
The exact screens depend on the chosen flow, but the integration normally needs:
- The REST Web Services and/or OAuth 2.0 features enabled.
- An integration record with the required OAuth 2.0 flow and scopes selected.
- A dedicated integration user and least-privilege role.
- The Log in Using OAuth 2.0 Access Tokens permission on the access role.
- Record permissions required by the actual operation.
- For client credentials, a certificate mapped to the application, user and role.
Select only the required scope:
rest_webservicesfor REST Web Services and SuiteQL.restletsfor RESTlet calls.
Sandbox, Release Preview and production are separate authentication environments. Oracle notes that OAuth applications or client-credential mappings are not automatically copied in several refresh and release-preview scenarios. Plan to configure and test each environment explicitly.
Keep configuration outside the code
The Python examples read values from environment variables:
export NETSUITE_ACCOUNT_ID='1234567_SB1'
export NETSUITE_ACCESS_TOKEN='replace-with-a-short-lived-token'
Do not commit access tokens, refresh tokens, client secrets, private keys or certificate material to Git.
Normalise the account ID for NetSuite service hostnames by replacing underscores with hyphens and using lowercase:
import os
account_id = os.environ["NETSUITE_ACCOUNT_ID"]
account_host = account_id.lower().replace("_", "-")
rest_base_url = (
f"https://{account_host}.suitetalk.api.netsuite.com"
"/services/rest"
)
For example, account 1234567_SB1 uses 1234567-sb1 in the hostname.
Create a reusable Python session
Install Requests:
python -m pip install requests
Create a session so authentication and response formats are consistent:
import os
import requests
session = requests.Session()
session.headers.update(
{
"Authorization": f"Bearer {os.environ['NETSUITE_ACCESS_TOKEN']}",
"Accept": "application/json",
"Content-Type": "application/json",
}
)
This example assumes the access-token acquisition or refresh process has already populated NETSUITE_ACCESS_TOKEN. A production service should acquire, cache and renew tokens automatically rather than relying on a manually copied token.
Read a record with REST Web Services
The record endpoint follows this structure:
/services/rest/record/v1/{recordType}/{internalId}
Read customer internal ID 123:
customer_url = f"{rest_base_url}/record/v1/customer/123"
response = session.get(customer_url, timeout=30)
response.raise_for_status()
customer = response.json()
print(customer["id"])
Use the REST API Browser and record metadata for the account’s supported record types, fields and subresources. Saved-search field IDs and SuiteQL field IDs are not automatically interchangeable with REST record properties.
Create a record
Create a customer using a minimal example payload:
customer_url = f"{rest_base_url}/record/v1/customer"
payload = {
"companyName": "Example Pty Ltd",
"email": "accounts@example.com",
}
response = session.post(customer_url, json=payload, timeout=30)
response.raise_for_status()
created_location = response.headers.get("Location")
print(created_location)
Successful create responses may identify the new resource through the Location response header. Do not assume every successful mutation returns a complete JSON representation.
Use an integration-specific external ID when repeated delivery is possible. A retry after an uncertain network failure must not create a duplicate customer, order or payment.
Update a record
Use PATCH for a partial update:
customer_url = f"{rest_base_url}/record/v1/customer/123"
payload = {
"email": "new-address@example.com",
}
response = session.patch(customer_url, json=payload, timeout=30)
response.raise_for_status()
Send only fields that the integration intends to change. Before updating financial or transactional records, confirm whether the account uses workflows, user events, mandatory fields or approval rules that also run for the request.
Query NetSuite with SuiteQL
SuiteQL is available through a POST request to the query service:
suiteql_url = f"{rest_base_url}/query/v1/suiteql"
query = """
SELECT
id,
entityid,
email
FROM customer
WHERE isinactive = 'F'
ORDER BY id
"""
response = session.post(
suiteql_url,
params={"limit": 1000, "offset": 0},
headers={"Prefer": "transient"},
json={"q": query},
timeout=60,
)
response.raise_for_status()
page = response.json()
for customer in page["items"]:
print(customer["id"], customer.get("email"))
The Prefer: transient header is required for SuiteQL requests through REST Web Services. Use the Records Catalog to confirm analytical record and field names.
Paginate every collection
Collection responses include values such as count, offset, hasMore, totalResults and navigation links. Never assume the first response contains every row.
This generator follows the offset until hasMore is false:
def iter_suiteql(session, suiteql_url, query, page_size=1000):
offset = 0
while True:
response = session.post(
suiteql_url,
params={"limit": page_size, "offset": offset},
headers={"Prefer": "transient"},
json={"q": query},
timeout=60,
)
response.raise_for_status()
page = response.json()
yield from page.get("items", [])
if not page.get("hasMore"):
break
count = page.get("count", 0)
if count <= 0:
raise RuntimeError("NetSuite returned hasMore with an empty page")
offset += count
Use a deterministic ORDER BY when paging a query. If the source data changes while pages are being retrieved, offset pagination can otherwise skip or repeat records.
Oracle documents a maximum of 100,000 results for SuiteQL through REST Web Services. For data-warehouse-scale extraction beyond that boundary, assess SuiteAnalytics Connect rather than trying to bypass the REST limit.
Call a RESTlet from Python
A RESTlet URL includes its script and deployment parameters:
https://{account}.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=123&deploy=1
Build the hostname from the same normalised account value:
restlet_url = (
f"https://{account_host}.restlets.api.netsuite.com"
"/app/site/hosting/restlet.nl"
)
response = session.post(
restlet_url,
params={"script": "customscript_example_api", "deploy": "1"},
json={"customerId": "123"},
timeout=60,
)
response.raise_for_status()
result = response.json()
print(result)
The access token used here must include the restlets scope, and the role must be permitted to run the script deployment and access every record used inside it.
Prefer script IDs in configuration where supported because they communicate intent better than unexplained numeric IDs. Verify the URL produced for the deployed RESTlet in the target account.
A minimal RESTlet contract
Keep the RESTlet response predictable:
/**
* @NApiVersion 2.1
* @NScriptType Restlet
*/
define(['N/record'], (record) => {
const post = ({ customerId }) => {
if (!customerId) {
throw new Error('customerId is required');
}
const customer = record.load({
type: record.Type.CUSTOMER,
id: customerId,
});
return {
id: String(customer.id),
email: customer.getValue({ fieldId: 'email' }) || null,
};
};
return { post };
});
Validate inputs before performing record operations. Return stable keys and explicit nulls. Avoid returning raw NetSuite objects, which can expose implementation details or fail during JSON serialization.
Add retries without creating duplicates
Retry temporary failures, not every error. A practical client may retry connection failures, timeouts, 429 throttling and selected 5xx responses with exponential backoff and jitter.
import random
import time
import requests
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
def request_with_retry(session, method, url, *, attempts=5, **kwargs):
for attempt in range(attempts):
try:
response = session.request(method, url, **kwargs)
except (requests.ConnectionError, requests.Timeout):
if attempt == attempts - 1:
raise
else:
if response.status_code not in RETRYABLE_STATUS_CODES:
return response
if attempt == attempts - 1:
return response
delay = min(30, 2 ** attempt) + random.random()
time.sleep(delay)
raise RuntimeError("Retry loop ended unexpectedly")
Automatic retries are safest for reads. Before retrying a create or other non-idempotent operation, use an external ID, an idempotency mechanism where supported, or a reconciliation lookup to determine whether the first request succeeded.
Always set a timeout. Without one, a stalled network call can hold a worker indefinitely.
Surface useful error details
raise_for_status() is useful, but log the structured NetSuite response before discarding it:
def raise_netsuite_error(response):
if response.ok:
return
try:
details = response.json()
except ValueError:
details = response.text[:2000]
request_id = (
response.headers.get("X-N-OperationId")
or response.headers.get("X-N-Request-Id")
)
raise RuntimeError(
f"NetSuite request failed: status={response.status_code}, "
f"request_id={request_id}, details={details!r}"
)
Do not log bearer tokens, authorization headers, private keys or sensitive record payloads.
Diagnose common failures
401 Unauthorized
Check:
- Whether the access token has expired.
- The token’s scope.
- The account-specific hostname.
- The client, certificate and role mapping for client credentials.
- Whether sandbox credentials were accidentally used against production, or the reverse.
403 Forbidden
Authentication may have succeeded while the role lacks a required permission. Check the REST Web Services or RESTlet permission, the record permissions, subsidiary restrictions, employee restrictions and the RESTlet deployment audience.
404 Not Found
Confirm the record type, internal ID, service hostname, RESTlet script and deployment. A role restriction can sometimes make an existing resource appear unavailable.
429 Too Many Requests
The integration has reached an applicable concurrency or request limit. Reduce parallelism, use backoff and inspect the account’s integration governance. Adding immediate retries can make throttling worse.
A RESTlet returns an unexpected SuiteScript error
Inspect the RESTlet execution log and add structured logging around input parsing, record access and response construction. The generic response is only the outer symptom; the execution log normally contains the actionable exception. See An Unexpected SuiteScript Error Has Occurred for a diagnostic workflow.
Production checklist
- Use OAuth 2.0 for new integrations.
- Give the integration its own least-privilege role and user mapping.
- Store secrets and private keys in a secret manager.
- Rotate certificates and credentials before expiry.
- Configure production, sandbox and Release Preview independently.
- Set connection and read timeouts.
- Paginate all collection and SuiteQL responses.
- Retry only temporary failures, with backoff and jitter.
- Make mutations idempotent or reconcilable.
- Log status, operation identifiers and sanitised error bodies.
- Monitor concurrency, RESTlet governance and integration failures.
- Test workflows, scripts and approval behaviour under the exact integration role.