Skip to content

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.

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"] }'
  • url must be https. Deliveries go to public addresses only, never private or loopback ones. (A plain http URL for localhost, 127.0.0.1 or [::1] is accepted when subscribing but is never reached.)
  • events is a list of the event names above, or ["*"] for every event (the default). An unknown name returns 400.

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

Some nodes send an event when they finish:

EventSent when
order.paidCapture payment (commerce.capture_payment) finishes on its ok branch
order.paidCharge (commerce.charge) finishes on its ok branch
order.invoicedGenerate invoice (commerce.invoice) finishes
order.shippedCreate shipment (commerce.ship) finishes
order.ready_for_pickupReady for pickup (commerce.pickup_ready) finishes
order.deliveredHand over (commerce.handover) finishes
order.deliveredDeliver digital goods (commerce.deliver_digital) finishes
order.refundedRefund (commerce.refund) finishes on its ok branch
order.payout_scheduledSeller 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
POST /hooks/orderloop HTTP/1.1
content-type: application/json
x-orderloop-signature: t=1760340000,v1=5f2c…
x-orderloop-event: order.shipped
x-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 } }, where output is 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).

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 });

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.cancelled on 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.