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, or your 3PL splits an order across warehouses (one event per new sibling) | orderId, orderNumber, channelOrderId, status, splitFromOrderId, splitFromOrderNumber (the parent order when this one is one warehouse's part of a split order, otherwise both null) |
order.shipped | An order transitions to shipped | orderId, orderNumber, trackingNumber, carrierService, splitFromOrderId, splitFromOrderNumber (as above) |
shipment.created | A label is created | shipmentId, orderId, trackingNumber, carrierService, shippingCost (tenant-wide endpoints only — see Payload), 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, type (CUSTOMER or RTS), outboundShipmentId (the parcel the carrier sent back — RTS only, otherwise null), settlement (RESEND_AS_IS when the RTS parcel was found intact and settled with no receipt and no stock move, otherwise null) |
return.cancelled | A return is cancelled | returnId, rmaNumber, status, reason, type, outboundShipmentId |
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",
"client_id": "cl_7f2a...",
"resource": { "type": "shipment", "id": "shp_3c1b..." },
"data": {
"shipmentId": "shp_3c1b...",
"orderId": "ord_9a8b...",
"trackingNumber": "1Z999AA10123456784",
"carrierService": "UPS Ground",
"shippingCost": 8.42,
"shipCode": "SHP-10231"
}
}| Key | Value |
|---|---|
id | Stable event id (also x-canopy-event-id) |
type | The event type |
created_at | When the event happened (ISO 8601, UTC) |
client_id | The client (brand) the event belongs to |
resource | The API resource the event is about: type is one of order, shipment, purchase_order, return (the event-name prefix) and id is that resource's id |
data | The event payload (see the catalog above) |
The body is additive: ignore unknown top-level keys, as you already ignore
unknown event types. (client_id and resource were added on 2026-10-13; a
replay of an older event carries them too.)
Client-pinned endpoints get the brand-portal view
An endpoint pinned to a single client (brand) is that brand's own integration,
so its data carries what the brand is shown in the CanopyWMS portal — the
warehouse's own costs are left out. Today that means shippingCost is
omitted from shipment.created; no other event is affected. Tenant-wide
endpoints are the 3PL's own and receive every documented field.
The filter is applied per delivery as the body is sent, so the signature covers
the body you receive, and a replay of an event recorded earlier is filtered
the same way. One event delivered to both kinds of endpoint therefore carries
the same id in two different bodies.
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 | The delivery's id — one per (event, endpoint), unchanged across every retry and replay of that delivery |
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 |
| First attempt | On the next delivery tick, which runs every 10 seconds |
| Success | Any 2xx response |
| Retries | 6 attempts in total — the first, then up to 5 retries — on non-2xx or transport error |
| Backoff | 1 min → 5 min → 30 min → 2 hr → 6 hr between attempts; after the sixth failure the delivery is marked FAILED_PERMANENT |
| Guarantee | At-least-once — your handler must be idempotent (use the event id) |
| Ordering | Not guaranteed — independent retries can reorder events |
| Retention | Events and their deliveries are hard-deleted 30 days after the event was created (once every delivery is terminal); replay is only possible inside that window |
Respond 2xx quickly (acknowledge, then process asynchronously). Any non-2xx —
or a timeout — schedules a retry on the backoff above. An endpoint receives
only events recorded after it subscribed: subscribers are resolved when the
event is recorded, not at each attempt, so a new endpoint never inherits
earlier events (reconcile with updatedSince for those).
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. A tenant administrator reads the log for an endpoint
in Settings → Webhooks → View deliveries (see
Settings → Webhooks). Each row
reports status (PENDING / RUNNING / SUCCEEDED / FAILED_PERMANENT),
attempts, lastResponseStatus, lastError, lastAttemptAt, nextRunAt,
and the originating event { id, type }.
Not callable with a machine token
The routes behind that screen — /api/webhooks/endpoints,
/api/webhooks/endpoints/:id/deliveries,
/api/webhooks/deliveries/:deliveryId/replay — are tenant-admin
session-authenticated routes with no partner-API scope. A bearer token
from /oauth/token cannot call them; ask your CanopyWMS administrator, or
reconcile with updatedSince polling instead.
Replay
A tenant administrator can re-send any delivery — including a
permanently-failed one — from the delivery log once your endpoint is healthy.
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).
Deliveries older than the 30-day retention window are gone and cannot be
replayed.
Managing endpoints
Webhook endpoints are configured by a CanopyWMS tenant administrator in Settings → Webhooks — a session-only surface, not reachable with an API token:
- 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), which also narrows what its bodies carry (see Payload).
- A tenant may have at most 25 active endpoints; creating another is refused until one is deleted or disabled.
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.