Order and webhook reliability¶
order-and-webhook-reliability
Accept order and payment webhooks with a raw-body signature check, durable deduplication, and reconciliation against the source of truth. Use when handling order events, payment notifications, or outbound webhook delivery.
Version 0.3.2 · MIT · Source · Rule
Install this skill
Copy the skill and its rule into your project:
The skill goes to .agents/skills/ and the rule to .agents/rules/. See Command line for other directories.
Use this skill for inbound payment or order notifications and for outbound webhooks your shop sends. Provider-specific state mapping stays in the payment skill. WooCommerce header names and the failure limit are in the WooCommerce webhook notes.
Verify a WooCommerce signature locally with scripts/verify-hmac-signature.mjs.
Procedure¶
- Read the raw body before any JSON parser reorders or re-encodes it.
- Verify the signature with a constant-time compare. Reject unsigned or invalid requests before you look at the event type.
- Store the event in a durable inbox, unique on the provider event id when that id is stable across retries.
- Acknowledge with 2xx only after that store succeeds. Do the order update after the store, in the same transaction when you can, or in a worker that can retry.
- Apply the change only if this event is newer than what you stored, or if the fetched resource still matches the event.
- When the event is incomplete or out of order, fetch the order or payment from the source of truth and write that.
Inbox¶
A practical unique key:
- Provider event id, when the provider reuses it for retries of the same event.
- Otherwise a hash of topic, resource id, and the resource's
date_modified_gmtor equivalent version.
Do not dedupe only on a delivery attempt id. Those change when the sender times out and posts again.
Store: event id, topic, resource id, raw body, signature verification time, and processing status (stored, applied, ignored). Ignore a duplicate by returning 2xx and the original result. A second apply is a bug.
The most common duplicate is a redelivery after a timeout. The handler stored and applied the event, but the sender timed out before the 2xx arrived and posts the same event again, often with a new delivery id. The retry must find the inbox row already applied, return 2xx, and run no side effect: no second fulfillment, no second confirmation email, no second stock decrement. This only holds when each side effect is recorded in the same transaction that marks the row applied, or is keyed on the event id where it runs.
Apply rules¶
- Compare amounts in integer minor units before marking an order paid.
- Do not move an order backward because a late
pendingevent arrived afterpaid, unless the source fetch shows the payment was reversed. - Record the provider payment id on the first paid transition. The paid transition itself is idempotent.
- Outbound side effects (email, stock, fulfillment) run inside that same idempotent transition, not in the HTTP handler after a commit you might repeat.
Outbound webhooks¶
When your shop sends webhooks:
- Sign the exact bytes you send.
- Include your event id and the resource version.
- Retry with backoff on connection failures and 5xx. Stop on a 4xx that means the subscription is invalid.
- Use an outbox row written in the same transaction as the order change. A webhook built only in memory is lost when the process dies after the order commit.
- Receivers will see duplicates and gaps. Document the event id so they can dedupe.
Failures¶
- Invalid signature: 401 or 403, and do not store the body as a trusted event. You may log the rejection id.
- Unknown resource: 200 after you record the event as ignored, or 404 if the provider treats 404 as "do not retry" and you are sure the resource will never exist. Prefer recording it and fetching. A retry storm from a 500 is worse than an ignored row you can inspect.
- Database down: 503 so the sender retries. The unique key makes the retry safe.
- Handler bug after a durable store: leave the row unapplied and retry the worker. Do not ask the sender to redeliver once you have the bytes, unless you failed to store them.
WooCommerce receivers¶
WooCommerce signs base64(hmac_sha256(secret, raw_body)). The secret is passed through wp_specialchars_decode( $secret, ENT_QUOTES ) before the MAC. decodeWebhookSecret() in the checker matches that decode.
WEBHOOK_SECRET="..." node scripts/verify-hmac-signature.mjs \
--body payload.json \
--signature "$SIGNATURE"
You can pass --secret-file instead of the environment variable. The checker does not print the secret.
X-WC-Webhook-Delivery-ID is a new log id per attempt. Dedupe on the resource id plus date_modified_gmt and the topic, then fetch /wp-json/wc/v3/orders/<id> if the payload is not enough.
The activation ping is not an order event. Return 2xx and do not create an order.
WooCommerce counts a delivery as failed when the status is outside 2xx, 301, and 302. After more than five consecutive failures it disables the webhook. The threshold filter is woocommerce_max_webhook_delivery_failures (default 5). WC_Webhook::deliver() posts with redirects disabled, so a 301 does not mean the JSON was delivered to the next URL. Answer on the registered URL.
There is no separate backoff retry of one payload described here. Timeouts and repeated order saves still deliver more than once. Handle duplicates anyway.
Gotchas¶
- Parsing JSON and signing
JSON.stringify(object)will not match. The sender signed the original bytes, including key order and spacing. - Returning 200 before the inbox write loses the event if the process dies.
- A slow handler that applies the event and sends the email before answering invites a timeout, and the redelivery sends the email again unless the email is tied to the
appliedrow. - Applying events in arrival order without a version can reopen a canceled order.
- Logging the raw body of a payment webhook can capture data you did not mean to keep. Store the body in the inbox with restricted access, and keep card data out of application logs.
- A shared webhook secret across shops makes a signature from shop A valid on shop B. Use one secret per subscription.
Verification¶
- The checker accepts a known body and secret and rejects a one-byte change.
- A duplicated delivery creates one inbox row and one state change.
- A redelivery after a sender timeout, where the first attempt was stored and applied but the 2xx never arrived, returns 2xx and repeats no side effect: one fulfillment, one email, one stock decrement.
- An older event received after a newer one does not downgrade a paid order.
- A wrong amount does not mark the order paid.
- Stopping the database makes the handler return a retryable status and, after recovery, the redelivered event still applies once.
Evaluation¶
Worked examples and the checks they must satisfy are in evals/tasks.json.
References¶
WooCommerce webhook delivery¶
Source of this note: WooCommerce webhook documentation and WC_Webhook::deliver(). Confirm header names against the shop's WooCommerce version when you debug a live miss.
Request¶
- Method:
POST - Content type:
application/json - Body: the REST representation of the resource, or a ping payload when the webhook is first activated
- User-Agent contains
WooCommerceandHookshot. Do not authenticate with the User-Agent.
Headers:
| Header | Role |
|---|---|
X-WC-Webhook-Source |
Store home URL. |
X-WC-Webhook-Topic |
For example order.updated. |
X-WC-Webhook-Resource |
For example order. |
X-WC-Webhook-Event |
For example updated. |
X-WC-Webhook-Signature |
Base64 HMAC-SHA256 of the raw body. |
X-WC-Webhook-ID |
Webhook id. |
X-WC-Webhook-Delivery-ID |
This attempt's log id. Not stable across attempts. |
The signature is computed after JSON encoding. The hash algorithm filter is woocommerce_webhook_hash_algorithm and defaults to sha256. If a shop changes that filter, a receiver that always uses SHA-256 will reject valid deliveries. Prefer leaving the filter at the default.
The secret is the webhook's configured secret. If the secret was left empty, WooCommerce uses the current API user's consumer secret. Set an explicit secret when you register the webhook.
Signature check¶
import { verifyWooCommerceSignature } from "../scripts/verify-hmac-signature.mjs";
const result = verifyWooCommerceSignature({
secret,
rawBody,
signature: headerValue,
});
rawBody is a Buffer or string of the exact POST body. decodeWebhookSecret() decodes ", ', ', <, >, and & to match wp_specialchars_decode( $secret, ENT_QUOTES ).
Success and automatic disable¶
WooCommerce treats 2xx, 301, and 302 as success. Other statuses add to the consecutive failure count. The webhook is disabled after more than five consecutive failures. Filter: woocommerce_max_webhook_delivery_failures, default 5.
Disabled webhooks stay disabled until someone sets them back to active. A receiver outage that returns 500 long enough will stop delivery until the merchant notices. Return 2xx once the event is durable, and do slow work afterward.
deliver() sets HTTP redirection to 0. Do not put a redirect on the delivery URL and expect the body to be posted to the target.
Ping¶
Saving an active webhook sends a ping. Treat a body that is only a webhook probe as a health check. Acknowledge it. Do not require an order id.
Topics¶
Resource topics include order.created, order.updated, order.deleted, product.created, product.updated, product.deleted, customer.created, customer.updated, and the coupon equivalents. order.updated fires for many status and meta changes. Your handler has to tolerate repeats with a newer date_modified_gmt.
Deleted resources may arrive after your local copy is already gone. Record the delete and return 2xx.
Evaluation¶
Fixtures from evals/tasks.json. See evaluation fixtures for how the checks work.
verify-raw-body¶
Verify a WooCommerce webhook signature against the exact posted bytes before parsing the JSON.
Must include: raw body, HMAC-SHA256
Must not include: JSON.stringify
Worked example
Sign the comparison with the original raw body.
Compute Base64 HMAC-SHA256 over those bytes with the webhook secret. Parse JSON only after the signature matches, and compare with a constant-time check.
dedupe-resource-version¶
Dedupe an order.updated webhook when WooCommerce delivers it more than once.
Must include: date_modified_gmt
Must not include: X-WC-Webhook-Delivery-ID
Worked example
Store the topic, the order id, and date_modified_gmt as the inbox key.
A repeat with the same version is acknowledged and not applied again. Fetch the order when the payload is older than the row you already stored.
redelivery-after-timeout¶
The order.paid handler stored and applied the event and created the fulfillment, but WooCommerce timed out before the 2xx arrived and posted the same event again. Make the retry safe.
Must include: applied, 2xx, side effect, timeout
Must not include: X-WC-Webhook-Delivery-ID
Worked example
Treat the retry after a timeout as a duplicate of an event you already applied.
Look up the inbox row by topic, order id, and date_modified_gmt. When its status is already applied, return 2xx with the original result and run no side effect: no second fulfillment, no second confirmation email, and no second stock decrement. Record the fulfillment, the email, and the stock change in the same transaction that marks the row applied, so the first attempt either did all of them once or none of them.