> ## Documentation Index
> Fetch the complete documentation index at: https://docs.deploybaseline.com/llms.txt
> Use this file to discover all available pages before exploring further.

# NetSuite

> Records, transforms, SuiteQL, custom fields and records, metadata, and OAuth 2.0.

Stateful NetSuite emulation of SuiteTalk REST web services: the record service (`/services/rest/record/v1`) for customers, contacts, vendors, employees, inventory, non-inventory, and service items, estimates, sales orders, item fulfillments, invoices, customer payments, credit memos, and the lists they point at, with create, read, update, upsert by external ID, and delete, sublists and subrecords, subresource paths, `fields`, `expandSubResources`, and `simpleEnumFormat`, collection paging and `q` filtering, transforms (sales order to fulfillment and invoice, invoice to payment and credit memo, and more), record actions, attaching contacts, create and edit forms, select options, asynchronous requests and batches, custom fields, lists, and record types from seed config, the metadata catalog as JSON Schema and OpenAPI 3.0, `serverTime`, and `governanceLimits`. Requests sign in with token-based authentication (OAuth 1.0a with HMAC-SHA256) or OAuth 2.0 bearer tokens from the authorization code grant (with PKCE) or the client credentials grant (a certificate-signed JWT), and every failed sign-in leaves its reason in a login audit trail the inspector shows, as NetSuite keeps one. The tests drive it with [`netsuite-api-client`](https://github.com/julbrs/netsuite-rest) (1.1.1).

## At a glance

| | |
| - | - |
| Package | `@deploybaseline/netsuite` |
| Port | `4000` when started alone; named services take consecutive ports |
| Config key | `netsuite` |
| HTTP operations | 29, browsable at `http://localhost:3999/docs/netsuite` while it runs ([API reference](/api-reference.md)) |

```bash CLI theme={null}
npx @deploybaseline/cli start --service netsuite
```

```ts Programmatic theme={null}
import { createEmulator } from "@deploybaseline/cli";

const netsuite = await createEmulator({ service: "netsuite" });
// netsuite.url is http://localhost:4000
```

```json baseline.config.json theme={null}
{
  "netsuite": {
    "account": {
      "id": "1234567",
      "name": "Acme"
    },
    "access_tokens": [
      {
        "token": "ns-baseline-local-token"
      }
    ],
    "integrations": [
      {
        "name": "Local integration",
        "consumer_key": "baseline-consumer-key",
        "consumer_secret": "baseline-consumer-secret",
        "oauth2": {
          "redirect_uris": ["http://localhost:3000/callback"]
        }
      }
    ],
    "tba_tokens": [
      {
        "token_id": "baseline-token-id",
        "token_secret": "baseline-token-secret",
        "integration": "Local integration"
      }
    ],
    "records": {
      "customer": [
        {
          "companyName": "Acme Corp",
          "externalId": "ACME",
          "email": "ap@acme.example",
          "subsidiary": {
            "id": "1"
          }
        }
      ],
      "serviceSaleItem": [
        {
          "itemId": "Consulting",
          "externalId": "SVC-1",
          "price": {
            "items": [
              {
                "priceLevel": {
                  "id": "1"
                },
                "price": 150
              }
            ]
          }
        }
      ]
    }
  }
}
```

## Start

```bash theme={null}
npx @deploybaseline/cli start --service netsuite
```

NetSuite starts only when named in config or with `--service`. When it is the only enabled service, it listens on `http://localhost:4000`. Without seed config, any bearer token the CLI accepts acts as the administrator employee, and the account starts with the standard lists described under [Records](#records).

Point `netsuite-api-client` at the emulator with `base_url` and the seeded integration and token:

```ts theme={null}
import { NetsuiteApiClient } from "netsuite-api-client";

const client = new NetsuiteApiClient({
  consumer_key: "baseline-consumer-key",
  consumer_secret_key: "baseline-consumer-secret",
  token: "baseline-token-id",
  token_secret: "baseline-token-secret",
  realm: "1234567",
  base_url: process.env.NETSUITE_EMULATOR_URL,
});

const created = await client.request({
  path: "record/v1/customer",
  method: "POST",
  body: JSON.stringify({ companyName: "Globex", subsidiary: { id: "1" } }),
});
const id = created.headers.location.split("/").pop();
const customer = await client.request({ path: `record/v1/customer/${id}?expandSubResources=true` });
```

## URL mapping

| Real NetSuite URL | Emulator URL |
| - | - |
| `https://{accountId}.suitetalk.api.netsuite.com/services/rest/record/v1/...` | `$NETSUITE_EMULATOR_URL/services/rest/record/v1/...` |
| `https://{accountId}.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql` | `$NETSUITE_EMULATOR_URL/services/rest/query/v1/suiteql` |
| `https://{accountId}.suitetalk.api.netsuite.com/services/rest/async/v1/job/...` | `$NETSUITE_EMULATOR_URL/services/rest/async/v1/job/...` |
| `https://{accountId}.suitetalk.api.netsuite.com/services/rest/system/v1/...` | `$NETSUITE_EMULATOR_URL/services/rest/system/v1/...` |
| `https://{accountId}.app.netsuite.com/app/login/oauth2/authorize.nl` | `$NETSUITE_EMULATOR_URL/app/login/oauth2/authorize.nl` |
| `https://{accountId}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/...` | `$NETSUITE_EMULATOR_URL/services/rest/auth/oauth2/v1/...` |

## Auth

* **Token-based authentication (TBA)**: `Authorization: OAuth realm="<account ID>", oauth_consumer_key=..., oauth_token=..., oauth_signature_method="HMAC-SHA256", oauth_timestamp=..., oauth_nonce=..., oauth_version="1.0", oauth_signature=...`. The signature is checked as RFC 5849 builds it: the method, the URL without its query, and the query and OAuth parameters (without `realm` and the signature), keyed with the consumer secret and token secret. The timestamp must be within five minutes of the server clock, and a nonce may not be used twice with the same token and timestamp. HMAC-SHA1 is refused, as NetSuite has refused it since 2023.1. The realm is the account ID, compared without case and with `_` and `-` alike (`1234567_SB1` or `1234567-sb1`).
* **OAuth 2.0**: `Authorization: Bearer <access token>` with a token from the flows below.
* **Seeded bearer tokens**: `Authorization: Bearer <token>`. Tokens seeded under `access_tokens` act as the employee and role they name, or the administrator. Any other token the CLI accepts acts as the administrator employee (`admin@example.com`, role 3 Administrator), so a quick start needs no setup.

Every refusal is HTTP 401 `INVALID_LOGIN` with the same detail (`Invalid login attempt. For more details, see the Login Audit Trail in the NetSuite UI at Setup > Users/Roles > User Management > View Login Audit Trail.`), and the reason goes to the login audit trail: `MissingTokenPassportRequiredFields`, `UnknownAlgorithm`, `consumer_key_unknown`, `consumer_key_refused` (blocked integration, or TBA turned off), `token_rejected` (unknown or revoked token, a token of another integration, a realm naming another account, or an unknown, revoked, or expired bearer token), `InvalidTimestamp`, `InvalidSignature`, `NonceUsed`, and `permission_denied` (inactive employee or role). Successful sign-ins are logged too; the trail keeps the latest 500.

TBA is on its way out at NetSuite (no new TBA integrations from 2027.1); OAuth 2.0 is the way forward, and the emulator supports both.

## Errors

Errors are NetSuite's envelope (`application/vnd.oracle.resource+json; type=error`):

```json theme={null}
{
  "type": "https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.1",
  "title": "Bad Request",
  "status": 400,
  "o:errorDetails": [
    {
      "detail": "Error while accessing a resource. Please enter value(s) for: First Name, Last Name.",
      "o:errorCode": "USER_ERROR"
    }
  ]
}
```

## OAuth 2.0

An integration uses OAuth 2.0 when it has an `oauth2` map in seed config. Its client ID and client secret are its consumer key and consumer secret, as on the integration record.

```json theme={null}
{
  "netsuite": {
    "integrations": [
      {
        "name": "Web App",
        "consumer_key": "baseline-client-id",
        "consumer_secret": "baseline-client-secret",
        "oauth2": {
          "redirect_uris": ["https://app.example/callback", "http://localhost:3000/callback"],
          "scopes": ["rest_webservices", "restlets"]
        }
      },
      {
        "name": "Nightly Sync",
        "consumer_key": "baseline-m2m-client-id",
        "consumer_secret": "baseline-m2m-secret",
        "oauth2": {
          "client_credentials": true
        }
      }
    ],
    "certificates": [
      {
        "integration": "Nightly Sync",
        "certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n",
        "employee": "ivy@example.com",
        "role": "Integration Role"
      }
    ]
  }
}
```

* `scopes`: the default; suite\_analytics too, or mcp on its own
* `certificates`: client credentials (M2M) mappings
* `employee`: the administrator when left out
* `role`: Administrator when left out
* `consent_policy` (not set above, for example `always_ask`): ask\_first\_time, never\_ask
* `public_client` (not set above, for example `true`): no secret, PKCE required, rotating refresh tokens
* `refresh_token_validity_hours` (not set above, for example `48`): public clients, 1 to 720
* `max_token_rotation_hours` (not set above, for example `168`): public clients, 1 to 720

The authorization code grant is on when `redirect_uris` are given (`authorization_code: false` turns it off). Redirect URIs must be `https` or a custom scheme (`myapp://callback`), as on NetSuite; Baseline also takes `http` on `localhost` and `127.0.0.1`.

### Authorization code grant

* `GET /app/login/oauth2/authorize.nl` — Authorize: the consent page
* `POST /services/rest/auth/oauth2/v1/token` — Get token: authorization code, refresh token, or client credentials grant
* `POST /services/rest/auth/oauth2/v1/revoke` — Revoke token

1. Send the user to the authorize page with `response_type=code`, `client_id`, `redirect_uri` (one of the integration's), `scope` (space separated), `state` (22 to 1024 printable characters), and optionally `code_challenge` with `code_challenge_method=S256` and `prompt` (`login`, `consent`, or both). PKCE is required for public clients and for the `mcp` scope, and `plain` is refused. The page lists every active employee with each active role; choosing one allows access (Deny refuses it). The browser returns to the redirect URI with `state`, `role`, `entity`, `company`, and `code`, or with `error`.
2. Call the token endpoint (form encoded) with `grant_type=authorization_code`, `code`, `redirect_uri`, and `code_verifier`, authenticated with `Authorization: Basic base64(client_id:client_secret)`. Public clients send the client ID alone, in the body or in Basic with an empty secret. The answer is `{ access_token, refresh_token, expires_in: 3600, token_type: "Bearer" }`. Codes work once and for ten minutes (Baseline's choice; Oracle says only that they are short lived).
3. Refresh with `grant_type=refresh_token&refresh_token=...`. Confidential clients get a new access token and keep their refresh token, which lasts seven days. Public clients get a new refresh token each time and the old one stops working; each lasts `refresh_token_validity_hours`, and the chain ends `max_token_rotation_hours` after it started.
4. The revoke endpoint with `token=<refresh token>` revokes it and every access token issued with or from it (an access token alone can be revoked too). It answers 200.

The consent screen follows the integration's `consent_policy` and `prompt` as Oracle's table describes: Always Ask always shows it, Never Ask shows only the sign in (except for `mcp`), and `prompt=consent` always shows it. The page signs in and asks at once, so Ask First Time stops asking once someone has allowed the same scopes for the integration (Baseline's choice). There is never a NetSuite session, so `prompt=none` returns `error=login_required` (Baseline's wording).

Step one problems, as Oracle lists them: an unknown client (`unauthorized_client`) or a redirect URI the integration does not list (`invalid_request`) is shown on the page; missing parameters or a bad `state` or PKCE value are `invalid_request`, a response type other than `code` is `unsupported_response_type`, a scope the integration does not enable is `invalid_scope`, a blocked integration or one without the code grant is `unauthorized_client`, and Deny is `access_denied`, all sent to the redirect URI.

Token endpoint errors are `{ "error": "..." }`: `invalid_request` (missing or malformed parameters, a body that is not form encoded, or Basic and a body `client_id` together), `invalid_client` (401 with `WWW-Authenticate: Basic realm="<account ID>"`), `invalid_grant` (a code or refresh token that is unknown, used, expired, revoked, issued to another client, or for another redirect URI, or a `code_verifier` that does not match), `unauthorized_client`, `unsupported_grant_type`, and `invalid_scope`.

### Client credentials grant

Map a certificate to the integration, an employee, and a role (under `certificates` in seed config, or with the rotation endpoint below), then `POST /services/rest/auth/oauth2/v1/token` with `grant_type=client_credentials`, `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`, and `client_assertion`, a JWT signed with the certificate's private key:

* Header: `alg` `PS256`, `PS384`, `PS512`, `ES256`, `ES384`, or `ES512`; `kid` the certificate ID; `typ` `JWT`.
* Payload: `iss` the client ID, `scope` (a list, or comma separated text), `aud` the token endpoint (the emulator's, or `https://<account>.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token`), `iat`, and `exp` at most an hour after `iat`.

The answer is `{ access_token, expires_in: 3600, token_type: "Bearer" }` with no refresh token, acting as the mapped employee and role. A certificate ID is the base64url SHA-256 of the certificate's DER (Baseline's choice; Oracle generates its own). Assertion problems are `invalid_grant` with the audit trail reasons `UnsupportedAlgo`, `MissingKeyId`, `MissingKey` (unknown, revoked, or expired certificate), `SignatureValidation`, `InvalidIssuer`, `TokenExpired`, `TokenNotAcceptable` (issued in the future, or for another audience), and `TokenValidityTooBroad`; scopes are `invalid_scope` (Baseline's choice of error values, since Oracle does not list them for this flow).

### Certificate rotation

* `GET /services/rest/auth/oauth2/v1/clients/{clientId}/certificates` — List certificates: the integration's

With an OAuth 2.0 access token (or another bearer token), the list call shows the integration's certificates, `POST` to the same path with `{ "fileContent": "<PEM>", "role": 3, "entity": 12 }` sets up a new one (at most five active per integration), and `POST .../certificates/{certificateId}/revoke` revokes one (`Successfully revoked`). TBA requests are refused. Errors here use the REST error envelope (Baseline's choice).

### Tokens

Access and refresh tokens are JWTs shaped as NetSuite's: RS256 with a company key (`kid` `c.<account>.<created>`), `sub` the role and entity (`1000;12`), `aud` the application ID with the account and the client ID, `scope` a list, `iss` `https://system.netsuite.com`, `oit` the start of the token chain, `iat`, `exp`, and `jti`. Oracle's text says PS256; its example tokens are RS256, which Baseline follows. Tokens are also kept in the emulator's state, so revoking one takes effect at once, and keys, tokens, and certificates survive a restart.

* `GET /services/rest/auth/oauth2/v1/keys` — Get signing keys: the public key as a JWKS

REST calls need the `rest_webservices` scope. A refused OAuth 2.0 token answers 401 `INVALID_LOGIN` with `WWW-Authenticate: Bearer realm="<account ID>", error="invalid_token", error_description="Invalid login attempt."`, and the login audit trail gets Oracle's reason: `TokenRejected` (a refresh token), `TokenBlocked` (revoked), `AccessTokenExpired`, `InvalidSignature` (a JWT the account did not issue), `InvalidIntegration`, `IntegrationBlocked`, `ScopeMismatched`, or `EntityOrRoleDisabled`. The authorize and token endpoints log their outcomes too (`ClientAuthenticationFailed`, `InvalidGrant`, `ClientIdMismatch`, `RefreshTokenExpired`, `InvalidRefreshToken`, `AuthorizationExplicitlyDenied`, ...).

## Records

* `POST /services/rest/record/v1/{type}` — Create record: answers 204 with `Location`
* `GET /{type}/{id}` — Get record: the same path takes `PATCH` to update it (204 with `Location`) and `DELETE` to remove it (204)
* `PUT /customer/eid:CID002` — Upsert record by external ID: creates the record or updates the one with that external ID

`PUT` works only with an external ID, as an upsert. Anywhere an internal ID goes, `eid:<external ID>` works too; external IDs hold letters, digits, `_`, and `-`. Type names and field names match without case.

Records come back as NetSuite writes them (`application/vnd.oracle.resource+json; type=singular`): `links` first, then the fields in alphabetical order, with empty fields left out. A select field is a reference `{ links, id, refName }`, an enumeration is `{ id, refName }` (or the ID alone with `simpleEnumFormat=true`), a multiselect is `{ links, items, totalResults }`, and datetimes are UTC at the minute (`2026-03-01T14:05:00Z`). In request bodies, a reference takes `{ "id": "1" }` or `{ "externalId": "ACME" }`, and an enumeration takes its ID or `{ "refName": "..." }`.

* `fields=companyName,email` returns only those body fields. `expandSubResources=true` expands sublists (each line with its own link) and subrecords; without it they are links. The two cannot be combined (`INVALID_PARAMETER`).
* Subresources: `GET /{type}/{id}/{select field}` reads the referenced record, `/{sublist}` and `/{sublist}/{line}` read lines, `/{subrecord}` and `/{sublist}/{line}/{subrecord}` read subrecords.
* Sublists in a body merge by key: a line whose key matches an existing line updates it (the address book by `id`, transaction items by `line`, or by `orderLine` on lines made from a sales order, item pricing by currency, price level, and quantity, payment and credit memo `apply` lines by `doc`), and other lines are added. A new quantity or rate on an existing line prices it again. `?replace=addressBook,item` replaces the named sublists, and a null sublist empties it.
* A null body field in a `PATCH` clears the field. With `replaceSelectedFields=true`, only the null fields named in `replace` are cleared.
* Read-only fields in a body are ignored.

Saves are checked as NetSuite checks them: missing mandatory fields are one `USER_ERROR` naming each by its label (`Error while accessing a resource. Please enter value(s) for: First Name, Last Name.`), a value of the wrong shape or a reference to a missing record is `INVALID_CONTENT` with `o:errorPath` (`item.items[0].item.id`), and too long a text is a `USER_ERROR`. Entity IDs, item names, and external IDs are unique; deleting a record other records point at is refused (`This record cannot be deleted because it has dependent records.`).

### Record types

| Group | Types |
| - | - |
| Entities | `customer`, `contact`, `vendor`, `employee` (each with the `addressBook` sublist) |
| Items | `inventoryItem`, `nonInventorySaleItem`, `serviceSaleItem` (with the `price` sublist) |
| Transactions | `estimate`, `salesOrder`, `itemFulfillment`, `invoice`, `customerPayment`, `creditMemo` (with the `item` sublist and billing and shipping address subrecords; payments and credit memos with the `apply` sublist) |
| Lists | `subsidiary`, `currency`, `term`, `department`, `location`, `classification`, `customerStatus`, `account`, `priceLevel`, `contactRole` |
| Custom | `customlist_*` and `customrecord_*` types from seed config (see [Custom fields and records](#custom-fields-and-records)) |

Transactions work out what NetSuite works out: each line's rate from the item's base price in the order's currency (a rate or amount in the body sets a custom price, price level `-1`), the amount as quantity times rate, the subtotal and total, the document number, and the customer's default addresses. Invoices get a due date from their terms and track the amount paid and remaining. Every save or delete of a transaction brings the linked ones up to date:

* A sales order is Pending Approval until `orderStatus` approves it. After that its status follows its fulfillments and invoices: Pending Fulfillment (B) until something ships, Partially Fulfilled (D), Pending Billing/Partially Fulfilled (E) when shipped lines wait to be billed, Pending Billing (F) when every item that ships has shipped, and Billed (G). Service lines need no fulfillment, so an approved order of services alone is Pending Billing. Each line tracks `quantityFulfilled` and `quantityBilled`.
* An invoice is Open (A) until payments and credit memos applied to it cover the total, then Paid In Full (B), with `amountPaid` and `amountRemaining`.
* A credit memo is Open (A) until all of it is applied, then Fully Applied (B), with `applied` and `unapplied`. A payment keeps `applied` and `unapplied` too.
* An estimate turns Processed (B) when a sales order or invoice is made from it.

A customer's `balance` adds up its open invoices less money received but not yet applied, `overdueBalance` counts the invoices past due, and `unbilledOrders` is what its approved orders have left to bill.

Payments (`customerPayment`, with `customer` and `payment`) and credit memos (`creditMemo`, with `entity` and item lines) pay invoices through their `apply` sublist: `{ "doc": { "id": "12" }, "apply": true, "amount": 50 }`. The amount defaults to what the invoice still owes; more than that is a `USER_ERROR` at `apply.items[0].amount`, an invoice of another customer is `INVALID_CONTENT`, and a payment that applies more than `payment` is a `USER_ERROR` at `payment`. With `autoApply: true` the money goes to the customer's open invoices oldest first. Only applied lines are kept, and an applied payment or memo can be deleted to free the invoice again. An item fulfillment (`itemFulfillment`) can only be made by transforming a sales order, and cannot ship more than is left on a line.

Every account starts with these records (IDs are Baseline's): currencies 1 USD, 2 GBP, 3 CAD, 4 EUR; subsidiary 1 Parent Company; terms 1 Net 15, 2 Net 30, 3 Net 60, 4 Due on receipt; customer statuses 6 to 13 (13 is `CUSTOMER-Closed Won`, the default for new customers); accounts 1 Checking, 2 Accounts Receivable, 3 Inventory Asset, 4 Sales, 5 Cost of Goods Sold, 6 Accounts Payable; price level 1 Base Price; the administrator employee; and role 3 Administrator. Entities share one internal ID sequence, as do items and transactions; lists number on their own.

Every account also has contact role -10 Primary Contact.

Baseline choices where NetSuite's documentation is silent: an unknown record type is 404 `NONEXISTENT_ID` (`Record type 'x' does not exist.`), an unknown body field is `INVALID_CONTENT` naming it, document numbers are `SO1`, `INV1`, and so on, an entity ID defaults to the company or person name, and `PUT` without `eid:` is 405.

## Collections

* `GET /services/rest/record/v1/{type}` — List records: in ascending internal ID order

The list is `{ links, count, hasMore, items: [{ links, id }], offset, totalResults }`. `limit` is 1 to 1000 (the default), `offset` must be a multiple of `limit`, and the links are `prev` and `first`, `next` and `last` when there are more, then `self`, keeping `q`.

`q` filters with N/query operators by field type: `EMPTY` and `EMPTY_NOT` for any field; `IS` and `IS_NOT` for checkboxes; `ANY_OF`, `BETWEEN`, `EQUAL`, `GREATER`, `GREATER_OR_EQUAL`, `LESS`, `LESS_OR_EQUAL`, and `WITHIN` (each with `_NOT`) for numbers, select fields (by internal ID), and `id`; `CONTAIN`, `IS`, `START_WITH`, and `END_WITH` (each with `_NOT`) for text and enumerations; and `AFTER`, `BEFORE`, `ON`, `ON_OR_AFTER`, and `ON_OR_BEFORE` (each with `_NOT`) for dates, written `"1/1/2019"` or `2019-01-01`. AND binds tighter than OR and parentheses group; values with spaces are quoted, and `BETWEEN`, `WITHIN`, and `ANY_OF` take a list (`id ANY_OF [1, 2, 3]`). Text compares without case, and an operator ending in `_NOT` also matches empty fields. A query that does not parse is `INVALID_PARAMETER` with `o:errorQueryParam: q`.

```bash theme={null}
curl -H "Authorization: Bearer ns-baseline-local-token" \
  "$NETSUITE_EMULATOR_URL/services/rest/record/v1/customer?q=companyName%20START_WITH%20%22Acme%22%20AND%20isInactive%20IS%20false"
```

## Transforms

* `POST /services/rest/record/v1/{type}/{id}/!transform/{target}` — Transform record: makes a new record from an existing one, as Transform Record does in the UI, and answers 204 with the new record's `Location`

The body sets or overrides fields on the new record; lines made from an order carry `orderLine` (the order's `line`), so a body line `{ "orderLine": 1, "quantity": 2 }` changes that line and `quantity: 0` drops it.

| Source | Targets Baseline builds |
| - | - |
| `customer` | `estimate`, `salesOrder`, `invoice`, `customerPayment`, `vendor` |
| `vendor` | `customer` |
| `estimate` | `salesOrder`, `invoice` |
| `salesOrder` | `itemFulfillment`, `invoice` |
| `invoice` | `customerPayment`, `creditMemo` |

A sales order turns into an item fulfillment of what is left to ship and an invoice of what is ready to bill: the shipped but unbilled quantity of items that ship, and the unbilled quantity of services. Once nothing is left, the transform is a `USER_ERROR` (`Error while accessing a resource. You can not initialize invoice: invalid reference 7.`), as is transforming an order still pending approval. A transform from an invoice applies the new payment or credit memo to that invoice. A transform from a customer starts a transaction for that customer, which takes the customer's defaults as a direct create does; customer to vendor (and back) copies the name, contact details, subsidiary, currency, terms, and address book.

The other pairs NetSuite lists (such as `salesOrder` to `cashSale`, `employee` to `expenseReport`, or `invoice` to `returnAuthorization`) end in record types Baseline does not model, so they are 404 `NONEXISTENT_ID` (`Record type 'cashSale' does not exist.`), as for any unknown type. A pair NetSuite does not support is 400 `INVALID_PARAMETER` (`Invalid transformation. A customer payment record cannot be transformed into a invoice record.`). Send `Accept: application/vnd.oracle.resource+json; type=create-form` to get the record the transform would make without saving it.

## Record actions

* `POST /services/rest/record/v1/{type}/{id}/@{action}` — Run record action: answers 200 `{ "links": [...], "result": true }`

Of the types Baseline models, only invoices have actions: `markforgrouping` (sets `forInvoiceGrouping`; refused with `This invoice is already marked.`, `The invoice is partially or fully paid.`, or `There is no sales order associated with this invoice.`), `unmarkforgrouping` (`This is already a standalone invoice.` when not marked), and `removeinvoicefromgroup` (always `This is already a standalone invoice.`, since Baseline does not model invoice groups). Refusals are 400 `USER_ERROR` with Oracle's messages; an unknown action is 400 `INVALID_PARAMETER`.

## Attach and detach

* `POST /services/rest/record/v1/{type}/{id}/!attach/contact/{contactId}` — Attach contact: to a customer, vendor, or transaction, with an optional role (`{ "role": { "id": "-10" } }` for Primary Contact); answers 204

`!detach/contact/{contactId}` removes it. On a customer the attachment shows in the `contactRoles` sublist. Attaching a file is 404 (Baseline has no file cabinet); any other record type, or a target that takes no contacts, is 400 `INVALID_PARAMETER`, and an unknown role is a `USER_ERROR` at `role`. Deleting a contact detaches it everywhere.

## Forms and select options

* `POST /{type}` — Create form: with `Accept: application/vnd.oracle.resource+json; type=create-form` it answers 200 with the record a create would make from the body (defaults, prices, totals, and addresses filled in), without saving it or checking mandatory fields.
* `PATCH /{type}/{id}` — Edit form: with `type=edit-form` it does the same for an update.
* `POST /{type}` or `PATCH /{type}/{id}` — Select options: with `type=select-options` and `fields=entityStatus,currency` it lists the values each select, multiselect, or enumeration field accepts: `{ links, entityStatus: { _selectOptions: { links, items: [{ id, refName }], count, offset, hasMore, totalResults } } }`. Sublist fields are named `item.item`. `q` filters by option name, one condition per field separated by commas (`entityStatus START_WITH PROSPECT, currency IS "Euro"`, with `IS`, `CONTAIN`, or `START_WITH`), and `limit` (default 100) and `offset` page every field. Inactive records are left out, and numeric IDs are numbers. A field that is not a select is `INVALID_PARAMETER` with `o:errorQueryParam: fields`.

## Custom fields and records

Seed config defines custom lists, custom record types, and custom fields on standard records. They show in REST under their script IDs in lower case, as NetSuite shows them, and work everywhere standard fields and types do: bodies, `fields`, `q`, sublists, select options, SuiteQL, the metadata catalog, and the inspector.

```json theme={null}
{
  "netsuite": {
    "custom_lists": [
      {
        "id": "customlist_region",
        "values": [
          "North",
          {
            "value": "South",
            "abbreviation": "S"
          },
          {
            "value": "Old",
            "inactive": true
          }
        ]
      }
    ],
    "custom_records": [
      {
        "id": "customrecord_project",
        "name": "Project",
        "fields": [
          {
            "id": "custrecord_budget",
            "label": "Budget",
            "type": "currency",
            "mandatory": true
          },
          {
            "id": "custrecord_customer",
            "label": "Customer",
            "type": "select",
            "source": "customer"
          }
        ]
      },
      {
        "id": "customrecord_ticket",
        "name": "Ticket",
        "autonumber": {
          "prefix": "TKT-",
          "min_digits": 4
        }
      }
    ],
    "custom_fields": [
      {
        "id": "custentity_tier",
        "label": "Tier",
        "type": "select",
        "source": "customlist_region",
        "applies_to": ["customer"]
      },
      {
        "id": "custbody_po_ref",
        "label": "PO Reference",
        "type": "text",
        "mandatory": true,
        "applies_to": ["salesOrder"]
      },
      {
        "id": "custcol_note",
        "label": "Line Note",
        "type": "textarea"
      },
      {
        "id": "custitem_color",
        "label": "Color",
        "type": "multiselect",
        "source": "customlist_region"
      }
    ],
    "records": {
      "customrecord_project": [
        {
          "name": "Apollo",
          "custrecord_budget": 1000,
          "externalId": "APOLLO"
        }
      ]
    }
  }
}
```

* The prefix says where a field goes: `custentity` on customers, contacts, vendors, and employees; `custitem` on items; `custbody` on transaction bodies; `custcol` on the item lines of estimates, sales orders, invoices, and credit memos; `custrecord` on a custom record type. `applies_to` narrows it to some record types.
* Types: `text`, `textarea`, `long_text`, `email`, `phone`, `url`, `integer`, `decimal`, `currency`, `percent`, `checkbox` (a JSON boolean, false by default), `date` (`YYYY-MM-DD`), `datetime`, `select`, and `multiselect`. A select's `source` is a record type, a `customlist_` ID, or a `customrecord_` ID. `mandatory` fields are named by label when missing, and `default` fills new records.
* A custom list (`customlist_region`) is a record type with `name` (the value), `abbreviation`, and `isInactive`. A custom record type (`customrecord_project`) has `name`, `isInactive`, `owner` (the administrator by default), `created`, `lastModified`, and its `custrecord_` fields. With `autonumber`, new records are numbered into `name` and the entered name goes in `altName`. `include_name: false` drops the Name field.
* In SuiteQL, custom fields are columns (`custentity_tier`), and `customlist_region` and `customrecord_project` are tables.

Baseline choices: Oracle does not publish the REST JSON of custom lists and custom records, so their field names (`name`, `altName`, `abbreviation`, `owner`) are Baseline's, taken from the SuiteScript record fields. Custom lists and record types each number their own internal IDs from 1.

## Asynchronous requests

Any REST request sent with `Prefer: respond-async` answers 202 with no body, `Preference-Applied: respond-async`, and `Location: .../services/rest/async/v1/job/{id}`. The job resources are:

* `GET /services/rest/async/v1/job/{id}` — Get job: `{ links, completed, id, progress, task: { links } }`
* `GET /job/{id}/task` — List job tasks: as a collection
* `GET /job/{id}/task/{taskId}` — Get job task: `{ links, completed, id, progress, startTime, endTime }`
* `GET /job/{id}/task/{taskId}/result` — Get task result: the request's own response, its status, body, `Content-Type`, and `Location`
* `GET /job/{id}/task/{taskId}/request` — Get task request: as submitted, with `method`, `url`, `body`, `parameters`, `service` (`RECORD` or `QUERY`), and the `prefer...` flags

Baseline runs a job as soon as it is submitted, so it is already complete on the first poll. A job is visible only to the user who submitted it. `X-NetSuite-idempotency-key` refuses a second job with the same key: 400 `IDEMPOTENCY_ERROR` with the first job in `Location`.

Batches run as asynchronous jobs with one task per record, up to 100:

* `PUT /record/v1/{type}` — Batch write records: `POST`, `PATCH`, or `PUT` with `Content-Type: application/vnd.oracle.resource+json; type=collection` and `{ "items": [...] }`. `PATCH` items name their record with `id` or `externalId`; `PUT` items upsert by `externalId`.
* `GET /record/v1/{type}?expandRecords=true&ids=1,eid:B2` — Batch read records
* `DELETE /record/v1/{type}?ids=...` — Batch delete records

Baseline choices: a job's `progress` is `succeeded`, or `failed` when any task answered an error, and an item without an ID becomes a failed task.

## SuiteQL

* `POST /services/rest/query/v1/suiteql` — Run SuiteQL query

The `Prefer: transient` header is required, as Oracle documents it; without it the answer is 400 with `o:errorHeader: Prefer` (Baseline's wording and `INVALID_HEADER` code). The body is `{ "q": "...", "params": ["...", ...] }`, where `params` fills anonymous `?` placeholders in order. `limit` (1 to 1000, the default) and `offset` (a multiple of `limit`) in the URL page the result, with the same links as collections, and at most 100,000 rows are returned. `netsuite-api-client`'s `query()` and `queryAll()` work unchanged.

```bash theme={null}
curl -X POST -H "Authorization: Bearer ns-baseline-local-token" -H "Prefer: transient" \
  -H "Content-Type: application/json" \
  "$NETSUITE_EMULATOR_URL/services/rest/query/v1/suiteql?limit=10" \
  -d '{"q": "SELECT id, companyname, BUILTIN.DF(subsidiary) AS sub FROM customer WHERE email LIKE ?", "params": ["%@acme.example"]}'
```

```json theme={null}
{
  "links": [{ "rel": "self", "href": "http://localhost:4000/services/rest/query/v1/suiteql?limit=10" }],
  "count": 1,
  "hasMore": false,
  "items": [{ "links": [], "id": "2", "companyname": "Acme Corp", "sub": "Parent Company" }],
  "offset": 0,
  "totalResults": 1
}
```

As on the service, every value is text, keys are lowercase (aliases too), NULL columns are left out of the item, booleans are `T` and `F`, and dates print as `M/D/YYYY` (datetimes too; use `TO_CHAR` for the time). An expression without an alias is named `expr1`, `expr2`, and so on.

Supported: `SELECT [TOP n] [DISTINCT]`, `*` and `alias.*`, `AS` aliases, `FROM` with table aliases, `[INNER] JOIN`, `LEFT [OUTER] JOIN`, and `RIGHT [OUTER] JOIN` with `ON`, `WHERE` (`=`, `<>`, `!=`, `<`, `>`, `<=`, `>=`, `LIKE` with `%` and `_`, `IN`, `BETWEEN`, `IS [NOT] NULL`, `AND`, `OR`, `NOT`), `GROUP BY`, `HAVING`, `ORDER BY` with `ASC`, `DESC`, `NULLS FIRST`, `NULLS LAST`, select aliases, or positions, `FETCH FIRST n ROWS ONLY`, `ROWNUM`, `SYSDATE`, `CURRENT_DATE`, `CASE`, `||`, arithmetic (dates plus or minus days), `'...'` strings with `''` for a quote, `"..."` identifiers, and `--` comments. Functions: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX` (with `DISTINCT`), `BUILTIN.DF` (a reference's or list value's display name; a transaction status shows as `Sales Order : Pending Approval`), `BUILTIN.CF`, `COALESCE`, `NVL`, `NVL2`, `NULLIF`, `DECODE`, `CONCAT`, `UPPER`, `LOWER`, `INITCAP`, `LENGTH`, `SUBSTR`, `INSTR`, `REPLACE`, `TRIM`, `LTRIM`, `RTRIM`, `LPAD`, `RPAD`, `LEFT`, `RIGHT`, `ABS`, `CEIL`, `FLOOR`, `ROUND`, `TRUNC` (numbers and dates), `MOD`, `POWER`, `SQRT`, `SIGN`, `GREATEST`, `LEAST`, `TO_CHAR`, `TO_DATE`, `TO_NUMBER`, `ADD_MONTHS`, `LAST_DAY`, and `MONTHS_BETWEEN`.

Oracle's rules apply: NULL is unknown in comparisons (so `NOT IN` with a NULL in the list matches nothing), `''` is NULL, text compares case sensitively, ascending order puts NULLs last and descending order puts them first, columns outside aggregates must be in `GROUP BY`, and a text value compared with a date is read as `M/D/YYYY`. Table and field names match without case. Baseline choices where Oracle's documentation is silent: datetimes print as dates, text compares case sensitively (as Oracle does), and multiselect columns hold comma separated internal IDs.

Tables, with columns named after the REST fields in lower case (`companyname`, `entityid`, `isinactive`, `trandate`, ...), references as internal IDs, and `id` and `externalid` on each:

| Table | Rows |
| - | - |
| `customer`, `contact`, `vendor`, `employee` | Each entity type |
| `entity` | All four, with `type` (`CustJob`, `Contact`, `Vendor`, `Employee`) |
| `item` | Inventory, non-inventory, and service items, with `itemtype` (`InvtPart`, `NonInvtPart`, `Service`) |
| `transaction` | Every transaction type, with `type` (`Estimate`, `SalesOrd`, `ItemShip`, `CustInvc`, `CustPymt`, `CustCred`), `recordtype`, `createdfrom`, `status` (letter codes), `foreigntotal`, and `employee` (the sales rep) |
| `transactionline` | One main line per transaction (`mainline = 'T'`, `id` 0, the total) and one row per item line, with `transaction`, `linesequencenumber`, `item`, `quantity`, `rate`, `netamount`, `foreignamount`, and `memo` |
| `subsidiary`, `currency`, `term`, `department`, `location`, `classification`, `customerstatus` (also `entitystatus`), `account`, `pricelevel`, `contactrole` | The lists |
| `customlist_*`, `customrecord_*` | Custom lists and record types from seed config |

Item lines of sales transactions post as credits, so their `quantity`, `netamount`, and `foreignamount` are negative, as on the service; write `-tl.quantity` or `ABS(tl.quantity)` for the ordered amount. A credit memo is the other way round (main line negative, item lines positive), and item fulfillment lines have negative quantities and no amounts. On payments, the customer is the `entity` column.

```sql theme={null}
SELECT t.tranid, BUILTIN.DF(tl.item) AS item, -tl.quantity AS qty, tl.rate
  FROM transaction t
  JOIN transactionline tl ON tl.transaction = t.id
 WHERE t.type = 'SalesOrd' AND tl.mainline = 'F'
 ORDER BY t.trandate DESC, tl.linesequencenumber
```

A query that does not parse or names something unknown is 400 `INVALID_PARAMETER` with `o:errorPath: q`. The detail starts `Invalid search query. Detailed unprocessed description follows. Search error occurred: ` as the service's does, followed by Baseline's description, such as `Record 'x' was not found.`, `Field 'x' for record 'customer' was not found.`, `Column 'id' is ambiguously defined.`, `'x' is not a GROUP BY expression.`, or `Function 'X' is not supported in SuiteQL.`.

## Metadata

* `GET /services/rest/record/v1/metadata-catalog` — List metadata catalog
* `GET /metadata-catalog/{type}` — Get record type metadata

The catalog lists the record types, each with canonical (`application/json`), OpenAPI (`application/swagger+json`), JSON Schema (`application/schema+json`), and `describes` links; `?select=customer,invoice` narrows it. A record type's metadata answers JSON Schema (draft-06) by default and OpenAPI 3.0 with `Accept: application/swagger+json`, which also works on the catalog itself for the selected types. Filterable fields carry `x-ns-filterable`.

## Governance limits

* `GET /services/rest/system/v1/governanceLimits` — Get governance limits: `{ accountConcurrencyLimit, accountUnallocatedConcurrencyLimit, integrationConcurrencyLimit, integrationLimitType }`

The account limit comes from `account.concurrency_limit` (default 5), an integration's own limit from `integrations[].concurrency_limit`, and the unallocated limit is the account limit less every integration's own. `integrationLimitType` is `integrationSpecific` for an integration with its own limit, `accountLimit` for one without, and `internal` for a bearer token. Baseline does not enforce the limits.

## Server time

* `GET /services/rest/system/v1/serverTime` — Get server time: `{ "serverTime": "..." }` in UTC

`OPTIONS` requests (which `netsuite-api-client`'s `connect()` sends) are answered 204 by the shared CORS layer without checking credentials.

## Seed config

```json theme={null}
{
  "netsuite": {
    "account": {
      "id": "1234567",
      "name": "Acme",
      "concurrency_limit": 5
    },
    "roles": [
      {
        "name": "Integration Role"
      }
    ],
    "integrations": [
      {
        "name": "Order Sync",
        "consumer_key": "baseline-consumer-key",
        "consumer_secret": "baseline-consumer-secret",
        "oauth2": {
          "redirect_uris": ["http://localhost:3000/callback"]
        }
      }
    ],
    "tba_tokens": [
      {
        "token_id": "baseline-token-id",
        "token_secret": "baseline-token-secret",
        "integration": "Order Sync",
        "employee": "ivy@example.com",
        "role": "Integration Role"
      }
    ],
    "access_tokens": [
      {
        "token": "ns-baseline-local-token"
      }
    ],
    "records": {
      "employee": [
        {
          "firstName": "Ivy",
          "lastName": "Integrator",
          "email": "ivy@example.com",
          "subsidiary": {
            "id": "1"
          }
        }
      ],
      "customer": [
        {
          "companyName": "Acme Corp",
          "externalId": "ACME",
          "subsidiary": {
            "id": "1"
          }
        }
      ],
      "serviceSaleItem": [
        {
          "itemId": "Consulting",
          "externalId": "SVC-1",
          "price": {
            "items": [
              {
                "priceLevel": {
                  "id": "1"
                },
                "price": 150
              }
            ]
          }
        }
      ],
      "salesOrder": [
        {
          "entity": {
            "externalId": "ACME"
          },
          "item": {
            "items": [
              {
                "item": {
                  "externalId": "SVC-1"
                },
                "quantity": 3
              }
            ]
          }
        }
      ]
    }
  }
}
```

* `id`: the TBA realm
* `concurrency_limit`: reported by governanceLimits
* `name`: IDs from 1000, in order
* `oauth2`: OAuth 2.0; see the OAuth 2.0 section
* `integration`: by name or consumer key
* `employee`: by email or internal ID; the administrator when left out
* `role`: by name or ID; Administrator when left out
* `records`: request bodies by type, created in order
* `state` (not set above, for example `blocked`): refuses every request
* `tba` (not set above, for example `false`): turns token-based authentication off
* `concurrency_limit` (not set above, for example `2`): this integration's own share of the account limit

`integrations[].oauth2` and `certificates` are described under [OAuth 2.0](#oauth-2-0). `custom_lists`, `custom_records`, and `custom_fields` are described under [Custom fields and records](#custom-fields-and-records); they are set up before `records`, so seed records can use them.

Seed records go through the same checks as `POST`, so a bad one stops the start with an error that starts with `netsuite seed:` and names the entry. A record with an `externalId` that already exists is updated rather than created again.

## Inspector

`GET /` serves a tabbed inspector with a tab per entity, item, and transaction type (each record expandable to its full JSON), the lists (custom lists too), the custom record types and their records, asynchronous jobs with each task's request and status, a SuiteQL console that runs a query read only and shows the rows as the API prints them, the account with its integrations (TBA and OAuth 2.0 settings), TBA tokens, bearer and OAuth 2.0 tokens, client credentials certificates, and roles, and the login audit trail with each sign-in's status, reason, method, request, entity, role, and integration.

## Limits

Transforms build only the pairs listed under [Transforms](#transforms); there are no cash sales, return authorizations, vendor bills, purchase orders, deposits, refunds, or invoice groups, and record actions exist only for invoices. Custom fields cannot be added at run time, and there are no custom segments, custom transactions, or custom forms. Async jobs run at once and concurrency limits are reported, not enforced. There is no NetSuite as OpenID Connect provider (`openid` scope, ID tokens, userinfo), no dynamic client registration, and no RESTlets or SuiteAnalytics Connect behind the `restlets` and `suite_analytics` scopes. SuiteQL has no subqueries, `UNION`, `WITH`, window functions, `LISTAGG`, `EXTRACT`, Oracle's `(+)` join marker, or `BUILTIN` functions other than `DF` and `CF`. There are no saved searches, SuiteAnalytics workbooks, file cabinet, RESTlets, or SOAP web services, no role permissions beyond an active role (every session can read and change every record), no tax, discounts, inventory counts, or GL postings, and no multi-currency exchange. NetSuite has no outbound webhooks of its own, so neither does the emulator.

## Conformance

| | |
| - | - |
| Routes | 34 supported or partial, 0 planned |
| Route gate | fail |
| Spec gate | no public spec |
| SDKs tested | `netsuite-api-client@1.1.1`, `openid-client@6.8.8` |
| Dual mode | not yet |

See [CONFORMANCE.md](https://github.com/deploybaseline/cli/blob/main/CONFORMANCE.md) for every provider.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.