mckinleycard / netsuite-rest-php
PHP client for the NetSuite REST API
Requires
- php: ^8.2
- ext-openssl: *
- guzzlehttp/guzzle: ^7.0
- web-token/jwt-library: ^3.4.10 || ^4.2
Requires (Dev)
- phpunit/phpunit: ^10.0
Suggests
- ext-bcmath: Fast enough PS256 signing when ext-gmp is not available. Either ext-gmp or ext-bcmath is required at runtime to authenticate.
- ext-gmp: Fastest PS256 signing. Either ext-gmp or ext-bcmath is required at runtime to authenticate.
Provides
None
Conflicts
None
Replaces
None
README
A standalone PHP library for interacting with NetSuite's REST APIs — OAuth 2.0 machine-to-machine (M2M) authentication, SuiteQL, and the Record API today.
Independent of Magento or any other framework; anything under src/ is usable by any PHP
application.
Project status
This package is pre-1.0 and its public API may still change based on real usage.
Implemented today:
- Configuration (
NetSuiteConfig) - OAuth 2.0 client-credentials (machine-to-machine) authentication, verified against a real NetSuite sandbox
- An authenticated REST transport (
NetSuiteRestClient) - SuiteQL (
SuiteQLService), including automatic and manual pagination - The Record API (
RecordService) — single-record create/read/update/delete and upsert-by-external-ID, verified against a real NetSuite sandbox - A consumer-facing facade (
NetSuite) composing all of the above into a single entry point — see Quick Start
Requirements
- PHP 8.2+
ext-gmporext-bcmath— one of the two is required to authenticate, see below- Composer
Why ext-gmp or ext-bcmath is required
NetSuite's OAuth 2.0 client-credentials grant requires a JWT client assertion signed with
PS256 (RSA-PSS), using an RSA key of at least 3072 bits (this README's setup example
uses 4096). Unlike its RS256 implementation, web-token/jwt-library's PS256 doesn't go
through OpenSSL: it does the RSA arithmetic in PHP through brick/math, which uses
ext-gmp if it is loaded, then ext-bcmath, and otherwise pure PHP. For a key this large,
the pure-PHP fallback is slow enough to break correctness, not just performance.
Measured signing one assertion with a real 4096-bit key:
| Runtime | PS256 sign |
|---|---|
ext-gmp (any supported PHP) |
~0.01 s |
ext-bcmath, PHP 8.4+ |
~0.3 s |
ext-bcmath, PHP 8.2 / 8.3 |
~12 s |
| neither (pure PHP) | ~7 minutes |
NetSuite's client assertion must carry a short lifetime (this library uses 60 seconds), so
without either extension, signing alone outlasts that window — a valid assertion arrives
already expired, and NetSuite reports it as a bare invalid_grant with no hint that the real
cause is signing latency rather than misconfiguration. Lengthening the assertion's lifetime
would just paper over slow signing rather than fix it.
Composer can't require "one of" two extensions, so neither is declared in require (both are
listed under suggest). Instead, the library checks before signing and throws an
AuthenticationException naming the missing extensions, without contacting NetSuite.
ext-gmp is the best choice; ext-bcmath is fine on PHP 8.4+ and works, but slowly, on
PHP 8.2 / 8.3. Access tokens are cached, so signing happens about once per token lifetime
per process.
Installation
composer require mckinleycard/netsuite-rest-php
The package is pre-1.0: a minor release (0.2 → 0.3) may include breaking changes, so
Composer's default ^0.x constraint is the right one — it accepts bug-fix releases but not
the next minor. See CHANGELOG.md for what changed between versions.
NetSuite OAuth 2.0 M2M setup
This library authenticates using NetSuite's OAuth 2.0 client-credentials (machine-to-machine) grant with a certificate-based JWT client assertion (PS256). Setting this up in NetSuite takes several distinct steps, each in a different part of the UI.
1. Enable the required features
Setup → Company → Enable Features → SuiteCloud tab:
- REST Web Services — required to call any NetSuite REST endpoint at all (SuiteQL, the Record API, etc).
- OAuth 2.0 — required separately from REST Web Services; this is what makes OAuth 2.0 grants (including client credentials) available on Integration records.
These are two distinct checkboxes controlling two distinct things. Enabling only one leaves you either unable to authenticate, or authenticated but unable to call the REST API at all. (Exact checkbox wording can shift slightly between NetSuite versions — confirm against your account's Enable Features page.)
2. Create an Integration record
Setup → Integration → Manage Integrations → New:
- Name it, set State to Enabled.
- On the Authentication subtab, check Client Credentials (Machine to Machine) Grant.
- Select scopes — for this package today, that's REST Web Services
(
rest_webservices); see Scopes below. - Save.
NetSuite displays a Client ID and Client Secret exactly once, immediately after
saving — neither can be retrieved again later. Record the Client ID — it becomes
NetSuiteConfig::$clientId and the JWT's iss claim.
This library's certificate-based client-credentials implementation does not use the Client Secret — no code path in this package reads it.
3. Generate a certificate
This library signs the client assertion with PS256 (RSA-PSS). NetSuite permits RSA keys of 3072 or 4096 bits for this; use 4096:
openssl req -x509 -newkey rsa:4096 -keyout private.pem -out public.pem -days 365 -nodes
private.pemstays with your application. It becomesNetSuiteConfig::$privateKeyand must never be committed to source control or uploaded to NetSuite.public.pemis the only part NetSuite ever sees — it's uploaded in the next step.
4. Map the certificate to an Entity, Role, and Integration
Setup → Integration → Manage Authentication → OAuth 2.0 Client Credentials (M2M) Setup → Create New:
- Entity — the employee record this integration authenticates as.
- Role — the role that entity uses for this integration; it needs whatever permissions your queries/records actually touch.
- Application — the Integration record from step 2 (only selectable if Client Credentials (M2M) Grant is checked on it).
- Upload
public.pem. - Save. NetSuite assigns a Certificate ID — this becomes
NetSuiteConfig::$certificateIdand is sent as the JWT header'skid.
This mapping is environment-specific. A mapping created in one account (e.g. production) does not carry over to another (e.g. sandbox) — each account needs its own Entity/Role/Integration/certificate mapping configured separately. Sandbox refreshes clear this mapping entirely, so refreshing a sandbox will require redoing this step.
5. Scopes
NetSuiteConfig::$scopes must match what's enabled on the Integration record. For everything
this package currently does (SuiteQL over REST, and any direct NetSuiteRestClient calls),
that's:
scopes: ['rest_webservices']
restlets is only relevant if you're also calling NetSuite RESTlets (script-based custom
endpoints) — nothing in this package requires it. suite_analytics is a separate NetSuite
feature (SuiteAnalytics Connect, a JDBC/ODBC connection) and is not required for SuiteQL
over REST, which is what this package uses — don't enable it on the Integration record just
to run SuiteQL.
6. The account-specific REST URL
NetSuiteConfig::$restApiBaseUrl must be the exact REST domain NetSuite gives your account,
e.g. https://1234567-sb1.suitetalk.api.netsuite.com. Don't construct this from the account
ID yourself — not every account follows that pattern, and this library deliberately never
tries to derive it for you.
Quick Start
use NetSuiteRest\Config\NetSuiteConfig; use NetSuiteRest\NetSuite; $config = new NetSuiteConfig( clientId: '<Client ID from the Integration record>', certificateId: '<Certificate ID from OAuth 2.0 Client Credentials (M2M) Setup>', privateKey: file_get_contents('/path/to/private.pem'), restApiBaseUrl: 'https://1234567-sb1.suitetalk.api.netsuite.com', scopes: ['rest_webservices'], ); $netsuite = new NetSuite($config); foreach ($netsuite->suiteQL()->query('SELECT id, companyname FROM customer WHERE id = ?', [42]) as $row) { echo $row['companyname'], PHP_EOL; } $customer = $netsuite->records()->get('customer', 42);
NetSuite owns OAuth 2.0 authentication, JWT signing, token caching, and the HTTP transport
internally. suiteQL() and records() return the exact same SuiteQLService/RecordService
documented below — just already wired to one shared, authenticated client — so everything in
the SuiteQL and Record API sections applies unchanged whether you
reach them through NetSuite or construct them yourself (see
Advanced: manual construction).
Customizing the HTTP client or authentication
NetSuite's constructor takes two optional arguments beyond $config, and no others — this is
a deliberately fixed, small set of customization points, not a general options mechanism:
new NetSuite($config, $myGuzzleClient); // custom middleware, proxy, timeouts, logging, etc. new NetSuite($config, $myGuzzleClient, $myTokenProvider); // e.g. a token provider backed by a shared cache
Supplying a custom $httpClient alone is enough on its own — the default
OAuth2ClientCredentialsProvider is built against whichever client you pass, not a second,
separately constructed one. Supplying a custom $tokenProvider (anything implementing
AccessTokenProviderInterface) replaces authentication entirely, independent of the HTTP
client.
By default (no $httpClient supplied), the client NetSuite builds itself sets
connect_timeout: 15 and timeout: 120 seconds — Guzzle's own defaults otherwise allow a
request to hang indefinitely. This bounds a single request (one token acquisition, or one
SuiteQL/Record API call); it has no bearing on how long a multi-page query() iteration takes
overall, since each page fetched while iterating is its own separate HTTP request. Supplying
your own $httpClient replaces these defaults entirely — the package applies no timeout policy
on top of an injected client, so your own settings (including no timeout at all) are respected
exactly as configured.
Escape hatch: NetSuite::client()
$netsuite->client()->patch('record/v1/customer/123', ['comments' => null], ['replaceSelectedFields' => 'true']);
Returns the same authenticated NetSuiteRestClient instance suiteQL()/records() are built
on, for anything they don't model — e.g. the Record API options
deliberately left out of RecordService — without
constructing a second, separate transport by hand.
Configuration reference
NetSuiteConfig is a plain, framework-agnostic value object — supply values from wherever
your application keeps configuration (environment variables, a secrets manager, etc.); this
library doesn't read any of that itself.
| Constructor argument | What it is |
|---|---|
clientId |
The Client ID from the Integration record (setup step 2). Sent as the JWT iss claim. |
certificateId |
The Certificate ID from OAuth 2.0 Client Credentials (M2M) Setup (setup step 4). Sent as the JWT kid. |
privateKey |
PEM-encoded RSA private key content — not a file path. Read the file yourself before constructing. |
restApiBaseUrl |
The account-specific REST base URL (setup step 6). |
scopes |
Array of OAuth 2.0 scopes matching the Integration record, e.g. ['rest_webservices']. |
SuiteQL
SuiteQLService wraps NetSuite's SuiteQL REST endpoint (query/v1/suiteql). It has two
methods.
query() — the normal way to run a query
foreach ($suiteql->query('SELECT id, companyname FROM customer WHERE id = ?', [42]) as $row) { // $row is an associative array, exactly as NetSuite returned it — no renaming, no coercion }
- Automatically pages through NetSuite's REST pagination for you, at a default page size of 1000.
- Pagination is offset-based, not keyset-based — NetSuite doesn't guarantee stable relative
row order across separate page requests unless your query specifies one. Add an
ORDER BY(e.g. on the table's id) to any query you page through, the same as you would for any offset-paginated SQL API. - Lazy: calling
query()does no HTTP work by itself. It returns aniterable, and requests are only made as you iterate it, one page at a time. If your query is invalid, or NetSuite is unreachable, the resulting exception is thrown during iteration, not at the point you calledquery(). - Single-pass: the returned iterable is single-use — once it's been iterated, it's spent
and isn't reusable. If you need the results more than once, or want every page fetched up
front rather than interleaved with your own per-row processing (slow row-by-row work
otherwise delays later page requests until your processing catches up), collect them
yourself first:
$rows = iterator_to_array($suiteql->query(...)). This only narrows the window between page requests — it does not give you snapshot isolation or transactional consistency; NetSuite can still change the underlying data between any two page requests either way. - Bound parameters (below) are the second argument; an optional third argument overrides the page size (default 1000).
queryPage() — manual pagination
$page = $suiteql->queryPage('SELECT id, companyname FROM customer', limit: 50, offset: 0); $page['items']; // this page's rows $page['count']; // how many rows this page actually returned $page['totalResults']; // total rows matching the query, across all pages $page['hasMore']; // whether another page exists
Use this when you want direct control over paging (e.g. a paged UI), or want totalResults
up front without fetching everything. query() is built on top of this internally.
Bound parameters
SuiteQL supports anonymous ? placeholders, bound via the $params argument:
$suiteql->query('SELECT * FROM transaction WHERE id BETWEEN ? AND ?', [100, 200]);
Prefer this over interpolating values into the SQL string yourself — exactly as with any other SQL API, string interpolation invites SQL injection the moment any part of the query comes from outside your own code.
Row shape
Rows from query()/queryPage() are passed through exactly as NetSuite's SuiteQL endpoint
returns them — no renaming, no coercion, nothing stripped:
- Expect scalar values to commonly arrive as strings. NetSuite's SuiteQL endpoint returns values such as IDs, quantities, and prices as JSON strings in normal query results. The package does not coerce them into PHP numeric or boolean types; cast values explicitly when your application requires a specific type.
- A column whose value is
nullmay be absent from the row entirely, rather than present with anullvalue. Check witharray_key_exists()/??, not an assumption that every selected column is always a key in every row. - Rows include NetSuite's own
linksentry. This package doesn't strip it — ignore orunset()the key yourself if you don't need it.
Record API
RecordService wraps NetSuite's Record API (record/v1/{recordType}/...) for single-record
operations. It is deliberately scoped to a record as the unit of work — NetSuite's own
collection endpoint (GET record/v1/{recordType}) returns links rather than record bodies, so
using it for datasets would just reintroduce the fetch-IDs-then-fetch-each-record pattern this
library is meant to move away from. Use SuiteQL for datasets; use RecordService
when you already know which single record you need, or are writing one.
// Read (internal ID or external ID) $customer = $records->get('customer', 42); $customer = $records->get('customer', 42, fields: ['companyName', 'email']); $customer = $records->get('customer', 42, expandSubResources: true); // inline sublists/subrecords $customer = $records->getByExternalId('customer', 'SKU-001'); // Create — returns the new record's NetSuite-assigned internal ID $id = $records->create('customer', ['companyName' => 'Acme, Inc.', 'subsidiary' => ['id' => 1]]); // Update — a partial update; omitted fields are left unchanged $records->update('customer', 42, ['companyName' => 'Acme Updated']); $records->updateByExternalId('customer', 'SKU-001', ['companyName' => 'Acme Updated']); // Delete $records->delete('customer', 42); $records->deleteByExternalId('customer', 'SKU-001'); // Upsert — create if externalId doesn't exist yet, update if it does; one call, no // existence check needed beforehand. Returns the affected record's internal ID either way. $id = $records->upsert('customer', 'SKU-001', ['companyName' => 'Acme, Inc.']);
Rejecting unknown fields: withStrictPropertyNames()
By default NetSuite silently ignores any body field that doesn't exist on the record: a misspelled field name, or a custom field that exists in production but not in your sandbox, is dropped and the write still succeeds. To have NetSuite reject such a write instead, use a strict copy of the service:
$strict = $netsuite->records()->withStrictPropertyNames(); $strict->create('salesOrder', $body); // 400 ApiException: "Unknown field name 'custbody_typo'..."
The strict copy sends X-NetSuite-PropertyNameValidation: Error on create(), update(),
updateByExternalId(), upsert(), transform() and transformByExternalId(), covering sublist line
fields too; a rejected write changes nothing. Reads and deletes are
unaffected. withStrictPropertyNames() returns a new instance and leaves the one you called it
on unchanged, so $netsuite->records() keeps NetSuite's default behavior.
Sublist lines on update and upsert: merged, or replaced
By default, sublist lines in an update()/updateByExternalId()/upsert() body are merged into the
record's: a line that carries its line key updates that line, a line without one is added, and lines
you don't mention are kept. To make a sublist hold exactly the lines you send, name it in
replaceSublists:
// Adds a line (existing lines stay) $records->update('salesOrder', 123, ['item' => ['items' => [['item' => ['id' => 10], 'quantity' => 1]]]]); // Changes line 2's quantity (other lines stay) $records->update('salesOrder', 123, ['item' => ['items' => [['line' => 2, 'quantity' => 5]]]]); // The order now has exactly this one line $records->update( 'salesOrder', 123, ['item' => ['items' => [['item' => ['id' => 10], 'quantity' => 1]]]], replaceSublists: ['item'], );
Every sublist named in replaceSublists must be in the body (NetSuite rejects the request
otherwise, so the library throws InvalidArgumentException before sending it).
Transforms: creating one record from another
NetSuite's "transform" creates a record from an existing one, the way the UI's buttons do (a sales
order into an item fulfillment, invoice or return authorization; a customer into a sales order). NetSuite
fills the new record from the source, including the link back to it (createdFrom), body fields and,
by default, every line:
// A return authorization for the whole sales order $raId = $records->transform('salesOrder', 123, 'returnAuthorization', ['memo' => 'Damaged in transit']); // Only some lines: list the lines to keep and name the sublist they replace $raId = $records->transform( 'salesOrder', 123, 'returnAuthorization', ['item' => ['items' => [['orderLine' => 2, 'quantity' => 1]]]], replaceSublists: ['item'], ); // The source can be addressed by external ID too $raId = $records->transformByExternalId('salesOrder', 'SO-1001', 'returnAuthorization');
Both return the new record's internal ID. Without replaceSublists, NetSuite treats lines in $body
as edits of or additions to the lines it copied, not as a selection; to keep only some of the source's
lines, name the sublist in replaceSublists. Use a transform rather than create() with a createdFrom field: NetSuite silently
ignores createdFrom on a plain create, so the new record is neither linked to the source nor filled
from it. A transform is a write, so withStrictPropertyNames() applies to it.
Internal IDs vs. external IDs
NetSuite internal IDs are system-assigned, sequential integers — never alphanumeric — so
every method taking one types it as int. External IDs are caller-assigned, can be
alphanumeric, and stay stable across environments in a way internal IDs don't. The two are
different NetSuite addressing schemes, not two spellings of the same thing, so they're
exposed as explicitly separate methods (get() vs. getByExternalId(), etc.) rather than one
method accepting either — a loosely-typed int|string $id would let a numeric-looking
external ID silently get misread as an internal one.
upsert() only exists for external IDs: NetSuite requires an external ID to upsert by (there
is no "upsert by internal ID", since you can't invent an internal ID for a record that doesn't
exist yet).
create() and upsert() return the new/affected record's internal ID
NetSuite reports this via the Location response header, not the (empty) response body — this
is the only way the library can report it back to you. If a successful response doesn't
actually carry a usable ID this way, create()/upsert() throw UnexpectedResponseException
rather than returning an empty or null value — there's no honest fallback value to invent
there.
update(), updateByExternalId(), delete(), and deleteByExternalId() return nothing:
NetSuite's Location header on those just echoes the ID you already supplied, so there's
nothing new to hand back.
Note that upsert()'s response is identical either way (204, same Location) whether it
created or updated the record — NetSuite doesn't report which branch it took, and this library
has no way to recover that information either.
Options kept out of this version
NetSuite's Record API also supports replaceSelectedFields (controlling whether a null field in a
PATCH body deletes that field). It isn't exposed on RecordService yet — its behavior, alone and
combined with replace, isn't confirmed against a real account, and nothing in this library's current
use cases needs it. It's still reachable directly via
NetSuiteRestClient::patch(..., query: ['replaceSelectedFields' => 'true']) if you need it now.
Error handling
Every exception this package throws extends NetSuiteRest\Exception\NetSuiteException, so
catching that is always a safe catch-all. Beyond that:
| Exception | Means |
|---|---|
AuthenticationException |
Acquiring or refreshing an access token failed (bad credentials, unreachable token endpoint, malformed token response). |
ApiException |
NetSuite received the request and returned a non-success HTTP status (400/403/404/429/5xx/etc). Carries the status code and, when present, NetSuite's parsed error details (getType(), getTitle(), getErrorDetails()). |
TransportException |
A valid HTTP response was not received from NetSuite at all — DNS failure, connection failure, TLS problem, timeout. |
UnexpectedResponseException |
NetSuite returned a response the library considers successful, but its body couldn't be interpreted as expected (e.g. non-JSON where JSON was required). |
ApiException::getMessage() includes NetSuite's own diagnostic text when one is available —
specifically, the first o:errorDetails[].detail entry NetSuite returned, falling back to the
generic title when no detail is present. getErrorDetails() remains the complete-access path
for every detail NetSuite returned, not just the one used in the message.
None of these ever let a raw Guzzle exception escape to your code.
Retry behavior
NetSuiteRestClient performs exactly one authentication replay: if a request gets a 401,
it invalidates the cached access token, acquires a new one, and retries the same request
once. If that retry also returns 401, it becomes a normal ApiException — there is no
second retry. If acquiring the replacement token itself fails, the resulting
AuthenticationException propagates instead.
Nothing else is retried automatically — 429 and 5xx responses become ApiException
immediately, with no backoff. Retry/backoff for those is a deliberately separate,
not-yet-designed concern, particularly since this library will eventually perform NetSuite
transactions, where blind retries aren't safe without idempotency handling.
Advanced: manual construction
NetSuite is pure composition — a small wiring step, not a requirement. Nothing it does is
unavailable by constructing the same pieces yourself:
use GuzzleHttp\Client; use NetSuiteRest\Auth\OAuth2ClientCredentialsProvider; use NetSuiteRest\Config\NetSuiteConfig; use NetSuiteRest\Http\NetSuiteRestClient; use NetSuiteRest\Record\RecordService; use NetSuiteRest\SuiteQL\SuiteQLService; $config = new NetSuiteConfig(/* ... */); $httpClient = new Client(); $tokenProvider = new OAuth2ClientCredentialsProvider($config, $httpClient); $restClient = new NetSuiteRestClient($config, $tokenProvider, $httpClient); $suiteql = new SuiteQLService($restClient); $records = new RecordService($restClient);
This is exactly what NetSuite's constructor does internally. Reach for it directly if you
only want one capability and would rather not construct a NetSuite you're not otherwise
using, or if you're composing these classes into something of your own. For most
customization needs (a custom HTTP client, a custom token provider) NetSuite's constructor
already covers it — see Customizing the HTTP client or authentication
above — so this manual path is for when you want the lower-level classes themselves, not just
non-default behavior from them.
Lower-level: NetSuiteRestClient
RecordService and SuiteQLService are both built on NetSuiteRestClient, which stays
available directly for anything they don't cover (e.g. RecordService's own
options kept out of this version):
$response = $restClient->get('record/v1/customer/123'); $response->json(); // decoded body (array), or null for an empty body $response->getStatusCode(); $response->getHeader('Location'); // e.g. the created resource's URL after a 204 response $restClient->post('record/v1/customer', ['companyName' => 'Acme, Inc.']); $restClient->patch('record/v1/salesOrder/100', ['item' => []], query: ['replace' => 'item']); $restClient->put('record/v1/customer/eid:CID001', ['companyName' => 'Acme, Inc.']); $restClient->delete('record/v1/customer/123');
get()/post()/patch()/put()/delete() all accept query (query-string parameters —
e.g. NetSuite's replace parameter for sublist replacement on PATCH) and headers as named
arguments. Nothing here understands SuiteQL or record-type-specific behavior.
Testing
composer test # unit + integration
composer test:unit # no network access required
composer test:integration # requires real NetSuite sandbox credentials
Integration tests need real sandbox credentials, supplied via a local .env (gitignored;
copy .env.example to start) plus a PEM private key file referenced from it. They are
skipped automatically — not failed — when those aren't present, so composer test stays
green without sandbox access. See .env.example for the exact variables and
tests/Integration/SandboxConfig.php for how they're loaded.
Sandbox integration tests are deliberately bounded — small limits for single-page checks,
and a WHERE ROWNUM <= n clause where a test needs to exercise real multi-page pagination —
since a real sandbox account can hold a large amount of real data. Tests that create records
(RecordServiceSandboxTest) delete them again before the test ends, successful or not, so
they never leave test data behind in the connected sandbox.
Development
The project runs as its own Composer project in Docker; the repository is mounted into the
PHP container (see compose.yaml / .docker/php/Dockerfile, which installs and enables both
ext-gmp and ext-bcmath). To exercise the bcmath path, point PHP_INI_SCAN_DIR at a
directory containing only the extension .ini files you want loaded.
docker compose up -d php
docker compose exec php composer install
Any CI configuration for this project should provision ext-gmp (or at least ext-bcmath)
in its runtime image/environment; without either, the authentication tests fail with the
missing-extension AuthenticationException.
Roadmap
Nothing specific is planned beyond what's documented above. Further capabilities (e.g.
RESTlets, metadata) get added to NetSuite only once a real underlying service exists for
them, the same way SuiteQLService/RecordService did — never speculatively.
License
Apache-2.0 — see LICENSE and NOTICE.
To report a security issue, see SECURITY.md.