5 Failures Silently Disable Your WooCommerce-NetSuite Webhook: The Reconciliation Job That Catches It
- WooCommerce’s webhook delivery code makes exactly one attempt per event. A failed delivery is never automatically retried — it only increments a counter, and 5 consecutive non-2xx responses silently disable the webhook until someone edits it back to active.
- NetSuite’s
X-NetSuite-idempotency-keyheader only protects requests sent withPrefer: respond-async. The default, synchronous POST that most lightweight integrations use ignores the header, with no error to say so. - NetSuite’s REST error responses carry a machine-readable
o:errorCodefield (INVALID_LOGIN,INVALID_CONTENT,NONEXISTENT_ID,CONCURRENCY_LIMIT_EXCEEDED) — retry logic should switch on that field, not guess from the HTTP status alone. - A reconciliation job that cross-checks WooCommerce order IDs against NetSuite’s
externalIdcolumn is the only layer that catches a webhook NetSuite never even knew fired. Size it against the 10-unit SuiteQL query cost and the 10,000-unit scheduled-script budget, not against guesswork.
WooCommerce’s webhook system gives an integration exactly one delivery attempt per event, then counts failures toward a 5-strike auto-disable — verified directly in the delivery code, not in a support article. NetSuite’s own idempotency-key header protects only the asynchronous request path most lightweight integrations never use. Neither gap is documented as a warning; both fail silently, and the first sign is usually a customer asking where an order went. This post specifies the three independent layers — retry, dedup, reconciliation — that catch what the other two miss, with the exact NetSuite fields, HTTP codes, and governance costs to build them.
Sync error handling needs three independent layers, not one
“Add retries and reconciliation” gets repeated across integration guides as a single piece of advice. It is three separate mechanisms, each catching a failure class the other two cannot see:
Retry catches a failure the sending side actually observed — a timeout, a 5xx, a dropped connection — at the moment it happens. It does nothing for a failure nobody observed.
Dedup (an idempotency key or an externalId check) catches the case a retry creates: the original request succeeded on the server, the client only thinks it failed, and a naive retry would create a second record. It does nothing if no retry was ever attempted.
Reconciliation catches everything neither side reported: a webhook that never fired because the receiving endpoint was down for six orders, not just five; a request that timed out on both ends with no log entry anywhere; a record NetSuite rejected during a maintenance window nobody was watching. Reconciliation is the only layer of the three that doesn’t depend on either system correctly reporting its own failure.
When the sync has already gone dark and this framing is too late, the site’s incident response runbook covers stopping the bleeding and replaying the backlog. This post is about the three layers that keep most integrations out of that runbook in the first place.
WooCommerce gives a webhook exactly one attempt, then disables it
WooCommerce’s webhook delivery class (class-wc-webhook.php) makes a single wp_safe_remote_request() call per triggering event and logs the result. There is no requeue, no reschedule, and no second attempt at the same payload anywhere in the delivery path. A failed delivery — anything outside WooCommerce’s own definition of success, “2xx, 301 or 302” — only increments a persistent failure counter; a success resets it to zero.
WooCommerce’s REST API documents three webhook statuses: active (“delivers payload”), paused (“delivery paused by admin”), and disabled (“delivery paused by failure”). The transition from active to disabled is automatic: WooCommerce states that “after 5 consecutive failed deliveries (as defined by a non HTTP 2xx response code), the webhook is disabled and must be edited via the REST API to re-enable.” The threshold is adjustable with the woocommerce_max_webhook_delivery_failures filter, but the default ships at 5 and most stores never change it.
The failure mode this creates: a receiving endpoint goes down for a maintenance window, a deploy, or a certificate expiry. The sixth order after the outage started arrives to a webhook that no longer exists in an active state — WooCommerce is not retrying orders four, five, and six, it has already stopped sending anything at all. No alert fires on the WooCommerce side; the webhook list in wp-admin simply shows one row as “disabled” among however many others exist. Delivery logs (WooCommerce > Status > Logs) capture the request, response, and duration of every attempt that was made, which is useful after the fact but only if someone thinks to look.
This is the reason reconciliation is not optional polish on top of a retry-and-dedup setup — it is the only mechanism that notices when the webhook layer has stopped participating at all. The site’s observability guide covers alerting on the receiving side; this post covers building the check on the sending side WooCommerce itself will never surface. It sits alongside the broader NetSuite-WooCommerce integration guide as the failure-handling layer of that stack.
Retry logic should switch on NetSuite’s error code, not the HTTP status alone
NetSuite’s REST web services return a consistent error body: an HTTP status, and an o:errorDetails array where each entry carries a human-readable detail string and a machine-readable o:errorCode. “HTTP status codes are used to inform you about the success or failure of a request,” in Oracle’s own phrasing — but the status code alone collapses distinct failure classes into the same bucket. A generic “retry every non-2xx” rule retries a bad request forever; a generic “never retry 4xx” rule skips the one 4xx that should be retried.
| Error code | Status | Retry? |
|---|---|---|
INVALID_LOGIN |
401 | No — fix credentials, don’t loop |
INVALID_CONTENT |
400 | No — the payload is malformed; retrying resends the same defect |
NONEXISTENT_ID |
404 | No — the referenced record isn’t there; a retry won’t create it |
CONCURRENCY_LIMIT_EXCEEDED |
429 | Yes — back off and retry; this is the one 4xx worth looping on |
Verdict: build the retry classifier on o:errorCode, not on the status number. 400 covers both a malformed request (never retry) and, as the next section covers, a duplicate idempotency key (which means the original request already succeeded) — two opposite actions behind the identical status code.
For 5xx and 429 responses that are worth retrying, Oracle’s guidance is exponential backoff with staggered timing across clients: “send the first retry attempt after a certain delay. For subsequent attempts, increase the delay between retries,” while avoiding “creating artificial concurrency peaks by synchronizing retry attempts across threads or applications.” That guidance is written in the SOAP-era language NetSuite’s own REST error-handling documentation still links out to — the principle carries over to REST even though the wording predates it. Every request and response — including the ones that failed — lands in Setup > Integration > Manage Integrations > [record] > Execution Log > REST Web Services, which most integration write-ups skip entirely despite it being NetSuite’s own record of exactly what a client sent and what NetSuite sent back.
NetSuite’s idempotency key only guards requests you marked asynchronous
NetSuite’s REST web services support an X-NetSuite-idempotency-key header — a client-supplied UUID that lets a retried request resolve to the original result instead of creating a duplicate. The catch is scope: Oracle’s own documentation introduces the feature under asynchronous execution specifically — “asynchronous request execution also supports an idempotency retry mechanism” — and REST web services execute synchronously by default, only running asynchronously when the client sends Prefer: respond-async. An integration that attaches the idempotency-key header to an ordinary, synchronous POST — the default call shape for a single order create — gets no protection from it and no error telling it so.
The documented request pairs the two headers deliberately:
POST /services/rest/record/v1/salesOrder
X-NetSuite-idempotency-key: cac827ea-3543-4cda-add2-bae634326c27
Prefer: respond-async
{ "entity": { "id": "1204" }, "externalId": "WOO-10482" }
Resubmit the same key on the same async job and NetSuite doesn’t silently succeed or silently fail — it returns a distinct response: HTTP 400, titled “Conflict,” carrying o:errorCode: IDEMPOTENCY_ERROR and a Location header pointing back at the original job. The status is worth flagging on its own: NetSuite uses 400, not the more conventional 409 Conflict, for a response that is really good news — proof the original request already went through. A retry handler that treats every 400 as “malformed request, log and drop” will misclassify the one 400 that means “you already succeeded, stop retrying.”
Because the header only fires on the async path, the fallback that works everywhere — synchronous or async, any record type — is application-level dedup on externalId: write the WooCommerce order ID into NetSuite’s externalId field on create (eid:WOO-10482 is then a valid lookup path), and check for an existing record with that ID before creating a new one. It’s the slower option — a lookup plus a create instead of one atomic call — but it’s the one every retry path can rely on.
Build the reconciliation job that catches what neither system reported
A reconciliation job answers one question on a schedule: for every WooCommerce order in a time window, does a matching NetSuite sales order exist? The WooCommerce side is a single REST call with a modified_after query parameter against the last successful run’s timestamp. The NetSuite side is a SuiteQL query against the transaction table, matching on the same externalId the create step wrote.
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
*/
define(['N/query', 'N/runtime', 'N/log'], (query, runtime, log) => {
const execute = (context) => {
const sql = `SELECT externalId, tranId, lastModifiedDate
FROM transaction
WHERE type = 'SalesOrd'
AND lastModifiedDate >= SYSDATE - 1/24
ORDER BY lastModifiedDate DESC`;
const rows = query.runSuiteQL({ query: sql }).asMappedResults();
for (const row of rows) {
if (runtime.getCurrentScript().getRemainingUsage() < 200) {
log.audit('Reconciliation paused', 'Remaining usage below threshold; rescheduling the rest of the window');
break;
}
// Compare row.externalId against the WooCommerce order IDs fetched via
// GET /wp-json/wc/v3/orders?modified_after=&status=any
// Flag any WooCommerce order with no matching externalId here.
}
};
return { execute };
});
Size the run against NetSuite’s own governance table before scheduling it. Every query.runSuiteQL call costs 10 governance units; a Scheduled Script carries a 10,000-unit budget per execution. That’s headroom for roughly 1,000 SuiteQL calls in a single run before SSS_USAGE_LIMIT_EXCEEDED — far more than one reconciliation pass over an hour of order volume needs, which is why the code above pages by time window rather than pulling the whole table, and why the getRemainingUsage() guard reschedules the remainder instead of dying mid-batch with no checkpoint.
Cross-reference a flagged gap against the REST Web Services Execution Log before assuming the order never reached NetSuite at all — the log shows whether a request arrived and what NetSuite returned, which distinguishes “WooCommerce never sent this” (the 5-strike webhook disable) from “WooCommerce sent it and NetSuite rejected it” (a validation or governance failure with its own fix). A reconciliation job that only reports the gap, without that distinction, turns every finding back into a manual investigation.
Audit an existing integration against all three layers
Run this against a live WooCommerce-NetSuite sync before assuming any of the three layers is already covered — the site’s integration staging checklist covers verifying the same logic in sandbox before any of this reaches production:
- Confirm the receiving endpoint returns a 2xx for every successful webhook — a 200 with an error message in the body still counts as a WooCommerce delivery success and will never trigger the failure counter.
- Check the current failure count and status on every WooCommerce webhook (WooCommerce > Settings > Advanced > Webhooks) — a webhook already sitting at 3 or 4 failures is one bad deploy from silently disabling.
- Verify every NetSuite create call writes
externalId— an integration relying solely on the async idempotency-key header has no protection on any synchronous call it makes. - Confirm retry logic branches on
o:errorCode, not just the HTTP status — a handler that retries every non-2xx will loop forever onINVALID_CONTENT. - Confirm a reconciliation job exists and runs on a schedule shorter than the business is willing to be wrong for — hourly for high-volume stores, daily is a floor, not a target.
- Confirm the reconciliation job checks the REST Web Services Execution Log before reporting a gap as “never sent,” not just as a first guess.
- Confirm the reconciliation job itself has a
getRemainingUsage()guard and pages its query window — an unbounded scan is the same governance-exhaustion failure mode as the sync it’s meant to be checking.
Not sure which of these three layers your integration is missing?
A sync audit checks webhook status, dedup coverage, and reconciliation gaps against your live WooCommerce-NetSuite integration — not a generic checklist.
Get the working checklists
The runbooks and decision checklists from these guides, as printable PDFs — free in the SoftXone guide library.
References
- WooCommerce core: class-wc-webhook.phpSource of the deliver()/failed_delivery() behavior — one attempt per event, no automatic retry.
- WooCommerce developer docs: Working with webhooksThe 5-consecutive-failure auto-disable rule and the delivery-failure filter.
- WooCommerce REST API: WebhooksWebhook status values (active/paused/disabled) and resource fields.
- WooCommerce REST API: OrdersThe modified_after/after/status query parameters used to build the reconciliation poll.
- NetSuite: REST Web Services Request ProcessingSynchronous-by-default execution and the idempotency retry mechanism’s async scope.
- NetSuite: Sending an Asynchronous Request Using an Idempotency KeyHeader format, request example, and the duplicate-key 400/Conflict response.
- NetSuite: Error Handling in REST Web ServicesThe o:errorCode taxonomy and error response shape.
- NetSuite: Using the REST Web Services Execution LogWhere every request/response for an integration record is logged.
- NetSuite: Using External IDsThe externalId field and eid: lookup path used for application-level dedup.
Frequently asked questions
Does WooCommerce retry a failed webhook delivery automatically?
No. WooCommerce’s webhook delivery code (class-wc-webhook.php) makes exactly one delivery attempt per triggering event and never requeues or resends that same payload. A failed delivery only increments a persistent failure counter; after 5 consecutive non-2xx responses the webhook is automatically set to disabled and stops sending anything further until it is manually or programmatically re-enabled.
Will NetSuite’s idempotency-key header protect a normal order-create call from duplicates?
Only if the request is sent asynchronously with a Prefer: respond-async header. NetSuite’s REST web services execute synchronously by default, and the idempotency-key mechanism is documented under asynchronous request execution specifically — on the default synchronous call most single-record creates use, the header is not enforced. Use externalId for dedup on any synchronous write.
What HTTP status does NetSuite return when the same idempotency key is submitted twice?
HTTP 400, titled “Conflict,” with o:errorCode: IDEMPOTENCY_ERROR and a Location header pointing at the original job — not the more conventional 409 Conflict status. A retry handler that treats every 400 as a permanent client error will misread this response as a failure instead of proof the original request already succeeded.
How often should a WooCommerce-NetSuite reconciliation job run?
Size it to how long the business tolerates being wrong, not to a default. Hourly suits high-volume stores; daily is a floor, not a target, given WooCommerce’s webhook layer can go fully silent after 5 consecutive failures with no alert. Each run costs 10 governance units per SuiteQL query against NetSuite’s 10,000-unit Scheduled Script budget, so an hourly cadence has wide headroom for most order volumes.

Leave a Reply