WP Webhooks / Blog / Integrations

Sending WooCommerce Orders to NetSuite with OAuth 2.0 and Upserts

WooCommerce to NetSuite over REST: OAuth 2.0 client credentials with an ES256 JWT in PHP, upsert by external ID, and the account concurrency limit.

9 min 2026-10-31
#netsuite#woocommerce#oauth

TL;DR: NetSuite's REST API can take WooCommerce orders directly from WordPress. You need two things most tutorials skip: an access token minted from a signed JWT, and an upsert by external ID so a retry can never create a second sales order.

  • Use OAuth 2.0 client credentials. From release 2027.1 no new integrations can use Token-Based Authentication, and support for existing ones is set to end around 2028.2.
  • The client authenticates with a JWT signed by a certificate key: PS256/384/512 or ES256/384/512. RS256 is not on the list. Certificates last at most two years.
  • Access tokens last 60 minutes, and there is no refresh token. When one expires, you sign a new JWT.
  • PUT /record/v1/salesOrder/eid:WC-1234 creates the order the first time and updates it on every later attempt.
  • Concurrency is per account: 5 requests at a time on the Standard tier, plus 10 per SuiteCloud Plus licence. Going over returns a 429.

/ Ready-made

Does NetSuite have an official WooCommerce connector?

Yes. Oracle's NetSuite Connector, which came from the FarApp acquisition, lists WooCommerce among its supported storefronts, alongside Shopify, BigCommerce, Magento 2 and Amazon. It syncs orders, items, inventory and fulfilments, and an Oracle partner usually sets it up. Pricing is not public.

For a business that already runs on NetSuite, that is usually the right answer. Two-way inventory and fulfilment sync is most of the value, and it is the hard part.

A direct API integration makes sense when the scope is narrow and the rules are yours: only B2B orders, only one subsidiary, orders that need a custom field filled from checkout data, or a store that has to send the same order to NetSuite and to a CRM, with one log covering both. It is also how you test the API before committing to a connector project.

/ Auth

Which NetSuite authentication should a new integration use?

OAuth 2.0, using the client credentials (machine-to-machine) flow. NetSuite's authentication overview says "You should use OAuth 2.0 for all new integrations", and adds a warning: "As of 2027.1, no new integrations using TBA can be created for SOAP web services, REST web services, and RESTlets." Most WooCommerce-to-NetSuite tutorials still sign every request with OAuth 1.0 Token-Based Authentication (TBA). TBA keeps working for existing integrations, but its end of support is planned for about release 2028.2.

The client credentials flow has no browser consent screen and no refresh token. The setup has three parts:

  • An integration record with the client credentials grant enabled. Its client ID is the JWT's issuer.
  • A certificate: RSA 3072 or 4096, or EC P-256, P-384 or P-521. You upload its public part under Setup → Integration → Manage Authentication → OAuth 2.0 Client Credentials (M2M) Setup, mapped to an entity and a role. The maximum validity is two years, and NetSuite shortens anything longer.
  • A role for the integration, with permission to create customers and sales orders and nothing more.

To get a token, you POST to https://<account>.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token with grant_type=client_credentials and a JWT signed by the certificate's private key as client_assertion. The token response is a bearer token that "is valid for 60 minutes". When it expires, you sign a new JWT and ask again.

FIG 01 — WordPress signs its own JWT, caches the hour-long token, and upserts by external ID so a retry lands on the same order

/ Signing

How do you sign the NetSuite JWT in PHP?

With ES256 and PHP's OpenSSL extension, plus one conversion. The allowed algorithms in the request token structure are PS256, PS384, PS512, ES256, ES384 and ES512. PHP's openssl_sign() has no option for RSA-PSS padding, so the PS algorithms need a library. ES256 works with OpenSSL alone, but openssl_sign() returns the signature in DER, while a JWT needs the raw 64-byte r‖s form. Send the DER version and NetSuite rejects a JWT that looks perfectly valid.

PHP — ES256 client assertion for the NetSuite token endpoint

function ns_der_to_jose( string $der ): string {
    $pos = 2;                                            // skip SEQUENCE tag + length
    if ( ord( $der[1] ) & 0x80 ) { $pos += ord( $der[1] ) & 0x7f; }
    $out = '';
    for ( $i = 0; $i < 2; $i++ ) {                       // r, then s
        $len  = ord( $der[ $pos + 1 ] );
        $out .= str_pad( ltrim( substr( $der, $pos + 2, $len ), "\x00" ), 32, "\x00", STR_PAD_LEFT );
        $pos += 2 + $len;
    }
    return $out;                                         // 64 bytes for P-256
}

