# Integrate Canopy — settlement webhooks You are adding Canopy's settlement webhook handler to an existing application. This is the endpoint that tells the server a payment actually settled, and it is **the only thing allowed to trigger fulfilment**. If the app already renders Canopy checkout or a deposit address, this is the half that makes it real. Work through the sections in order. Do not skip section 1. --- ## 1. Stop and collect the signing secret from the human You cannot create this yourself, and there is **no API for it** — a Canopy secret key cannot register a webhook endpoint. It is a dashboard action, by a human, with a logged-in session. **Ask now, before writing code**, and wait: > To wire up settlement webhooks I need you to do one thing in the Canopy > dashboard, then send me one value: > > 1. Go to **Dashboard → Webhooks** and add an endpoint pointing at > `/webhooks/canopy`. > 2. Copy the **signing secret** it shows you — it starts `whsec_`. > > If the app is not deployed yet, point it at a tunnel (ngrok, Cloudflare > Tunnel) so Canopy can reach it, or add the endpoint later and give me the > secret then. I'll build the handler either way. ``` CANOPY_WEBHOOK_SECRET=whsec_... ``` Never commit it. If the human cannot supply it yet, build the handler and leave it reading the env var rather than inventing a secret or stubbing verification out. --- ## 2. Verify the signature before you parse or act on the body Canopy protects this endpoint **no other way**. There is no IP allowlist and no mutual TLS. An unverified payload is indistinguishable from anything a stranger could POST to the same URL — and it is a payload that releases goods. Every delivery carries three Standard Webhooks headers: | Header | Meaning | | ------------------- | ------------------------------------------------------ | | `webhook-id` | The event's identifier. **Stable across every retry.** | | `webhook-timestamp` | Signing time, Unix seconds. | | `webhook-signature` | One or more space-separated `v1,` entries. | Use the `standardwebhooks` package. Do not re-derive the HMAC yourself. ```ts import { Webhook, WebhookVerificationError } from "standardwebhooks"; const wh = new Webhook(process.env.CANOPY_WEBHOOK_SECRET!); const payload = wh.verify(rawBody, toVerifyHeaders(headers)); // throws if nothing matches ``` **`verify()` returns the already-parsed body**, not a JSON string. Do not `JSON.parse` its result — that stringifies to `"[object Object]"` and throws, which rejects every _valid_ delivery while every signature test still passes. It fails closed, so it looks exactly like the security control working, and the only symptom is that real payments never fund. Do not parse it. Do not stop at a cast either. `verify()` is typed `unknown`, so `as SettlementEventPayload` compiles — but it is an assertion you have not checked, and section 4's "malformed-but-verified" case only exists if something actually looks. A signature proves the bytes came from Canopy; it proves nothing about their shape. Run a narrow type guard before you touch the fields, and have it handle `undefined` too: a verified **empty** body returns `undefined` rather than throwing. ### Your headers are the wrong shape. Both of them. `verify()` normalises with `Object.keys(headers)`, so it needs a plain object whose values are all strings. **Neither shape you will actually be holding satisfies that, and the two fail differently:** - Express's `req.headers` is `IncomingHttpHeaders`, whose values are `string | string[] | undefined`. It does not typecheck. - A Next.js / Hono `Request.headers` is a WHATWG `Headers`, which has **no own enumerable keys at all**. `Object.keys()` returns `[]`, so `verify()` throws `WebhookVerificationError: Missing required headers` on **every valid delivery**. It is a type error at any strictness setting — but it is the kind people silence with a cast, or never see in plain JavaScript, and it then rejects 100% of real traffic. Normalise once, and read your idempotency key off the result: ```ts function toVerifyHeaders( input: Headers | Record, ): Record { const out: Record = {}; if (input instanceof Headers) { input.forEach((v, k) => { out[k.toLowerCase()] = v; }); return out; } for (const [k, v] of Object.entries(input)) { if (v === undefined) continue; out[k.toLowerCase()] = Array.isArray(v) ? v[0]! : v; } return out; } ``` For the same reason, `req.headers["webhook-id"]` is `undefined` on a WHATWG `Headers` — you need `.get("webhook-id")`, or the normalised object. Get this wrong and every event keys on `undefined`: the first delivery funds, and every later one short-circuits as a duplicate and **never funds**. That failure looks healthy from the outside, which makes it worse than the outage above. ### The secret, and what actually breaks Pass the secret as the dashboard gave it to you. The library strips a leading `whsec_` and base64-decodes the remainder into the raw HMAC key, which is exactly the shape Canopy mints (`whsec_` + base64 of 32 random bytes). Pre-stripping it yourself is harmless — the constructor handles both. What breaks **loudly** is double-prefixing (`whsec_whsec_…`), an unset variable, or an empty secret. Those throw a **plain `Error`** (`Base64Coder: incorrect characters for decoding`, `Secret can't be empty.`, `Expected secret to be of type string`), **not** a `WebhookVerificationError` — so they must not fall into your signature-rejection branch. Construct the `Webhook` lazily inside the handler rather than at module scope, so a missing env var surfaces as a logged 5xx on a delivery instead of a boot failure with no webhook-shaped message anywhere. What breaks **silently** is a secret that is still valid base64 but is not the right key — re-encoded, truncated, or copied from a different endpoint. The constructor accepts it without complaint and builds the wrong HMAC key. Every delivery then fails as `WebhookVerificationError: No matching signature found`, your handler answers `400` exactly as it should, Canopy retries seven times, and the settlement is abandoned in about twelve hours. **No exception type can tell this apart from a genuine attacker**, which is why it deserves its own defence: if _every_ delivery is 400ing, suspect your secret before you suspect the sender. Log a one-way fingerprint of the configured key at boot — the first 8 hex of `sha256(key)` — and compare it with the dashboard. (A trailing newline on a pasted secret does throw, so the commonest mis-paste is caught.) `verify()` throws a `WebhookVerificationError` if no entry matches your secret, or if `webhook-timestamp` is more than five minutes from now in either direction. The tolerance is symmetric and the boundary is inclusive: 300s stale passes, 301s does not, and a timestamp six minutes in the _future_ is rejected too. ### The raw body is not optional `verify()` must see the **exact bytes** Canopy signed. If your framework parses JSON before your handler runs, a re-serialized body will not match and every delivery will fail verification. Turn body parsing off for this one route: - **Express**: `express.raw({ type: "*/*" })` on this route, mounted _before_ any global `express.json()`. Do **not** narrow it to `type: "application/json"`. Canopy does send `content-type: application/json`, but `express.raw` fails _open_: on any other content type it leaves `req.body` as `undefined` rather than erroring, and `verify(undefined, …)` then throws a `TypeError`. **The `Buffer.isBuffer` guard is the real protection here, not the `*/*`** — even `*/*` fails open when a request arrives with no `Content-Type` at all, because body-parser's type matcher returns false. Guard with `if (!Buffer.isBuffer(req.body))` and return a 5xx before verifying. Note also that `express.raw` has a **100kb default `limit`**; a larger body 413s before your handler runs. - **Next.js App Router**: `await req.text()` in the route handler. - **Fastify**: a content-type parser that preserves the raw buffer. - **Hono**: `await c.req.text()`. Verify first, and let `verify()` do the parsing — never `JSON.parse` before it, and never `JSON.parse` its result. ### Several signatures is normal A delivery may carry more than one `v1,...` entry — one per currently-active signing secret. That is how rotation works without a synchronized cutover: rotating mints a new secret and marks the old one _retiring_, and both sign concurrently until the old one is revoked. `verify()` accepts as soon as any entry matches, so you never need to know which secret was used. --- ## 3. The events and the payload Canopy's internal event catalogue names two settlement events, `payment.settled` and `payment.failed`. **Neither name appears in anything you will ever receive.** The three headers do not carry an event type and the payload has no `type` field, so there is no "outer" type to read; the type is stored on Canopy's side and never leaves the database. **The discriminator is the body's `state`.** Only one value is produced today: every delivery carries `state: "settled"`. `payment.failed` currently has no producer at all, so there is no second value to match on and no documented vocabulary for one — treat the set as open. That is exactly why your branch must **default to refusing**: ```ts if (payload.state === "settled") { // fund the order } else { // record, alert, do NOT fund } ``` Do not write `if (state === "failed")`. A future non-settled value that is not the string you guessed would leave the order stuck in `pending` forever, and "fund on any verified delivery" is correct only by accident right now. The body is the intent's **full current state**, not 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; }; ``` `feeUnits` and `netUnits` are base-units decimal **strings**. Keep them as strings or `BigInt`. Never `Number()` them into your ledger — that silently loses precision above 2^53. **Do not put `created` in your type guard.** It is typed as the _literal_ `false`, and transcribing that faithfully into a guard (`o.created === false`) means a delivery that ever carries `true` is routed to your "unexpected shape, do not fund" branch permanently. Guard the fields you actually read — `state`, `netUnits`, `feeUnits`, `merchantReference` — and ignore `created`. Here is the guard, since everything above depends on it existing: ```ts function isSettlementEvent(o: unknown): o is SettlementEventPayload { if (typeof o !== "object" || o === null) return false; const r = o as Record; return ( typeof r.state === "string" && typeof r.feeUnits === "string" && typeof r.netUnits === "string" && typeof r.txHash === "string" && typeof r.chainId === "number" && (typeof r.intentId === "string" || r.intentId === null) && (typeof r.merchantReference === "string" || r.merchantReference === null) ); } ``` `merchantReference` is the value you set when you created the intent. It is usually the cleanest join back to your own order. `intentId` is nullable, so do not assume it as your only key. A complete delivery looks like this: ```json { "intentId": "0f2ad4e998206c864aa65d54c4a4b5cd", "inboxAddress": "0x74cEeE5f715fb2d9fACDB33411eF2243133f90E9", "created": false, "state": "settled", "feeUnits": "125000", "netUnits": "12375000", "txHash": "0x…", "chainId": 4663, "merchantReference": "order_10024" } ``` --- ## 4. The four rules that make this correct 1. **Idempotently, in one statement.** Key on `webhook-id`, which is stable across retries. Retries are expected and will re-deliver events you have already processed; double-funding an order is the failure mode this prevents. Do **not** check-then-write. `if (await alreadyProcessed(id)) return` followed by an insert leaves a window in which two concurrent retries — which is exactly what a retry storm is — both pass the check and both fund. Make the unique index itself the test, in a single statement: ```sql INSERT INTO webhook_events (event_id, raw_body, received_at, processed_at) VALUES ($1, $2, now(), NULL) ON CONFLICT (event_id) DO NOTHING RETURNING event_id; ``` No row returned means duplicate: respond `2xx` and stop. **`processed_at` is not decoration — this row is a work queue, not just a dedupe marker.** The moment you claim an event you have made it undeliverable: a retry short-circuits on the duplicate branch, so Canopy will never resend it. If your funding step then crashes, the order sits `pending` forever while Canopy records a successful delivery — the exact "never funds, looks healthy from the outside" failure this document keeps warning about. Your drain selects `WHERE processed_at IS NULL` and stamps it on success; add an `attempts` counter so a poisoned row gets alerted on rather than retried forever. 2. **Order is best-effort.** Delivery is not ordered. A terminal event can arrive before an earlier one. That is exactly why every event carries full state: act on each event **alone**, without assuming you saw a predecessor. 3. **Durable write, then acknowledge.** Any status outside `2xx` — and any timeout; Canopy gives you **10 seconds** — counts as a failed delivery and is retried. Acknowledge only once the event is durably recorded, then process asynchronously. Retries walk a fixed backoff of **0s, 5s, 30s, 5m, 30m, 2h, 10h** — seven attempts spanning about 12 hours. After the last one the event is **abandoned**: Canopy alerts internally and stops. Your endpoint is _not_ disabled, and nothing re-drives the event later, so a handler that 5xx's for half a day loses those settlements permanently. That is the real cost of rule 4. Note the "then process asynchronously" is only literally possible on a long-lived server (Express, Fastify). In a Next.js App Router handler you _return_ the response, and on serverless the container may freeze the moment you do — nothing after the return is guaranteed to run. There, the durable insert must be the whole of your synchronous work, with processing picked up out of band by a queue, a cron worker draining unprocessed rows, or `waitUntil()`. The insert is what makes the acknowledgement honest; the processing is not. 4. **Never 5xx on a payload you dislike.** A malformed-but-verified body is your bug, not a delivery failure. Record it, alert, and acknowledge. Rule 3 is why: retries are finite, and an event you keep rejecting is gone for good in about twelve hours. Return a non-`2xx` **only** when you genuinely want the delivery retried — for example your database was briefly unavailable. A failed **signature** check is not that: return `400` and do not retry it into your system. ### The three ways `verify()` throws, and what each one means This is where the rules above are most often violated, because it is tempting to write one `catch` and return `400`. That single line converts rule 4 into its opposite. `verify()` checks the signature and _then_ JSON-parses, so a throw is not proof of a bad delivery: | Thrown | Meaning | Respond | | -------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | | `WebhookVerificationError` | Bad signature, missing headers, or stale timestamp. Genuinely unverified. | `400`, never retry | | `SyntaxError` | The signature **matched** — a real Canopy delivery whose body you cannot parse. | `2xx`, record the raw bytes and alert (rule 4) | | plain `Error` | Your own misconfiguration: empty/unset/double-prefixed secret, or a non-Buffer body from a mis-mounted parser. | `5xx` — you want this retried once you fix it | A bare `catch { return respond(400) }` turns both of the last two into a permanent rejection loop that ends with the event abandoned. --- ## 5. The shape to write ```ts // POST /webhooks/canopy const raw = await readRawBody(req); // Buffer or string -- NOT parsed const headers = toVerifyHeaders(req.headers); // section 2: both shapes are wrong raw let body: unknown; try { // verify() returns the ALREADY-PARSED body -- never JSON.parse its result. body = new Webhook(requireSecret()).verify(raw, headers); } catch (err) { if (err instanceof WebhookVerificationError) return respond(400); // unverified if (err instanceof SyntaxError) { // Signature MATCHED. Rule 4: this is our problem, not a delivery failure. // Key this on webhook-id too -- otherwise all seven retries of one bad // event write seven rows and fire seven alerts. await recordUnparseableOnce(headers["webhook-id"], raw); return respond(200); } return respond(500); // our own misconfiguration -- we want the retry } if (!isSettlementEvent(body)) { // narrow it; a cast checks nothing await recordUnexpectedShapeOnce(headers["webhook-id"], body); return respond(200); // rule 4 again } // One statement: the unique index IS the idempotency test (rule 1). const eventId = headers["webhook-id"]; let claimed: boolean; try { claimed = await claimEvent(eventId, raw); // INSERT ... ON CONFLICT DO NOTHING } catch { return respond(503); // the ONE case that deserves a retry } if (!claimed) return respond(200); // idempotent replay // RETURN it. Every other exit above returns; this one must too, or a Next.js // handler falls off the end and 500s every genuinely settled delivery. return respond(200); // ...and processing happens OUTSIDE this handler -- a queue, a cron worker // draining `processed_at IS NULL`, or waitUntil(). Nothing after the return is // guaranteed to run. Branch on body.state there; refuse anything not "settled". ``` Fulfilment belongs behind this handler and nowhere else. In particular, the checkout SDK's browser-side `paid` event is a **UX signal only** — it runs in a browser you do not control and must never release goods, credit an account, unlock a download, or move money. --- ## 6. Prove it works before you report done Signature verification is security code. Test it, do not eyeball it. 1. **Valid delivery accepted** — sign a body with the real secret using `standardwebhooks`' own signer, POST it, assert `2xx` and that the order moved to paid. `sign()` is a public method on `Webhook`, and you assemble the three headers yourself: ```ts const secret = "whsec_" + crypto.randomBytes(32).toString("base64"); const wh = new Webhook(secret); const id = "msg_" + crypto.randomBytes(6).toString("hex"); const date = new Date(); // sign() takes a Date... const headers = { "webhook-id": id, "webhook-timestamp": String(Math.floor(date.getTime() / 1000)), // ...the header is SECONDS "webhook-signature": wh.sign(id, date, body), // returns "v1," }; ``` Derive the header from the same `Date` you signed with — computing the two independently gives you an off-by-a-second flake. 2. **Tampered body rejected** — same signature, one byte changed in the body. Assert `400` and that nothing was fulfilled. 3. **Wrong secret rejected** — sign with a different secret. Assert `400`. 4. **Stale timestamp rejected** — sign with `webhook-timestamp` six minutes old. Assert `400`. 5. **Replay is idempotent** — POST the identical valid delivery twice. Assert `200` both times and that the order was funded **exactly once**. 6. **Out-of-order is safe** — deliver `payment.settled` with no prior event for that intent. Assert it is handled correctly on its own. 7. **Raw body is preserved** — build your test app _with_ a global `express.json()` installed, assert on a second route that it really is active (that route receives a parsed object), then run test 1 again against the webhook route. Test 1 still passing is the proof that this route bypasses the global parser. If it fails, `verify()` will have thrown a plain `Error: Expected payload to be of type string or Buffer.` — a misconfiguration, not a rejected delivery. 8. **Precision is kept** — assert `netUnits` is stored as a string or `BigInt`, never a JS `number`. 9. **Verified-but-unparseable is acknowledged, not rejected** — sign a body that is not valid JSON. Assert the handler returns `2xx`, records it, and funds nothing. A `400` here is the rule-4 violation that eventually loses real events. 10. **Header shape is handled** — run test 1 twice, once passing Express's `req.headers` and once a WHATWG `Headers`. Both must verify. Passing a `Headers` straight to `verify()` fails every time, so this is the test that catches the most common transcription bug. 11. **Concurrent replay funds once** — fire the identical valid delivery twice _concurrently_, not sequentially. Assert exactly one fund. A check-then-write handler passes test 5 and fails this one. If the human has not supplied a real `whsec_` secret yet, run tests 1–11 against a locally generated secret (`"whsec_" + 32 random bytes, base64`). Once they have, the same suite is what you run against live Canopy deliveries — webhooks work; the handler is the fulfilment path. The helpers above (`readRawBody`, `requireSecret`, `respond`, `claimEvent`, `recordUnparseableOnce`, `recordUnexpectedShapeOnce`, `processAsync`) are yours to write in the idiom of the codebase you are in — only `isSettlementEvent` and `toVerifyHeaders` are given, because those two are where the correctness lives. Then report: what you built, the commands you ran, their output, and anything you could not finish because a credential was still missing.