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.ready_for_pickup, order.delivered, order.refunded, order.payout_scheduled, order.completed, order.declined, order.cancelled, order.failed.
Subscribing
Section titled “Subscribing”Subscriptions are managed through the REST API with an operator
API token; there is no UI for them.
curl -X POST https://orderloop.app/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. Deliveries go to public addresses only, never private or loopback ones. (A plainhttpURL forlocalhost,127.0.0.1or[::1]is accepted when subscribing but is never reached.)eventsis a list of the event names above, or["*"]for every event (the default). An unknown name returns400.
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), at most 500 |
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.ready_for_pickup | Ready for pickup (commerce.pickup_ready) finishes |
order.delivered | Hand over (commerce.handover) finishes |
order.delivered | Deliver digital goods (commerce.deliver_digital) finishes |
order.refunded | Refund (commerce.refund) finishes on its ok branch |
order.payout_scheduled | Seller payouts (commerce.payout) finishes |
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 assistant or POST /api/v1/orders/:id/cancel |
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.ready_for_pickup,order.delivered,order.refunded,order.payout_scheduled):{ 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, model, channel, externalId, 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: compute the HMAC of `${t}.${raw}` with the secret, compare it with v1
in constant time, and reject a t more than 5 minutes from now. In Node:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret: string, raw: string, header: string | null): boolean { const parts = Object.fromEntries((header ?? '').split(',').map((p) => p.split('=', 2))); const t = Number(parts.t); if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > 300) return false; const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${raw}`).digest('hex')); const given = Buffer.from(parts.v1); return given.length === expected.length && timingSafeEqual(given, expected);}
const raw = await request.text();if (!verify(secret, raw, request.headers.get('x-orderloop-signature'))) return new Response('Bad signature', { status: 401 });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.
- Node and End events, and failures: retried 5 times with exponential backoff from 10 seconds.
order.cancelledon cancel: retried 5 times, 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.
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.