CanopyDocs

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 typeFires whendata includes
order.createdAn 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.shippedAn order transitions to shippedorderId, orderNumber, trackingNumber, carrierService, splitFromOrderId, splitFromOrderNumber (as above)
shipment.createdA label is createdshipmentId, orderId, trackingNumber, carrierService, shippingCost (tenant-wide endpoints only — see Payload), shipCode
purchase_order.receivedReceiving completes on a POinboundShipmentId, shipmentNumber, externalPoNumber, status
purchase_order.updatedA PO is updatedinboundShipmentId, shipmentNumber, externalPoNumber
return.completedA return passes inspectionreturnId, 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.cancelledA return is cancelledreturnId, 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:

Webhook body
{
  "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"
  }
}
KeyValue
idStable event id (also x-canopy-event-id)
typeThe event type
created_atWhen the event happened (ISO 8601, UTC)
client_idThe client (brand) the event belongs to
resourceThe 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
dataThe 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:

HeaderValue
x-canopy-event-idStable id for the event (your dedupe key)
x-canopy-event-typeThe event type
x-canopy-delivery-idThe delivery's id — one per (event, endpoint), unchanged across every retry and replay of that delivery
x-canopy-timestampUnix seconds — also part of the signature
x-canopy-signaturesha256=<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:

Verify a webhook (Node.js)
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

TransportPOST over HTTPS, 10-second timeout
First attemptOn the next delivery tick, which runs every 10 seconds
SuccessAny 2xx response
Retries6 attempts in total — the first, then up to 5 retries — on non-2xx or transport error
Backoff1 min → 5 min → 30 min → 2 hr → 6 hr between attempts; after the sixth failure the delivery is marked FAILED_PERMANENT
GuaranteeAt-least-once — your handler must be idempotent (use the event id)
OrderingNot guaranteed — independent retries can reorder events
RetentionEvents 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.

On this page