Settlement webhooks
Verify deliveries and fulfil orders. The only thing allowed to trigger fulfilment.
Copy this whole, and paste it into your coding agent along with your own instructions about the codebase. It is self-contained: it restates every part of the Canopy contract it needs, so the agent does not have to reach this site to follow it.
Also available as plain text at /agents/webhooks/raw — see the prompt index for the rest of the catalogue.
It will ask you for credentials before it writes code
That is deliberate. Keys, webhook endpoints and embed origins all need a human with a dashboard session — an agent cannot create any of them. Have your keys to hand.
The prompt
# 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
> `<your app's public URL>/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,<base64>` 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<string, string | string[] | undefined>,
): Record<string, string> {
const out: Record<string, string> = {};
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<string, unknown>;
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,<base64>"
};
```
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.