Outbound webhooks
Subscribe an endpoint to signed order events, verify them, and handle retries and duplicates.
OrderLoop POSTs signed order events to the endpoints you subscribe. The events are
order.paid, order.invoiced, order.shipped, order.refunded, order.completed, order.declined, order.cancelled, order.failed.
Subscribing
Section titled “Subscribing”Subscriptions are managed through the REST API with the admin password; there is no UI for them.
curl -X POST https://orderloop.io/api/v1/webhook-subscriptions \ -H "Authorization: Bearer $ORDERLOOP_TOKEN" -H 'content-type: application/json' \ -d '{ "url": "https://erp.example.com/hooks/orderloop", "events": ["order.shipped", "order.paid"] }'urlmust behttps; plainhttpis accepted only forlocalhost,127.0.0.1and[::1].eventsis a list of event names, or["*"]for every event (the default).
The 201 response is { "subscription": { … }, "secret": "whsec_…" }. The secret is returned
once, here; listing subscriptions never shows it. Store it with the receiver.
| Route | Does |
|---|---|
GET /api/v1/webhook-subscriptions |
List subscriptions (no secrets) |
POST /api/v1/webhook-subscriptions |
Create one; returns the secret |
DELETE /api/v1/webhook-subscriptions/:id |
Delete one |
GET /api/v1/webhook-subscriptions/:id/deliveries |
The 50 most recent deliveries, newest first |
When events are sent
Section titled “When events are sent”Some nodes send an event when they finish:
| Event | Sent when |
|---|---|
order.paid | Capture payment (commerce.capture_payment) finishes on its ok branch |
order.paid | Charge (commerce.charge) finishes on its ok branch |
order.invoiced | Generate invoice (commerce.invoice) finishes |
order.shipped | Create shipment (commerce.ship) finishes |
order.refunded | Refund (commerce.refund) finishes on its ok branch |
The ending events cover every way an order ends:
| Event | Sent by |
|---|---|
order.completed, order.declined, order.cancelled, order.failed |
An End node with that outcome |
order.failed |
A node that throws after its retries, so the order fails without reaching an End node |
order.cancelled |
A cancel from the order page, the order chat or POST /api/v1/orders/:id/cancel, sent by the order’s agent because the Workflow is terminated |
The request
Section titled “The request”POST /hooks/orderloop HTTP/1.1content-type: application/jsonx-orderloop-signature: t=1760340000,v1=5f2c…x-orderloop-event: order.shippedx-orderloop-delivery: whd_ord_…_ship_1_whs_…
{ "id": "whd_ord_…_ship_1_whs_…", "event": "order.shipped", "occurredAt": 1760340000123, "orderId": "ord_…", "data": { … }}data depends on what sent the event:
- A node (
order.paid,order.invoiced,order.shipped,order.refunded):{ order, node: { id, type, output } }, whereoutputis the node’s output. - An End node:
{ order, outcome, reason }. - A node that threw:
{ order, outcome: "failed", reason, node: { id, type, error } }. - A cancel:
{ order, outcome: "cancelled", reason }.
order is a summary: id, type, title, customerId, currency, subtotal, tax, total
(all in currency), fxRate (units of currency per USD) and baseTotal (the total in USD).
Verifying the signature
Section titled “Verifying the signature”Each subscription signs with its own secret, in the same scheme as
signed inbound events:
t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>. Verify against the raw body,
before parsing it. In TypeScript, verifyWebhook from @orderloop/core does it and rejects
timestamps more than 5 minutes off:
import { SIGNATURE_HEADER, verifyWebhook } from '@orderloop/core';
const raw = await request.text();const check = await verifyWebhook(secret, raw, request.headers.get(SIGNATURE_HEADER));if (!check.ok) return new Response(check.reason, { status: 401 });Elsewhere, compute the HMAC of `${t}.${raw}` with the secret, compare it with v1 in
constant time, and check that t is recent.
Retries and duplicates
Section titled “Retries and duplicates”Any 2xx response counts as delivered; anything else, or no response within 10 seconds, is a
failure.
- From the Workflow (node and End events, failures): the delivery runs in its own step,
${nodeId}#${run}:emit, retried 5 times with exponential backoff from 10 seconds. - From the agent (
order.cancelledon cancel): retried 5 times on the agent’s schedule, 10 seconds doubling each time.
Each attempt only resends deliveries that haven’t succeeded. When retries run out the delivery
stays failed in the deliveries list, and the order carries on: a subscriber being down never
fails an order. Backoff isn’t affected by TIME_SCALE.
The delivery id (X-OrderLoop-Delivery, and id in the body) is derived from the order, the
step that sent it and the subscription, and stays the same across retries. Use it to dedupe:
a receiver can see the same delivery more than once.
Each delivery row records status (pending, delivered or failed), attempts,
responseStatus, lastError and deliveredAt.