function ns_b64url( string $s ): string {
    return rtrim( strtr( base64_encode( $s ), '+/', '-_' ), '=' );
}

function ns_client_assertion(): string {
    $now    = time();
    $header = [ 'alg' => 'ES256', 'typ' => 'JWT', 'kid' => NS_CERT_ID ];
    $claims = [
        'iss'   => NS_CLIENT_ID,
        'scope' => 'rest_webservices',
        'aud'   => 'https://' . NS_ACCOUNT . '.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token',
        'iat'   => $now,
        'exp'   => $now + 1800,                           // must be under 60 min after iat
    ];
    $input = ns_b64url( json_encode( $header ) ) . '.' . ns_b64url( json_encode( $claims ) );
    openssl_sign( $input, $der, openssl_pkey_get_private( file_get_contents( NS_KEY_FILE ) ), OPENSSL_ALGO_SHA256 );
    return $input . '.' . ns_b64url( ns_der_to_jose( $der ) );
}

The constants live in wp-config.php, and NS_KEY_FILE points to a key file outside the web root. We tested the conversion by verifying the PHP output with Node's crypto.verify in ieee-p1363 mode, which is the raw JWT format.

Cache the access token in a transient for 55 minutes. A token is good for 60, and the margin covers clock drift and a request that starts just before expiry. On hosting with several web servers, the transient has to live in a shared object cache. Otherwise each server mints its own token, which works but wastes calls.

PHP — exchange the assertion for a token, cached for 55 minutes

function ns_access_token(): ?string {
    $cached = get_transient( 'ns_access_token' );
    if ( $cached ) {
        return $cached;
    }
    $res = wp_remote_post( 'https://' . NS_ACCOUNT . '.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token', [
        'timeout' => 15,
        'body'    => [
            'grant_type'            => 'client_credentials',
            'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
            'client_assertion'      => ns_client_assertion(),
        ],
    ] );
    $body = json_decode( wp_remote_retrieve_body( $res ), true );
    if ( empty( $body['access_token'] ) ) {
        return null;                                     // no token: NetSuite answers 401 and the failure shows in the log
    }
    set_transient( 'ns_access_token', $body['access_token'], 55 * MINUTE_IN_SECONDS );
    return $body['access_token'];
}

Whatever sends the order then adds Authorization: Bearer plus that token on every attempt, retries included, so a retry an hour later picks up a fresh token instead of replaying an expired one.

/ Upsert

How do you stop a retry from creating a second NetSuite sales order?

Write by external ID. The REST upsert operation "enables you to either create a record, or update an existing record", and it is only available with PUT and an external ID in the URL, written as eid: followed by the ID. The first attempt creates the order. A retry after a timeout updates that same order instead of adding another one.

JSON — PUT /services/rest/record/v1/salesOrder/eid:WC-1234

{
  "entity": { "id": "1874" },
  "otherRefNum": "1234",
  "memo": "WooCommerce order 1234",
  "item": {
    "items": [
      { "item": { "id": "512" }, "quantity": 2, "rate": 25.00 },
      { "item": { "id": "77" },  "quantity": 1, "rate": 4.90 }
    ]
  }
}

The customer works the same way: PUT /record/v1/customer/eid:WC-C-<hash of the email> before the order. A successful write answers 204 with no body, and the new record's URL is in the Location header. That URL ends in the internal ID that the order's entity needs. OneWorld accounts also need a subsidiary on the customer.

Sublist field names such as quantity and rate depend on the record's configuration in your account. Read them from /services/rest/record/v1/metadata-catalog/salesOrder instead of copying them from any example, this one included.

POST creates a record every time it runs. PUT by external ID creates it once and lands on the same record after that. A retry is only safe with the second one. — the one design choice that matters most
ConcernNetSuite ConnectorWebhook Actions + token filter
Inventory and fulfilment syncBuilt in, both directionsNot built. Orders flow one way into NetSuite
SetupUsually a partner project; pricing not publicAn integration record, a certificate, a role, and the PHP above in an fswa_headers filter
Which orders go to NetSuiteIts flow settingsAny condition on the order payload
Access tokenHandled by the connectorMinted from your JWT and cached for 55 minutes; the delivery log stores Authorization as [redacted]
NetSuite busy (429) or downSee its documentationRetried with backoff; the eid: upsert makes the repeat land on the same order
One order to several systemsNetSuite onlyThe same trigger feeds other webhooks, each with its own log
try_it

Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards.

