Webhook events
Canopy sends settlement results to a server endpoint you register. Verify each delivery's signature before you trust its body.
You set up this endpoint in the quickstart's watch-for-settlement step. This page covers how to verify each delivery and which events it carries.
Verify the signature first
Canopy follows the Standard Webhooks convention (github.com/standard-webhooks/standard-webhooks). Every delivery has three headers:
webhook-id: the event's ID. It is the same on every retry of that delivery.webhook-timestamp: when the delivery was signed, as a Unix timestamp in seconds.webhook-signature: one or more space-separatedv1,<base64 signature>entries.
Verify with the Webhook class from the standardwebhooks package. Do not compute the HMAC yourself.
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(endpointSecret); // your whsec_... signing secret
const payload = wh.verify(rawBody, headers); // throws if no signature matchesverify() throws a WebhookVerificationError in two cases:
- none of the signature entries match your secret
webhook-timestampis more than five minutes before or after the current time
Anyone can POST to your webhook URL. The signature is the only way to know a delivery came from Canopy. There is no IP allowlist and no mutual TLS.
A delivery can have more than one v1,... entry, one for each active signing secret. This lets you rotate secrets without downtime. verify() accepts the delivery if any one entry matches. You do not need to know which secret was used.
Key rotation
Rotating a signing secret creates a new secret and marks the old one retiring. Both secrets sign every delivery until you revoke the old one.
Deliveries already in flight still verify against the retiring secret. Rotating does not revoke the old secret. Revoke it yourself once every service that verifies webhooks uses the new secret.
Events
Canopy defines two settlement events. Both use the same payload. It holds the payment intent's full current state, not only what changed.
type SettlementEventPayload = {
intentId: string | null;
inboxAddress: string;
created: false;
state: string;
feeUnits: string;
netUnits: string;
txHash: string;
chainId: number;
merchantReference: string | null;
};Use state in the verified body to tell the events apart. The headers do not include the event type, so state is the only place to read it.
// 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.
}Today every delivery has state: "settled". payment.failed is part of the contract but is not sent yet. Write the else branch anyway. It must not fund the order. Do not assume every verified delivery is a settlement.
Events can arrive out of order. Each event carries the full state, so you can act on it alone. This holds even if a final event arrives first and you have not seen an earlier event for that intent.
Retries and delivery
Canopy retries a delivery if your endpoint does any of these:
- returns a status outside
200–299 - does not respond, for example because it timed out
- is disabled
Return a 2xx status only after you have saved the event somewhere durable. If your processing could fail after you respond, save the event first and process it in the background.
See also
- Quickstart: the full walkthrough, including the watch-for-settlement step.