Skip to content
Last updated

Webhooks

Orderwerks can notify your systems when something happens in your account: an order is submitted, a shipment is created, a catalog item changes, an order enters a workflow step you care about. Instead of polling the REST API, register an HTTPS endpoint and Orderwerks posts a signed JSON envelope to it.

A business admin switches webhooks on under Business Integrations in the web app. Endpoints are then managed under Business Settings > Webhooks, or through the Webhooks endpoints of the REST API with an API key.

Registering an endpoint

An endpoint has a name, an HTTPS URL, and what it subscribes to:

  • Events from the catalog below (for example order.submitted, shipment.created).
  • Workflow triggers: a workflow step (optionally restricted to one workflow). When an order enters that step the endpoint receives workflow.fired. The same event can also be sent from a workflow step's own "send webhook" action in the workflow editor.

Optionally add up to ten static headers, such as an Authorization header your receiver expects. Header values are stored encrypted and never shown again.

The URL must use HTTPS, must not contain credentials, and must resolve to a public address. Redirects are not followed. A business may register up to ten endpoints.

When you create an endpoint you receive its signing secret once. Store it. If it is lost, rotate it.

The delivery

Every delivery is an HTTP POST with Content-Type: application/json and these headers:

HeaderValue
X-Orderwerks-EventThe event type, e.g. order.submitted
X-Orderwerks-DeliveryThe delivery id (also id in the body)
X-Orderwerks-Api-VersionThe payload version pinned on the endpoint
X-Orderwerks-Signaturet=<unix seconds>,v1=<hex HMAC> (see below)
User-AgentOrderwerks-Webhooks/1.0 (+https://docs.orderwerks.com)

The body is an envelope:

{
  "id": "8c0b1d3e-5f6a-4b7c-9d8e-0f1a2b3c4d5e",
  "type": "order.submitted",
  "api_version": "2026-10-27",
  "occurred_at": "2026-10-27T18:04:11.000Z",
  "supplier_guid": "3a1f...",
  "data": {
    "guid": "9b2e...",
    "number": "ABC-10231",
    "state": { "id": 2, "name": "SUBMITTED" },
    "grand_total": 1240.5,
    "customer": { "guid": "...", "name": "Acme, Inc.", "email": "orders@acme.example", "customer_internal_id": "ACME01" },
    "ship_to": { "...": "..." },
    "items": [ { "guid": "...", "sku": "WIDGET-1", "qty": 12, "unit_price": 100, "total": 1200, "...": "..." } ],
    "items_truncated": false
  }
}

data is shaped per event type; the exact fields are in the webhook definitions of the reference. Payloads are a curated view of the record: internal notes, costs, commissions and integration identifiers are never included. When you need the full record, fetch it through the REST API using the guid in data.

Respond with any 2xx status within 10 seconds to acknowledge the delivery. Do the real work after you have responded. Response bodies are stored (first 4 KB) for troubleshooting but otherwise ignored.

Verifying the signature

Every delivery is signed with HMAC-SHA256 over the string <timestamp>.<raw request body> using your endpoint's signing secret. The X-Orderwerks-Signature header carries the timestamp and one or more signatures:

X-Orderwerks-Signature: t=1761588251,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e4...

To verify:

  1. Split the header on ,. Read t and every v1 value.
  2. Compute HMAC_SHA256(secret, t + "." + rawBody) with the raw bytes of the request body, not a re-serialised object.
  3. Compare it against each v1 using a constant-time comparison. Accept if any matches.
  4. Reject if t is more than a few minutes from the current time, to defeat replays.
const crypto = require('crypto');

function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')));
  const timestamp = Number(parts.t);
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

  const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  return header
    .split(',')
    .filter((p) => p.trim().startsWith('v1='))
    .some((p) => {
      const sig = Buffer.from(p.trim().slice(3), 'hex');
      return sig.length === expected.length / 2 && crypto.timingSafeEqual(sig, Buffer.from(expected, 'hex'));
    });
}

After you rotate the secret, deliveries carry two v1 values for 24 hours, one per secret, so a receiver that still holds the old secret keeps working while you switch.

Static headers are a convenience for your receiver; they are not a substitute for verifying the signature.

Retries and failures

A delivery that gets no 2xx is retried with increasing delays, about eight attempts over a day (30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h, 12 h). After that it is marked failed and can be redelivered manually from Business Settings or POST /v1/webhook-deliveries/{deliveryGuid}/redeliver.

Failures are also counted per endpoint. Any 2xx resets the count; after 50 consecutive failed attempts the endpoint is disabled, its queued deliveries are cancelled, and the business admins are emailed. A 410 Gone response disables the endpoint immediately. Fix the receiver, then re-enable the endpoint and redeliver what you missed.

Idempotency and ordering

Retries and redeliveries carry the same delivery id (id in the envelope, X-Orderwerks-Delivery header). Store the ids you have processed and ignore repeats.

Deliveries are not ordered. Two events on the same order can arrive out of sequence, especially when one was retried. Use occurred_at and the resource's updated_at when the order of events matters, or fetch the current record from the REST API.

Some events are delivered on purpose a little after they happen: order.created and quote.created wait about 30 seconds so that line items added right after the order row are included, and *.updated events fold saves within a few seconds of each other into one delivery.

Events

EventWhendata
order.createdAn order row was created (web app, API, integrations)Order
order.submittedAn order left PRELIMINARYOrder
order.state_changedAn order moved to another workflow state; data.previous_state names the one it left and data.state the one it enteredOrder
order.deletedAn order was deletedDeleted record (guid, order number)
quote.createdA quote was createdOrder
quote.sentA quote was emailed to the customerOrder
quote.converted_to_orderA quote became an order; data.quote names the quoteOrder
shipment.createdA shipment was createdShipment
shipment.voidedA shipment was voidedShipment
shipment.deletedA shipment was deletedDeleted record
catalog_item.created / updated / deletedA catalog item changedCatalog item / Deleted record (guid, SKU)
vendor.created / updated / deletedA customer changedCustomer / Deleted record
vendor_source.created / updated / deletedA vendor source changedVendor source / Deleted record
workflow.firedAn order entered a step named by the endpoint's workflow triggers or by a step's "send webhook" action; data.workflow names the stepOrder
testSent by the "send test" action; the envelope carries "test": trueSample order

order.state_changed is the superset of the order lifecycle. If you subscribe to it and to a named status event such as order.submitted, you receive two deliveries for that transition.

The transition itself (data.previous_state and data.state) is captured when the change happens. The rest of the order is read when the delivery is sent, so an order that moved again in between still reports the original transition alongside its latest totals, items and dates.

Changes made with bulk database operations by some imports and integrations do not produce *.updated events. The REST API's delta sync (lastUpdatedAt) remains the way to catch everything.

Testing

Send test on an endpoint queues a real delivery of type test with a sample order and "test": true, attempts it immediately and shows you the response. The Deliveries log on each endpoint shows every delivery with its status, attempts, the endpoint's last response and the envelope that was sent.