CanopyAPI docs

Webhook events

Verify every delivery's signature before you trust anything in its body -- Canopy delivers settlement outcomes to the server endpoint you register.

This is the endpoint the quickstart's watch-for-settlement step registers. Read on for the signature it sends and the events it carries.

Verify the signature first

Every delivery carries three headers, per the Standard Webhooks convention (github.com/standard-webhooks/standard-webhooks):

  • webhook-id: the event's identifier. It stays the same across every retry of that delivery.
  • webhook-timestamp: the signing time, as a Unix timestamp in seconds.
  • webhook-signature: one or more space-separated v1,<base64 signature> entries.

Verify with the standardwebhooks package's own Webhook class rather than re-deriving the HMAC yourself:

verify-webhook.tsts
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(endpointSecret); // your whsec_... signing secret
const payload = wh.verify(rawBody, headers); // throws if no signature matches

verify() throws a WebhookVerificationError if none of the signature entries match your secret, or if webhook-timestamp is more than five minutes from the current time in either direction.

A single delivery can carry more than one v1,... entry, one per currently-active signing secret. That is what makes secret rotation possible without a synchronized cutover. verify() accepts the delivery as soon as any one entry matches, so you do not need to know in advance which secret was used.

Key rotation

Rotating a signing secret is not a swap. It mints a new secret and marks the previous one retiring, and both keep signing every delivery concurrently.

A message already in flight when you rotate still verifies against the retiring secret. That retiring secret signs until you revoke it, and revoking is a separate action on your own schedule rather than a side effect of rotation. Revoke once you have deployed verification against the new secret everywhere that checks it.

Events

Canopy defines two settlement events. Both carry the same shape: the payment intent's full current state, rather than a delta.

ts
type SettlementEventPayload = {
  intentId: string | null;
  inboxAddress: string;
  created: false;
  state: string;
  feeUnits: string;
  netUnits: string;
  txHash: string;
  chainId: number;
  merchantReference: string | null;
};

Branch on state, in the body you have just verified. A delivery carries exactly three headers -- webhook-id, webhook-timestamp and webhook-signature -- and none of them names the event type, so there is no "outer" type to read. state is the discriminator, which is why one payload shape covers both events.

ts
// payload is the verified body -- never branch before verifying.
if (payload.state === "settled") {
  // The intent settled. Fund the order.
} else {
  // Settlement did not complete for this intent. Do not fund.
}

Delivery ordering out of the outbox is best-effort and not guaranteed. That is why each event carries full state: if a terminal event arrives before an earlier one, you can still act correctly on it alone, having seen no prior event for that intent.

Retries and delivery

Any response outside 200–299 counts as a failed delivery attempt and is retried. So does no response at all: a timeout or a disabled endpoint lands the same way.

Respond with a 2xx status only once you have durably recorded the event. If your own processing can fail after acknowledging, do the durable write first and process asynchronously.

See also

  • Quickstart: the full walkthrough this page's watch-for-settlement step hangs off.