/ Limits

What are the NetSuite API concurrency limits?

NetSuite limits how many requests run at the same time, not how many run per minute, and the limit covers the whole account: "The account governance limit applies to the combined total of web services and RESTlet requests." From the governance table:

  • Standard: 5, Premium: 15, Enterprise and Ultimate: 20.
  • +10 for each SuiteCloud Plus licence. An Ultimate account with 5 licences gets 20 + 5 × 10 = 70.
  • Development and partner accounts: always 5.

The "combined total" is the trap. Your store's two calls per order share those 5 slots with every other integration on the account: the payroll sync, the warehouse system, the BI tool's nightly extract. A request over the limit gets a 429 with the error code CONCURRENCY_LIMIT_EXCEEDED, and the error reference says to retry it. Run the store's deliveries one at a time, so a burst of orders never takes all 5 slots from everyone else.

Requests that run longer than 15 minutes time out. A two-line sales order never comes close, but a timeout on the sender's side is much shorter than that, which is exactly the retry the upsert protects against.

/ Exposure

What does NetSuite not protect you from?

A certificate that expires. Two years at most, and when it expires every token request fails. NetSuite allows up to five active certificates per integration, entity and role, so you can upload the new one before the old one expires and switch NS_CERT_ID without any downtime. Put the date in a calendar.

A role that can do too much. Every call runs with the role mapped to the certificate. An administrator role on an integration means a leaked private key can change anything in the ERP.

Prices that NetSuite recalculates. Leave rate out and NetSuite applies its own price level, which may not be what the customer paid. Send the WooCommerce price, then compare one order per tax code against the WooCommerce order screen.

A failure only the log shows. When NetSuite answers 429 or 5xx, a retried delivery tries again with backoff, and the order stays in the log until it lands. The retry policy article covers the schedule. The Zoho CRM article uses the same cached-token pattern for another OAuth API, and the Xero article shows the alternative, where an automation tool holds the token.

/Footnotes
¹ Client credentials flow, JWT parameters, certificate rules, the TBA warning and the upsert operation from the NetSuite Help Center, read 2026-11-05.
² The DER-to-raw conversion was checked by signing in PHP 8.4 and verifying the result with Node.js crypto.verify using dsaEncoding: 'ieee-p1363'.
FAQ

Things engineers always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

Is there an official NetSuite connector for WooCommerce? +
Yes. Oracle’s NetSuite Connector, which came from the FarApp acquisition, lists WooCommerce among its supported storefronts. It covers orders, items, inventory and fulfilments, and is usually set up by a partner. A direct REST integration suits narrower, rule-specific jobs.
Should a new NetSuite integration use TBA or OAuth 2.0? +
OAuth 2.0. NetSuite recommends it for all new integrations, and as of release 2027.1 no new integrations using Token-Based Authentication can be created for SOAP, REST web services and RESTlets. Existing TBA integrations keep working until support ends, tentatively in 2028.2.
How long does a NetSuite OAuth 2.0 access token last? +
Sixty minutes, with expires_in always 3600. The client credentials flow has no refresh token: when the token expires you sign a new JWT with the certificate key and request another. The JWT must use PS256, PS384, PS512, ES256, ES384 or ES512.
How do I avoid duplicate NetSuite sales orders on retry? +
Use the REST upsert: PUT /services/rest/record/v1/salesOrder/eid:<external id>. It creates the record if no record has that external ID and updates it otherwise, so a retried delivery lands on the same sales order. Upsert is only available with PUT and an external ID in the URL.
What is the NetSuite API concurrency limit? +
It is per account and shared by all web services and RESTlet integrations: 5 concurrent requests on Standard, 15 on Premium, 20 on Enterprise and Ultimate, plus 10 for each SuiteCloud Plus licence. Requests over the limit get HTTP 429 with CONCURRENCY_LIMIT_EXCEEDED.
Ready

Your next automation is
one sentence away.

$ wp plugin install flowsystems-webhook-actions --activate