Webhooks
Subscribe to signed events — the event catalog, signature verification, delivery and retry semantics, delivery logs, and replay.
Webhooks push events to your endpoint as they happen, so you don't have to poll. Every delivery is signed (HMAC-SHA256), retried on failure, logged, and individually replayable.
Event catalog
Subscribe an endpoint to any of these event types. The data payload for each
mirrors the corresponding API resource.
| Event type | Fires when | data includes |
|---|---|---|
order.created | An order is ingested | orderId, orderNumber, channelOrderId, status |
order.shipped | An order transitions to shipped | orderId, orderNumber, trackingNumber, carrierService |
shipment.created | A label is created | shipmentId, orderId, trackingNumber, carrierService, shippingCost, shipCode |
purchase_order.received | Receiving completes on a PO | inboundShipmentId, shipmentNumber, externalPoNumber, status |
purchase_order.updated | A PO is updated | inboundShipmentId, shipmentNumber, externalPoNumber |
return.completed | A return passes inspection | returnId, rmaNumber, status |
return.cancelled | A return is cancelled | returnId, rmaNumber, status, reason |
Event-type names are a stable, additive contract — new event types are added over time without renaming existing ones, so subscribe to the specific types you need and ignore unknown ones.
Payload
Every delivery is a POST with this envelope:
{
"id": "evt_clz9q2k7b0001x8p3a1b2c3d4",
"type": "shipment.created",
"created_at": "2026-06-29T17:42:10.123Z",
"data": {
"shipmentId": "shp_3c1b...",
"orderId": "ord_9a8b...",
"trackingNumber": "1Z999AA10123456784",
"carrierService": "UPS Ground",
"shippingCost": 8.42,
"shipCode": "SHP-10231"
}
}Headers on every delivery:
| Header | Value |
|---|---|
x-canopy-event-id | Stable id for the event (your dedupe key) |
x-canopy-event-type | The event type |
x-canopy-delivery-id | Per-attempt delivery handle |
x-canopy-timestamp | Unix seconds — also part of the signature |
x-canopy-signature | sha256=<base64 HMAC> |
The id in the body (and x-canopy-event-id) is stable across retries
and across multiple endpoints — use it to make your handler idempotent.
Verifying the signature
The signature is HMAC-SHA256 over the string `{timestamp}.{rawBody}`,
base64-encoded, using your endpoint's signing secret (shown once when the
endpoint is created). Verify it against the raw request body, before
JSON-parsing:
import crypto from "node:crypto";
function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
const ts = headers["x-canopy-timestamp"];
const sig = headers["x-canopy-signature"]; // "sha256=<base64>"
// 1. Reject stale timestamps (replay protection)
if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > toleranceSeconds) {
return false;
}
// 2. Recompute over `${ts}.${rawBody}`
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("base64");
// 3. Constant-time compare
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}Bind the timestamp into your check and reject deliveries outside a tolerance window (e.g. 5 minutes) to defend against replay. Always compare signatures in constant time.
Delivery & retries
| Transport | POST over HTTPS, 10-second timeout |
| Success | Any 2xx response |
| Retries | Up to 5 attempts on non-2xx or transport error |
| Backoff | 1 min → 5 min → 30 min → 2 hr → 6 hr, then marked permanently failed |
| Guarantee | At-least-once — your handler must be idempotent (use the event id) |
| Ordering | Not guaranteed — independent retries can reorder events |
Respond 2xx quickly (acknowledge, then process asynchronously). Any non-2xx —
or a timeout — schedules a retry on the backoff above.
Because delivery is at-least-once and unordered, treat webhooks as a
notification to go read the resource, and reconcile with updatedSince
polling (see Conventions)
for a guaranteed-complete picture.
Delivery logs
Every attempt is recorded. Query the recent deliveries for an endpoint:
curl "https://api.staging.canopywms.com/api/webhooks/endpoints/:id/deliveries" \
-H "Authorization: Bearer $ADMIN_TOKEN"Each row reports status (PENDING / RUNNING / SUCCEEDED /
FAILED_PERMANENT), attempts, lastResponseStatus, lastError,
lastAttemptAt, nextRunAt, and the originating event { id, type }.
Replay
Re-send any delivery — including a permanently-failed one — once your endpoint is healthy:
curl -X POST "https://api.staging.canopywms.com/api/webhooks/deliveries/:deliveryId/replay" \
-H "Authorization: Bearer $ADMIN_TOKEN"Replay re-arms the same delivery, so the body and x-canopy-event-id are
identical to the original (only the timestamp and signature are recomputed).
Managing endpoints
Webhook endpoints are configured by a CanopyWMS tenant administrator (in Settings → Webhooks, or via the tenant-admin webhook API):
- Create / update / delete endpoints, each with a URL and a list of subscribed event types. A signing secret is returned once at creation.
- Rotate the signing secret at any time.
- Per-client scoping — an endpoint can receive events for the whole tenant or be pinned to a single client (brand).
For security, endpoint URLs must be HTTPS and resolve to a public address — requests to private, loopback, link-local, or cloud-metadata ranges are rejected, and the target is re-validated at delivery time to defend against DNS rebinding.