@brokenrubik/netsuite-sdk
v0.2.0
Published
Production-ready NetSuite REST API client SDK with OAuth 2.0 M2M authentication, retry logic, RESTlet support, and SuiteQL.
Downloads
486
Maintainers
Readme
@brokenrubik/netsuite-sdk
Production-ready TypeScript SDK for the NetSuite REST API with OAuth 2.0 M2M authentication.
Features
- OAuth 2.0 M2M authentication with certificate-based JWT (PS256)
- Full CRUD operations for NetSuite records + upsert via external IDs
- SuiteQL query execution with auto-pagination
- RESTlet support (custom scripts via
restlets.api.netsuite.com) - Metadata catalog access (JSON, OpenAPI 3.0, JSON Schema)
- Automatic retry with exponential backoff + jitter (429, 502, 503, 504)
- Structured error hierarchy with parsed
o:errorDetails - Per-scope token caching (SuiteTalk, RESTlets, Analytics)
- TypeScript-first with full generics support
- Dual CJS + ESM output
- Zero dependencies beyond
jsonwebtoken
Table of Contents
- Installation
- Prerequisites
- Quick Start
- Configuration
- Common Use Cases
- Error Handling
- Logging
- Advanced Usage
- API Reference
- Running Tests
- Official NetSuite Documentation
- License
Installation
npm install @brokenrubik/netsuite-sdkRequires Node.js >= 18.0.0 (uses native fetch)
Prerequisites
Before using this SDK, you need to set up OAuth 2.0 M2M authentication in your NetSuite account. This requires four credentials:
| Credential | Where to find it |
|---|---|
| Account ID | Setup > Company > Company Information (e.g., TSTDRV1234 or TSTDRV1234_SB1 for sandbox) |
| Client ID | Setup > Integration > Manage Integrations > your integration record |
| Certificate ID | Setup > Integration > OAuth 2.0 Client Credentials > Manage Certificates |
| Private Key | The PEM file you generated when creating the certificate |
Step-by-step setup
Generate a certificate and private key:
# Generate a 4096-bit RSA private key openssl genrsa -out private.pem 4096 # Extract the public key openssl rsa -in private.pem -pubout -out public.pemCreate an Integration Record in NetSuite:
- Navigate to Setup > Integration > Manage Integrations > New
- Enable Token-Based Authentication and OAuth 2.0 Client Credentials (M2M)
- Save and note the Client ID
Upload the public certificate to NetSuite:
- Navigate to Setup > Integration > OAuth 2.0 Client Credentials (M2M) > Create New
- Select the integration record, upload
public.pem - Assign a role (e.g., Administrator or a custom role)
- Save and note the Certificate ID
Store your private key securely. For environment variables, you can encode it as a single line:
# Option A: Escape newlines for .env files NETSUITE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEv...base64...\n-----END PRIVATE KEY-----" # Option B: Read from file at runtime const privateKey = fs.readFileSync('/path/to/private.pem', 'utf-8');
For more details, see the official NetSuite OAuth 2.0 setup guide.
Quick Start
import { NetSuiteClient } from '@brokenrubik/netsuite-sdk';
import { readFileSync } from 'node:fs';
const client = new NetSuiteClient({
accountId: process.env.NETSUITE_ACCOUNT_ID!,
clientId: process.env.NETSUITE_CLIENT_ID!,
certificateId: process.env.NETSUITE_CERTIFICATE_ID!,
privateKey: readFileSync('./private.pem', 'utf-8'),
});
// Create a customer
const { id } = await client.createRecord('customer', {
companyName: 'Acme Corporation',
email: '[email protected]',
subsidiary: { id: '1' },
});
console.log(`Created customer: ${id}`);
// Fetch it back
const customer = await client.getRecord('customer', id);
console.log(customer);
// Run a SuiteQL query
const results = await client.suiteql<{ id: string; companyname: string }>(
'SELECT id, companyname FROM customer WHERE ROWNUM <= 10',
);
console.log(`Found ${results.totalResults} customers`);Configuration
const client = new NetSuiteClient({
// Required
accountId: 'TSTDRV1234_SB1', // Supports underscores — auto-formatted to hyphens for API URLs
clientId: 'abc123...', // From Integration Record
certificateId: 'cert-id...', // From OAuth 2.0 Client Credentials setup
privateKey: '-----BEGIN...', // PEM-formatted private key string
// Optional — all have sensible defaults
requestTimeout: 30_000, // Per-request timeout in ms (default: 30s)
tokenExpiryMargin: 60_000, // Refresh token this many ms before expiry (default: 60s)
maxRetries: 3, // Retry attempts for transient errors (default: 3)
initialRetryDelay: 1_000, // First retry delay in ms (default: 1s)
maxRetryDelay: 60_000, // Retry delay cap in ms (default: 60s)
backoffMultiplier: 2, // Exponential backoff factor (default: 2)
logger: new ConsoleLogger(), // Optional ILogger — see Logging section
});Common Use Cases
Working with Records
Built-in record types
The SDK ships with type definitions for common record types, generated from the NetSuite metadata catalog. Use them directly or extend with your custom fields:
import type { Customer, SalesOrder, NsRef } from '@brokenrubik/netsuite-sdk';
// Use built-in types directly
const customer = await client.getRecord<Customer>('customer', '123');
console.log(customer.companyName); // typed as string | null
console.log(customer.subsidiary); // typed as NsRef ({ id, refName? })
// Extend with your custom fields
interface MyCustomer extends Customer {
custentity_loyalty_tier: NsRef | null;
custentity_signup_source: string | null;
}
const myCustomer = await client.getRecord<MyCustomer>('customer', '123');
console.log(myCustomer.custentity_loyalty_tier); // typed!Available types: Customer, SalesOrder, Invoice, Vendor, Employee, InventoryItem, Contact, Subsidiary, VendorBill, PurchaseOrder, Department, Classification, Location, Account, Currency.
Building blocks: NsRef (reference field), NsSublist (sublist), NsRecord (base record), NsLink (HATEOAS link).
Record field loading
NetSuite has three loading levels that affect what you get back:
| Load level | Reference fields (e.g. entity) | Sublists (e.g. item) |
|---|---|---|
| getRecord(type, id) | { id, refName, links } — always a ref, never the full record | { links } — collapsed, no items |
| getRecord(type, id, { expandSubResources: true }) | { id, refName, links } — same | { links, items: [...], totalResults } — items loaded |
| getRecord(type, id, { fields: '...' }) | { id, refName, links } — if requested | Not returned (only body fields) |
Reference fields are never fully loaded inline. To get the full referenced record, follow the link:
import type { SalesOrder, SalesOrderItem, Customer } from '@brokenrubik/netsuite-sdk';
// Load a sales order with expanded sublists
const order = await client.getRecord<SalesOrder>('salesOrder', '456', {
expandSubResources: true,
});
// entity is always { id, refName, links } — never the full customer
console.log(order.entity.refName); // "Acme Corp"
console.log(order.entity.id); // "123"
// To load the full customer, make a separate call:
const customer = await client.getRecord<Customer>('customer', order.entity.id);
// item sublist is expanded (because expandSubResources: true)
for (const line of order.item.items ?? []) {
console.log(`${line.item.refName}: ${line.quantity} x ${line.rate} = ${line.amount}`);
}Create a record
const result = await client.createRecord('customer', {
companyName: 'Acme Corporation',
email: '[email protected]',
subsidiary: { id: '1' },
});
console.log(result);
// {
// id: '12345',
// location: 'https://tstdrv1234.suitetalk.api.netsuite.com/.../customer/12345',
// success: true,
// operationId: '...', // NetSuite operation tracking ID
// }Get a record by ID
// Simple get
const customer = await client.getRecord<Customer>('customer', '12345');
// With sublists and subrecords expanded
const full = await client.getRecord<Customer>('customer', '12345', {
expandSubResources: true,
});
// Only specific fields (reduces payload size)
const partial = await client.getRecord<Pick<Customer, 'id' | 'email'>>('customer', '12345', {
fields: 'id,email',
});Note: You cannot combine
expandSubResourcesandfields— NetSuite returns anINVALID_PARAMETERerror.
List records with filtering
// Single page with query filter
const page = await client.listRecords<Customer>('customer', {
limit: 50,
offset: 0,
q: 'email CONTAINS "@acme.com"',
});
console.log(`Showing ${page.count} of ${page.totalResults} total`);
for (const customer of page.items) {
console.log(customer.companyName);
}Fetch all records (auto-pagination)
// Automatically fetches all pages (1000 records per page)
const allCustomers = await client.listAllRecords<Customer>('customer', {
q: 'isinactive IS false',
});
console.log(`Total active customers: ${allCustomers.length}`);Update a record
const result = await client.updateRecord('customer', '12345', {
email: '[email protected]',
phone: '555-0100',
});
console.log(result.success); // trueDelete a record
const result = await client.deleteRecord('customer', '12345');
console.log(result.success); // trueUpsert a record (create or update by external ID)
// If customer with external ID 'CID-002' exists, update it; otherwise, create it
const result = await client.upsertRecord('customer', 'CID-002', {
companyName: 'Updated Corp',
email: '[email protected]',
subsidiary: { id: '1' },
});
// The 'eid:' prefix is added automatically — these are equivalent:
await client.upsertRecord('customer', 'CID-002', data);
await client.upsertRecord('customer', 'eid:CID-002', data);Working with custom records
// Custom records use their scriptId as the record type
const result = await client.createRecord('customrecord_my_record', {
name: 'Test Entry',
custrecord_field1: 'value1',
});
const record = await client.getRecord('customrecord_my_record', result.id);SuiteQL Queries
SuiteQL lets you query NetSuite data using SQL-like syntax. It's the most powerful way to retrieve data — supports JOINs, aggregation, and ordering (which the Record API doesn't).
Basic query
const result = await client.suiteql<{ id: string; companyname: string }>(
'SELECT id, companyname FROM customer WHERE companyname LIKE \'%Acme%\' ORDER BY companyname',
{ limit: 100 },
);
for (const row of result.items) {
console.log(`${row.id}: ${row.companyname}`);
}Pagination
// Manual pagination
let offset = 0;
const pageSize = 1000;
while (true) {
const page = await client.suiteql<{ id: string }>(
'SELECT id FROM transaction WHERE type = \'SalesOrd\'',
{ limit: pageSize, offset },
);
console.log(`Page at offset ${offset}: ${page.items.length} rows`);
if (!page.hasMore) break;
offset += page.items.length;
}Auto-paginate all results
// Automatically fetches all pages (up to 100,000 rows, NetSuite limit)
const allOrders = await client.suiteqlAll<{
id: string;
tranid: string;
total: string;
}>('SELECT id, tranid, total FROM transaction WHERE type = \'SalesOrd\' ORDER BY tranid');
console.log(`Total orders: ${allOrders.length}`);JOINs and aggregation
// Count orders per customer
const stats = await client.suiteql<{
companyname: string;
order_count: string;
}>(`
SELECT c.companyname, COUNT(t.id) as order_count
FROM customer c
INNER JOIN transaction t ON t.entity = c.id
WHERE t.type = 'SalesOrd'
GROUP BY c.companyname
ORDER BY order_count DESC
`);
for (const row of stats.items) {
console.log(`${row.companyname}: ${row.order_count} orders`);
}RESTlets
RESTlets are custom SuiteScript endpoints deployed in NetSuite. They use a different hostname (restlets.api.netsuite.com) and OAuth scope (restlets), which the SDK handles automatically.
// GET request to a RESTlet
const searchResults = await client.callRestlet({
scriptId: '1234',
deployId: '1',
method: 'GET',
queryParams: { action: 'search', term: 'widget' },
});
// POST request with a body
const created = await client.callRestlet({
scriptId: '1234',
deployId: '1',
method: 'POST',
body: {
recordType: 'inventoryitem',
values: { itemid: 'WIDGET-001', displayname: 'Widget' },
},
});
// Custom headers
const result = await client.callRestlet({
scriptId: '1234',
deployId: '1',
method: 'POST',
body: { data: 'value' },
headers: { 'X-Custom-Header': 'my-value' },
});Tip: The SDK automatically uses the
restletsOAuth scope and therestlets.api.netsuite.comhostname. Token caching is per-scope, so SuiteTalk and RESTlet tokens are managed independently.
Metadata Catalog
The metadata catalog describes the structure of all available record types, including custom records and fields.
// Fetch the full catalog
const catalog = await client.getMetadata();
// Fetch metadata for a specific record type
const customerMeta = await client.getMetadata({ select: 'customer' });
// Get OpenAPI 3.0 (Swagger) definition
const swagger = await client.getMetadata({
select: 'customer',
format: 'openapi3',
});
// Get JSON Schema definition
const schema = await client.getMetadata({
select: 'salesorder',
format: 'jsonschema',
});Available formats:
| Format | Accept header | Use case |
|---|---|---|
| 'json' (default) | application/json | Browse record structure |
| 'openapi3' | application/swagger+json | Generate API clients, import into Postman |
| 'jsonschema' | application/schema+json | Data validation |
Error Handling
The SDK provides a structured error hierarchy that maps to NetSuite's error response format:
NetSuiteError (base)
├── NetSuiteAuthError — 401/403 authentication failures
├── NetSuiteValidationError — 400 with parsed o:errorDetails
├── NetSuiteNotFoundError — 404 record/type not found
├── NetSuiteRateLimitError — 429 concurrency limit exceeded
└── NetSuiteTimeoutError — request timeoutCatching specific errors
import {
NetSuiteError,
NetSuiteAuthError,
NetSuiteValidationError,
NetSuiteNotFoundError,
NetSuiteRateLimitError,
NetSuiteTimeoutError,
} from '@brokenrubik/netsuite-sdk';
try {
await client.createRecord('customer', { /* missing required fields */ });
} catch (error) {
if (error instanceof NetSuiteValidationError) {
// 400 Bad Request — NetSuite tells you exactly what's wrong
console.error('Validation failed:', error.message);
console.error('Error code:', error.errorCode); // e.g., 'REQUIRED_FIELD'
console.error('Error path:', error.errorPath); // e.g., '/companyname'
// Full list of validation issues
for (const detail of error.errorDetails) {
console.error(` - ${detail.errorCode}: ${detail.detail} (${detail.errorPath})`);
}
} else if (error instanceof NetSuiteNotFoundError) {
// 404 — record or record type doesn't exist
console.error('Not found:', error.message);
} else if (error instanceof NetSuiteAuthError) {
// 401/403 — credentials are wrong or expired
console.error('Authentication failed:', error.message);
console.error('Check your clientId, certificateId, and privateKey');
} else if (error instanceof NetSuiteRateLimitError) {
// 429 — too many concurrent requests
// Note: The SDK already retries these automatically (maxRetries times)
// This only fires if all retries are exhausted
console.error('Concurrency limit exceeded after all retries');
} else if (error instanceof NetSuiteTimeoutError) {
// Request took longer than requestTimeout
console.error('Request timed out');
} else if (error instanceof NetSuiteError) {
// Any other NetSuite API error (500, etc.)
console.error(`API Error ${error.status}: ${error.message}`);
console.error('Response body:', error.body);
}
}Error properties
All errors extend NetSuiteError and include:
| Property | Type | Description |
|---|---|---|
| message | string | Human-readable error message |
| status | number | HTTP status code (0 for timeouts) |
| statusCode | number | Alias for status |
| body | Record<string, unknown> | Raw response body (if JSON) |
NetSuiteValidationError adds:
| Property | Type | Description |
|---|---|---|
| errorDetails | NetSuiteErrorDetail[] | Parsed list of validation errors |
| errorCode | string | First error's code (e.g., REQUIRED_FIELD) |
| errorPath | string | First error's path (e.g., /companyname) |
Automatic retries
The SDK automatically retries requests that fail with transient errors:
- 429 — Rate limit / concurrency limit exceeded
- 502 — Bad gateway
- 503 — Service unavailable
- 504 — Gateway timeout
- Network errors —
ECONNRESET,ETIMEDOUT,ECONNREFUSED
Retries use exponential backoff with jitter (e.g., 1s, 2s, 4s with ±10% randomization). 500 errors are NOT retried — per NetSuite docs, these indicate server-side issues that require contacting support.
Logging
Built-in console logger
import { NetSuiteClient, ConsoleLogger } from '@brokenrubik/netsuite-sdk';
const client = new NetSuiteClient({
...credentials,
logger: new ConsoleLogger('MyApp'), // Prefix for log messages
});
// Output:
// [MyApp] Requesting new access token { scope: 'rest_webservices' }
// [MyApp] Access token obtained { scope: 'rest_webservices', expiresIn: 3600 }
// [MyApp] Creating record { recordType: 'customer' }Custom logger (pino, winston, etc.)
Implement the ILogger interface:
import type { ILogger } from '@brokenrubik/netsuite-sdk';
import pino from 'pino';
const pinoInstance = pino();
const logger: ILogger = {
debug: (msg, data) => pinoInstance.debug(data, msg),
info: (msg, data) => pinoInstance.info(data, msg),
warn: (msg, data) => pinoInstance.warn(data, msg),
error: (msg, err) => pinoInstance.error(err, msg),
};
const client = new NetSuiteClient({
...credentials,
logger,
});Silent mode (default)
If you don't pass a logger, the SDK uses a NoopLogger internally — no output at all.
Advanced Usage
Low-level requests
For endpoints not covered by the SDK methods, use request() directly:
// Hit any SuiteTalk endpoint
const result = await client.request(
'/services/rest/record/v1/invoice/123',
'GET',
{ headers: { Accept: 'application/schema+json' } },
);
// Hit a restlets endpoint
const restletResult = await client.request(
'/app/site/hosting/restlet.nl?script=100&deploy=1',
'POST',
{
body: { action: 'custom' },
baseHost: 'restlets',
scope: 'restlets',
},
);Token management
// Check if a valid token is cached
client.hasValidToken(); // rest_webservices scope (default)
client.hasValidToken('restlets'); // restlets scope
// Get token expiration timestamp (ms since epoch, or null)
const exp = client.getTokenExpiration();
if (exp) {
console.log(`Token expires at: ${new Date(exp).toISOString()}`);
}
// Force token refresh on next request
client.clearTokenCache(); // Clear all scopes
client.clearTokenCache('restlets'); // Clear specific scopeBulk operations with SuiteQL
// Export all active inventory items
const items = await client.suiteqlAll<{
id: string;
itemid: string;
displayname: string;
quantityavailable: string;
}>(`
SELECT id, itemid, displayname, quantityavailable
FROM item
WHERE isinactive = 'F'
AND itemtype = 'InvtPart'
ORDER BY itemid
`);
console.log(`Exported ${items.length} inventory items`);Sync pattern: find-or-create
async function findOrCreateCustomer(
client: NetSuiteClient,
externalId: string,
data: Record<string, unknown>,
) {
// Check if customer exists via SuiteQL
const existing = await client.suiteql<{ id: string }>(
`SELECT id FROM customer WHERE externalid = '${externalId}'`,
{ limit: 1 },
);
if (existing.items.length > 0) {
// Update existing
return client.updateRecord('customer', existing.items[0].id, data);
} else {
// Create new
return client.createRecord('customer', { ...data, externalid: externalId });
}
}Or, use the built-in upsert which does this atomically:
const result = await client.upsertRecord('customer', externalId, data);API Reference
NetSuiteClient
| Method | Signature | Description |
|---|---|---|
| getRecord | <T>(type, id, options?) => Promise<T> | Get a record by internal ID |
| listRecords | <T>(type, options?) => Promise<PaginatedResponse<T>> | List records (single page) |
| listAllRecords | <T>(type, options?) => Promise<T[]> | List all records (auto-paginate) |
| createRecord | (type, data) => Promise<MutationResult> | Create a new record |
| updateRecord | (type, id, data) => Promise<MutationResult> | Update a record by ID |
| deleteRecord | (type, id) => Promise<MutationResult> | Delete a record by ID |
| upsertRecord | (type, externalId, data) => Promise<MutationResult> | Create or update by external ID |
| suiteql | <T>(query, options?) => Promise<PaginatedResponse<T>> | Run a SuiteQL query (single page) |
| suiteqlAll | <T>(query) => Promise<T[]> | Run a SuiteQL query (auto-paginate) |
| callRestlet | <T>(options) => Promise<T> | Call a RESTlet script |
| getMetadata | <T>(options?) => Promise<T> | Fetch metadata catalog |
| request | <T>(endpoint, method, options?) => Promise<T> | Low-level request |
| hasValidToken | (scope?) => boolean | Check if a cached token is valid |
| clearTokenCache | (scope?) => void | Clear cached tokens |
| getTokenExpiration | (scope?) => number \| null | Get token expiration timestamp |
Exported Types
import type {
// Client
NetSuiteClientOptions,
ILogger,
// Records — options & results
GetRecordOptions, // { expandSubResources?, simpleEnumFormat?, fields? }
ListRecordsOptions, // { limit?, offset?, q? }
MutationResult, // { id, location, success, operationId?, jobId?, propertyValidation? }
// Records — type definitions (from metadata catalog)
NsRef, // { id: string; refName?: string } — reference field
NsSublist, // { links, items?, totalResults?, ... } — sublist
NsRecord, // Base: { id, externalId?, links, refName }
NsLink, // { rel: string; href: string }
NsRefLink, // NsRef + links — actual shape of reference fields
Customer,
SalesOrder,
Invoice,
Vendor,
Employee,
InventoryItem,
Contact,
Subsidiary,
VendorBill,
PurchaseOrder,
Department,
Classification, // "Class" in NetSuite UI
Location,
Account,
Currency,
// SuiteQL
SuiteQLOptions, // { limit?, offset? }
// RESTlets
RestletOptions, // { scriptId, deployId, method, body?, queryParams?, headers? }
// Metadata
MetadataFormat, // 'json' | 'openapi3' | 'jsonschema'
MetadataOptions, // { select?, format? }
// Common
HttpMethod, // 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' | 'HEAD' | 'OPTIONS'
AuthScope, // 'rest_webservices' | 'restlets' | 'suite_analytics'
PaginatedResponse, // { items, count, hasMore, offset, totalResults, links? }
Link, // { rel, href }
RequestOptions, // { headers?, baseHost?, scope? }
// Errors
NetSuiteErrorDetail, // { detail, errorCode, errorPath?, urlPath? }
} from '@brokenrubik/netsuite-sdk';Exported Classes
import {
NetSuiteClient,
// Errors
NetSuiteError,
NetSuiteAuthError,
NetSuiteValidationError,
NetSuiteNotFoundError,
NetSuiteRateLimitError,
NetSuiteTimeoutError,
// Logger utilities
ConsoleLogger,
NoopLogger,
} from '@brokenrubik/netsuite-sdk';Running Tests
Unit tests
npm test # Run all tests
npm run test:unit # Unit tests only
npm run test:coverage # With coverage report (100% stmt/line/func, 94% branch)Integration tests
Integration tests run against a real NetSuite sandbox. Copy the env template and fill in your credentials:
cp .env.template .envNETSUITE_ACCOUNT_ID=TSTDRV1234_SB1
NETSUITE_CLIENT_ID=your-client-id
NETSUITE_CERTIFICATE_ID=your-cert-id
NETSUITE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEv...\n-----END PRIVATE KEY-----"
# Optional: RESTlet testing
NETSUITE_RESTLET_SCRIPT_ID=1234
NETSUITE_RESTLET_DEPLOY_ID=1Then run:
npm run test:integrationIntegration tests cover:
- Full CRUD lifecycle (create, get, get+fields, get+expand, update, list, delete)
- Reading multiple record types (subsidiary, currency, account, department, item)
- Pagination with offset and
nextlinks - Record filtering with
qparameter - SuiteQL: SELECT, WHERE, ORDER BY, COUNT, GROUP BY, JOIN, LIKE, pagination, empty results
- Metadata catalog in JSON, OpenAPI 3.0, and JSON Schema formats
- Error handling for non-existent records, invalid record types, and invalid SQL
Tests skip automatically if credentials are not set.
CI
GitHub Actions runs on every PR and push to main:
| Job | What it does | Required to merge | |---|---|---| | Unit Tests | Runs on Node 18, 20, 22 | Yes | | Coverage | Enforces coverage thresholds | Yes | | Integration Tests | Runs against real NetSuite sandbox (via secrets) | No (informational) |
Branch protection on main requires Unit Tests and Coverage to pass.
Official NetSuite Documentation
- SuiteTalk REST Web Services Overview
- OAuth 2.0 Setup
- REST Record API
- SuiteQL
- Record Collection Filtering
- Working with Resource Metadata
- REST API Practical Advice (Tim Dietrich)
License
ISC
