Headless (server to server)
Your server creates intents and renders the deposit address yourself. No browser SDK.
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/headless/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 — headless (server to server)
You are integrating Canopy into an existing application. Canopy takes a
deposit in a supported currency from an end user on whatever chain they already hold funds
on, takes a platform fee, and delivers the net to the configured payout destination. This task is the
**headless** integration: your server calls Canopy's HTTP API and renders the
deposit address yourself. No Canopy browser SDK, no iframe, no popup.
Work through the sections in order. Do not skip section 1.
---
## 1. Credentials come from a human, not from you
You cannot create these. They come from a Canopy account someone has to set up
in the dashboard. **Ask for them before writing code:**
> To wire this up I need three things from your Canopy dashboard:
>
> 1. **Secret key** — starts `cnpy_sk_live_`. Dashboard → API keys.
> 2. **API base URL** — the host your Canopy account is served from,
> **including the `www.`**. Check it answers: an unauthenticated
> `POST {base}/api/v1/intents` must return **HTTP 401 with
> `content-type: application/json`** and a body of
> `{"error":{"code":"unauthorized",...}}`. A `308`, a `text/plain` body, or
> an HTML 404 all mean the base URL is wrong.
> 3. **Webhook signing secret** — starts `whsec_`. Dashboard → Webhooks → add
> an endpoint pointing at `<your app>/webhooks/canopy`, then copy the
> signing secret.
>
> Point 3 has to be done by hand in the dashboard. There is no API for
> registering a webhook endpoint, and a secret key cannot do it — that route
> authenticates with a logged-in dashboard session.
```
CANOPY_SECRET_KEY=cnpy_sk_live_...
CANOPY_API_BASE=https://...
CANOPY_WEBHOOK_SECRET=whsec_...
```
**If nobody answers** — you are running unattended, or the human does not have
these yet — do not stall and do not invent values. Build everything, read every
value from the environment, and finish by printing a short blocking list of
exactly what is still needed and where to get it. Never fabricate a key,
never hardcode a placeholder that could be mistaken for real, and never stub
signature verification out to make a test pass.
**Smoke-test the key before you build anything on it.** One call:
```bash
curl -s -o /dev/null -w '%{http_code}\n' "{CANOPY_API_BASE}/api/v1/intents?limit=1" \
-H "Authorization: Bearer $CANOPY_SECRET_KEY" -H "Canopy-Version: 2026-09-01"
```
**Do not use the bare apex host.** The apex 308-redirects to `www`, and both
`curl -L` and Node's `fetch` (which follows redirects by default) **drop the
`Authorization` header across that cross-host hop**. A perfectly good key then
produces `401 "A bearer token is required."` — the exact message this section
tells you means your request shape is fine. The `docs` link inside every error
envelope points at the apex; do not copy your base URL out of it.
`200` means you are good. `401` with
`"The bearer token is invalid, revoked, or not a secret key."` means the key
you were handed does not work — it is revoked, from another account, or for
another host. That message is **byte-identical** to the one a made-up key
gets, so no amount of re-checking your header construction will distinguish
them: stop and go back to the human for a fresh key rather than rebuilding
your client. (A _missing_ header gives a different message,
`"A bearer token is required."`, which is how you know your request shape is
fine — _provided you are not being redirected_. Log `response.url` and
`response.redirected` before you believe that: a header stripped across a host
redirect is indistinguishable from one you never sent. A **publishable** key
(`cnpy_pk_live_`) gets the invalid-token message too, which is a likely mix-up.)
Do this first. Every other step depends on it, and discovering it after a full
build wastes the entire build.
**The secret key is server-only.** Never send it to a browser, inline it into
HTML, or expose it through a public endpoint. If the framework has a
client/server split, every Canopy call goes on the server side of it.
---
## 2. The API contract
```
Authorization: Bearer $CANOPY_SECRET_KEY
Canopy-Version: 2026-09-01
Content-Type: application/json
```
`Canopy-Version` is **optional but you should always send it**. Omitting it
does not fail — the server applies its current version and echoes the version
it used in a `canopy-version` response header. That is exactly why you pin it:
without the header your integration silently rides whatever the current version
becomes, and a future version bump changes your behaviour with no code change.
An _unknown_ value is a `400 unsupported_version`.
### Errors
Handled errors use one envelope:
```json
{
"error": {
"code": "invalid_request",
"message": "The request body failed validation.",
"param": "priceUnits",
"request_id": "req_1e70e61c3a2a62631bfa3708",
"docs": "https://.../errors/invalid_request"
}
}
```
Always log `error.request_id`. It is what Canopy support traces on. (The `docs`
URL in the envelope points at the apex host and 308s to `www` — it is a link
for a human, not a base URL to copy. See section 1.)
Three things this envelope does **not** guarantee, all of which will bite you:
- **It is not universal.** At least five non-envelope shapes exist:
| Request | Response |
| ------------------------ | ---------------------------------------------------------------- |
| Unknown path | HTML 404, and **no `x-request-id`** — nothing to log |
| Real route, wrong method | `405`, **empty body, no `content-type`** |
| `HEAD` on a real route | `404`, **`content-type: application/json`, empty body** |
| Non-`/api/v1` route | `text/plain` `401 "Authentication required."` |
| Body over ~4.5 MB | `text/plain` `413 FUNCTION_PAYLOAD_TOO_LARGE`, no `x-request-id` |
**Checking `content-type` is not enough.** The `HEAD` row claims
`application/json` and then hands you zero bytes, so `JSON.parse("")` throws
in the one branch you thought was safe. Require `content-type: application/json`
**and** a non-empty body, **and** wrap the parse in a try. A bare
`body.error.code` throws a `TypeError` on any of these. Where there is no
envelope, fall back to `x-request-id` — and accept that two of the five rows
do not carry one.
- **`param` is often missing or empty.** It is populated for per-field type
errors, but the body is `.strict()` and an unknown key rejects with
`param: ""`. Some real 400s carry no `param` at all and only a prose
`message` (`"One-time intents require priceUnits"`, `"Route does not accept
a per-intent destination"`). Never require `param` to be present, and never
branch on `message` text — treat it as human-readable only.
- **Rate limits are on the response, not in this document.** Read
`ratelimit-limit`, `ratelimit-remaining` and `ratelimit-reset` rather than
hardcoding a budget — but treat them as **optional**. They are absent on
several error paths (`401 unauthorized` and `400 unsupported_version` carry
none), so code that asserts their presence breaks on exactly the responses
you most want instrumented. (Today: writes 30/min; single reads and `/origins` 120/min; but
**`GET /api/v1/intents` — the list — is 60/min**, not 120. Size pagination
loops off the header, not off that sentence.)
### Create a payment intent
`POST /api/v1/intents` — once per end user about to pay.
Body (`.strict()` — an unknown key is a 400):
| Field | Required | Notes |
| ---------------------- | -------- | ----------------------------------------------------------------------------------------- |
| `merchantReference` | no | ≤128 chars. Echoed back on webhooks — usually your best join key. |
| `metadata` | no | ≤16 keys, keys ≤64 chars, values string ≤256 / number / boolean, ≤2048 bytes total. |
| `priceUnits` | no | Base-units decimal **string**, `^[1-9][0-9]{0,29}$`. Never a JSON number — that is a 400. |
| `oneTime` | no | Boolean. Without `priceUnits` it is a 400. |
| `payoutWallet` | no | Per-intent destination. See the gate note. |
| `payoutNamespace` | no | `"eip155"` or `"solana"`. Only with `payoutWallet`. |
| `payoutChainReference` | no | Chain reference string. Only with `payoutWallet`. |
There is no idempotency key — not in the body, and not as a header. An
`Idempotency-Key` header is accepted and silently ignored; a body
`idempotencyKey` property is a `400` (the schema is `.strict()`). Section 2.1
explains the one dedupe rule that replaces both.
`priceUnits` is denominated in the **settlement asset's base units**. The
default settle rail is USDG with **6 decimals**, so $12.50 is `"12500000"`.
Compute with integer or `BigInt` arithmetic.
Avoid `Math.round(x * 1e6)` — but know why, because at 6 decimals it is
_actually exact_ for ordinary dollar amounts, and an implementer who tests the
claim there will find it false and discard the whole paragraph. The failure is
on the wide rail: the same account carries an 18-decimal ETH rail, where
`Math.round(1.1 * 1e18)` is `1100000000000000100` — off by 100 base units. Use
integer arithmetic so the code stays correct when the rail changes under you.
Six is a safe default, not a guarantee: an account can settle on more than one
rail. If you want certainty, read `settleRails[].token.decimals` from
`GET /api/v1/intents/{id}/origins` — taking the entry with `"default": true`,
never `[0]`, since the rails carry different decimals (USDG 6, ETH 18). Note
the ordering trap: that call needs an intent that already exists and
`priceUnits` can only be set at create time. So do not try to read the decimals
before your first create. Either use 6, or create one unpriced intent at
startup, read its rails, cache the value, and use it for every priced create
afterwards.
**Check the default rail's `token.symbol`, not just its decimals.**
`priceUnits` is an amount of the settlement asset, not a currency conversion.
Turning "$12.50" into `12.5 × 10^decimals` is only correct when that asset is a
dollar stablecoin. If the default rail is ever something like ETH, the same
arithmetic asks the payer for 12.5 ETH. If the symbol is not the stablecoin you
expect, stop and ask a human rather than shipping the multiplication.
201 response:
```json
{
"intentId": "0f2ad4e998206c864aa65d54c4a4b5cd",
"inboxAddress": "0x74cEeE5f715fb2d9fACDB33411eF2243133f90E9",
"created": true,
"priceUnits": null,
"oneTime": false,
"widgetToken": "<64 hex chars>"
}
```
`inboxAddress` is this intent's deposit address **on the settle chain**. It is
not automatically the address to show your user — that depends on which chain
they are paying from, and section 2.3 is where you decide. `widgetToken` is for
the browser SDK; a headless integration ignores it and uses the view signal
(section 2.5) with its secret key instead. Parse the fields you need and
ignore any others — the response is open and gains fields.
Every new intent uses a dynamic destination. Explicit destination fields take
precedence over the account default; the selected wallet and chain are saved
on the intent. Changing account defaults later does not retarget existing
intents. The UI uses the account default when creating a payment.
Supply `payoutWallet` with `payoutNamespace` and `payoutChainReference` to name
another destination. Chain fields without a wallet return
`400 "Destination requires payoutWallet"`; malformed addresses are refused.
New issuance requires confirmed dynamic routes. The former per-intent toggle
is deprecated and no longer controls destination admission.
For supported EVM funding rails, when the deposited token already matches the
intent's destination chain and currency, the first sweep takes the platform
fee and sends the net directly to the destination wallet. Other assets use the
existing conversion/bridge path. Same-chain swaps are a separate routing
choice and can gain a dedicated executor without changing intent semantics.
Fee and treasury remain authoritative onchain route values.
### 2.1 What `created` actually means
There is exactly **one** dedupe rule: at most one _active_ intent per
(merchant account, payout namespace, chain, wallet, token).
| You send | Response |
| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| The same chain, wallet and token with an active intent, same terms | `201`, `created: false`, the SAME `intentId`, and a **fresh** `widgetToken` |
| The same chain, wallet and token with different `priceUnits`/`oneTime` | `409 idempotency_key_reuse` — nothing is written |
| No destination fields at all | `201`, `created: true`, a **new** intent and a **new** deposit address, every time |
Note the status on the `created: false` row is `201`, not `200`. The
`widgetToken` is **always present** on a successful create, and on a
`created: false` get it is a _rotated_ token — the token from the earlier
create for that wallet stops authenticating the widget surface.
```
POST {"payoutWallet":"0x1111…"}
-> 201 {"intentId":"cb05f835…", "created":true}
POST {"payoutWallet":"0x1111…"} <-- same wallet
-> 201 {"intentId":"cb05f835…", "created":false} <-- SAME intent
```
Two genuinely separate payments that name the same destination collapse onto
**one intent and one deposit address**, and nothing in the response says which
of your orders it belongs to. Check the returned `intentId` against your own
table before treating a checkout as new. Once the intent leaves the active state, its destination can be used for a new intent.
A repeatable intent stays active after a deposit; archive it before changing its terms. EVM addresses compare case-insensitively;
Solana addresses compare byte-exactly. With no destination fields there is no
dedupe at all — every create is a new intent, so your own database is the only
place a retry can be recognised.
A different chain or token is a different destination and can have its own active intent.
Supplying any destination field enables this rule; omitted fields resolve from account defaults.
Serialize creates for the same destination and retain the returned `intentId` in your own database.
A retry rotates the widget token, while a request without destination fields creates another intent.
Do not blindly retry a timed-out destination-less create. `merchantReference` is metadata, not a dedupe key.
### On the dedupe path, the rest of your body is silently discarded
A `created: false` response is the _existing_ intent. A changed
`merchantReference` or `metadata` is **not** applied to it, and nothing tells
you so. Since `merchantReference` is what you meant to join webhooks on, the
value you just sent may not be the value that comes back. Store your join key
locally against the returned `intentId` rather than trusting the echo.
**You cannot confirm this from the API**, so do not spend time trying:
`merchantReference` is write-only and is returned by no read surface — not
`GET /intents/{id}`, not the list, not `/origins`. It becomes observable only on
a settled webhook payload. Take it on faith and keep the local mapping.
Consequences to design around:
- **`created: false` is a get, not an error.** Decide new-vs-existing from
your own database, not from this field alone.
- **`widgetToken` is always present, but a `created: false` get carries a
ROTATED token.** If an earlier checkout for that wallet is still open on the
prior token, its next widget call stops authenticating. Do not re-create an
intent whose checkout you still expect a user to be inside.
### 2.2 Read an intent, list intents, list origins
`GET /api/v1/intents/{intentId}` → `{ intentId, inboxAddress, created }`.
> **This read is lossy.** It does not return `priceUnits`, `oneTime`,
> `merchantReference` or `metadata` — if you need those later, store them
> yourself at create time. And `created` is always `false` here regardless of
> anything, including for an intent you just created: it carries no information
> on this route. Do not use it as a signal.
`GET /api/v1/intents?limit=20&cursor=<next_cursor>` → `{ object: "list", data,
has_more, next_cursor }`.
> **Stop on `has_more`, never on `next_cursor`.** `next_cursor` is a bare
> ordinal string and is **non-null even on the last page**. A loop written as
> `while (next_cursor)` never terminates.
`GET /api/v1/intents/{intentId}/origins` → `{ origins, settleRails,
sourceDepositIssuable }`.
- `origins[]` — one entry per chain + token the user may pay _from_ for this intent. Do not hardcode the list. For priced intents, currencies whose route/catalog minimum exceeds `priceUnits` are omitted; an amount exactly at the minimum remains eligible. If none remain, show that no currencies are available for this amount instead of restoring options from another intent or the global catalog.
Each entry carries `namespace` (`"eip155"` or `"solana"`), `reference` (the
chain reference — a numeric string on EVM, the genesis hash on Solana),
`chainId` (**`null` on Solana**), `name`, `token { symbol, address,
decimals }`, `caip19`, `minimumUnits`, `minimumDisplay`, `depositIssuable`,
plus presentation fields (`shortName`, `explorerUrl`, `publicRpcUrl`,
`nativeName`, `nativeSymbol`, `nativeDecimals`).
**Branch on `namespace`, never on `chainId`.** A naive
`origins.map(o => o.chainId)` dispatch sends `{"chainId": null}` for Solana.
**Key your picker on `caip19`, the only unique field.** `name`, `chainId` and
`reference` all collide: Base/USDC and Base/USDT are both
`name: "Base", chainId: 8453` with the same minimum. A picker keyed on any of
those silently collapses five options into three. (Relatedly, the EVM
source-inbox body carries only `chainId`, so both Base tokens issue the _same_
address — that is expected, not a bug you need to work around.)
The source-inbox body is built straight from these fields — do not parse
`caip19` to recover them:
```
EVM: { "chainId": origin.chainId }
Solana: { "namespace": origin.namespace,
"reference": origin.reference,
"mint": origin.token.address }
```
- `settleRails[]` — what the merchant settles _in_. Ids look like
`robinhood-mainnet:usdg` — a network slug plus a **lower-cased** token symbol,
so do not reconstruct one from `token.symbol` (which is `"USDG"`). Each rail
also carries `chainId`, `chainName`, `explorerUrl` and `status` (`"live"`).
One is marked `default: true`. Today: `robinhood-mainnet:usdg` (USDG, 6dp,
default) and `robinhood-mainnet:eth` (ETH, 18dp). **This is where you read the
settlement decimals for `priceUnits`.**
- `sourceDepositIssuable` — whether this intent has a deposit customer bound
yet. See 2.4; it is not a capability flag and you must not gate on it.
Show `minimumDisplay` beside every option, **and append `token.symbol`
yourself** — it is a bare decimal string with no unit (`"2.0825"`, `"10"`), so
rendering it alone gives the user "Minimum: 2.0825" of nothing. Minimums are specific to this intent’s source token, destination chain/token and platform fee. Read them from this intent’s `/origins` response; do not reuse another intent’s minimum or hardcode a chain-wide amount. `minimumUnits` is the integer threshold in the source token’s smallest units. The response also respects fixed-price and catalog floors. Below-minimum deposits wait for enough top-ups to accumulate before execution.
### 2.3 Deposit addresses: one call, one answer
- **`inboxAddress`**, from the create response, is the intent's inbox **on the
settle chain**.
- **The source-inbox address**, from
`POST /api/v1/intents/{id}/source-inbox`, is the address for a specific
source origin.
**On EVM origins these are currently the same string.** The inbox is deployed
counterfactually at one address, so the settle chain, Base and Arbitrum all
return it byte for byte:
```
create inboxAddress -> 0x58AF383DFaE3CCE34e207bfE6986235Bf86c5657
source-inbox 8453 address -> 0x58AF383DFaE3CCE34e207bfE6986235Bf86c5657
source-inbox 42161 address -> 0x58AF383DFaE3CCE34e207bfE6986235Bf86c5657
```
Solana is genuinely different — a Solana-format address, not an `0x` one.
**Do not rely on either fact, in either direction.** Always call `source-inbox`
for the origin the user actually picked and render exactly what it returns. Two
reasons, and the second is the real one:
1. It is already wrong for Solana today.
2. The addresses being equal on EVM is an implementation detail, not a
contract. Code that shortcuts to `inboxAddress` because "it's the same
anyway" sends a user's funds nowhere on the day it stops being the same, and
nothing will fail loudly when that happens.
Use the returned `caip10` (`eip155:8453:0x…`, `solana:<ref>:<pubkey>`) rather
than the bare `address` whenever you record or display which chain an address
belongs to — that is the field that actually distinguishes them.
**Everything in `origins[]` is a source origin.** That list never contains the
settle chain, so anything a user picks out of the picker needs a source inbox.
Do not try to match `origins[]` against `settleRails[]`; there is no join
between them and you do not need one. Reach for `inboxAddress` directly only on
a deliberate, separate "pay on the settle chain" path that does not come from
the picker.
```
POST /api/v1/intents/{id}/source-inbox
{ "chainId": 8453 } // EVM
{ "namespace": "...", "reference": "...", "mint": "..." } // Solana
```
`201` returns `{ "address": "0x…", "caip10": "eip155:8453:0x…" }`. It is
issue-or-get: repeating the call returns the same address and never a second
one, so it is safe to retry.
### 2.4 When issuance is refused
**`sourceDepositIssuable` is not a capability flag. Do not gate the picker on
it.** It means "this intent has a deposit customer bound yet" — a _state_, not a
permission. It is commonly `false` on a freshly created intent, because binding
has not completed, and flips to `true` as a consequence of that binding (issuing
a source inbox is one thing that causes it). A checkout nobody has clicked
through yet is the normal case for `false`.
Gate each payment option on that origin's own **`depositIssuable`**, which is
the capability flag. If you need "can this intent take a deposit at all", the
answer is `origins.some(o => o.depositIssuable)` — not the intent-level flag.
Concretely: `sourceDepositIssuable: false` while every origin is
`depositIssuable: true` is a real and common state. Treating the intent-level
flag as a gate greys out every payment option on a perfectly healthy new
checkout — which is exactly the "empty picker tells the user nothing" failure
you are trying to avoid.
**Always render the full origin list**, and do not raise a diagnostic merely
because `sourceDepositIssuable` is false. Show origins whose own
`depositIssuable` is false as unavailable.
**Retry by code, never by status.** A non-origin is refused _two different ways_
depending on the arm, and neither status alone tells you what to do:
```
# EVM arm -- chainId that is not a source origin (incl. the settle chain)
POST /source-inbox {"chainId":1}
-> 400 {"error":{"code":"invalid_request",
"message":"This origin is not currently accepting deposits.",…}}
# Solana arm -- valid reference, mint that is not an origin
POST /source-inbox {"namespace":"solana","reference":"5eykt4…","mint":"So111…112"}
-> 503 {"error":{"code":"source_inbox_not_active",
"message":"Deposit issuance is not enabled for this origin.…",…}}
```
So `source_inbox_not_active` is **common, deterministic, and the normal Solana
refusal** — not a rare provisioning artefact. Assert on it; you will get it.
| Code | Status | Retry? |
| -------------------------- | ------ | --------------------------------------------------------------------------------------------------------------- |
| `invalid_request` | 400 | **No** — EVM non-origin, bad Solana `reference`, or a malformed field (`param` names it). Offer another origin. |
| `source_inbox_not_active` | 503 | **No** — the standard Solana non-origin refusal, and issuance-off generally. Offer a different origin. |
| `not_found` | 404 | **No** — unknown intent. |
| `source_inbox_unavailable` | 503 | **Yes** — transient |
| `customer_not_bound` | 503 | **Yes** — transient |
| `source_inbox_refused` | 500 | **No** — contact Canopy support with the `request_id` |
**This is the one place where "retry `5xx`" will burn you.** Two of the three
`5xx` rows are not retryable, one of them is the everyday Solana refusal, and
nothing in the status distinguishes them. Branch on `error.code` before you
even look at the status, and treat every `400` from this route as terminal too.
A client that retries every `503` will hammer `source_inbox_not_active`
forever. Also: **`Retry-After` is frequently absent** on these responses, so
default to your own backoff rather than reading it blindly —
`Number(header) * 1000` on a missing header is `NaN`, which degrades into a
zero-delay hot loop.
### 2.5 Tell Canopy what the payer is looking at
`POST /api/v1/intents/{id}/view` — call it when you show the payer a deposit
address, and again whenever they pick a chain or token.
```
POST /api/v1/intents/{id}/view
{} // on screen, nothing picked yet
{ "selection": { "namespace": "eip155", "reference": "8453",
"tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" } }
{ "selection": { "namespace": "solana", "reference": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
"tokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" } }
```
`202` returns `{ "recorded": true }`. An empty body is fine — it records the
view without naming a token.
**Why:** webhooks on a freshly issued address take a couple of minutes to go
live. Until then, Canopy watches recently viewed addresses directly.
`selection` tells it exactly which token's address to watch; without it Canopy
watches all of the customer's addresses, which is slower. It is a detection
hint only — it never settles a payment or moves funds.
`{ "recorded": false }` (still `202`) means nothing matched the selection —
usually you have not issued the source inbox for that chain yet (section 2.3),
or the token is not live there. Fix the order of calls rather than retrying.
Errors: `400 invalid_request` or `400 invalid_json` for a bad body,
`404 not_found` for an unknown, archived, or another merchant's intent (never
distinguished, like every intent-scoped route here), and `429 rate_limited`
above 30 calls per minute per intent.
---
## 3. What to build
1. A server module wrapping the API: `createIntent`, `getIntent`,
`listIntents`, `listOrigins`, `issueSourceInbox`, `recordView`. One place that sets the
headers, unwraps the envelope defensively, logs `request_id`, and applies
the retry policy below.
2. A checkout route that creates an intent, lists the origins with their
minimums, and renders the correct deposit address for the origin the user
chose (section 2.3) — as text, a copy button and a QR code.
3. A webhook route (section 4).
4. Persistence linking your order id to `intentId`, written **before** you show
any address, so a webhook arriving immediately finds it. Store
`priceUnits` / `merchantReference` / `metadata` too — the intent read does
not give them back.
**Retry policy.** For reads and repeatable operations, retry on `429`, transport failures and
`5xx` **except** the codes section 2.4 marks not-retryable
(`source_inbox_not_active`, `source_inbox_refused`). Decide on
`error.code` first and fall back to status only when there is no code.
Exponential backoff with jitter; honour `Retry-After` when present and use your
own default when it is not. Do not retry other `4xx` responses unchanged.
For intent creation, retry only a request that supplied destination fields,
with the same resolved destination and terms; serialize retries and retain the newest token.
A destination-less create may have committed before a timeout or `5xx`; do not
automatically repeat it. Reconcile your saved intent ID or investigate using the request ID first.
Match the codebase you are in: its HTTP client, error handling, persistence and
test framework. Do not add a dependency for something the project already has.
---
## 4. Settlement is confirmed by webhook, and only by webhook
Do not poll for settlement. Rendering an address is not payment.
Canopy POSTs to your registered endpoint with exactly three headers:
`webhook-id`, `webhook-timestamp`, `webhook-signature`.
**Verify before you parse or act.** 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.
```ts
import { Webhook, WebhookVerificationError } from "standardwebhooks";
const wh = new Webhook(process.env.CANOPY_WEBHOOK_SECRET!);
// verify() returns the ALREADY-PARSED body. Do not JSON.parse it -- that
// throws on "[object Object]" and rejects every valid delivery.
const body: unknown = wh.verify(rawBody, toVerifyHeaders(headers));
```
**Normalise the headers first — yours are the wrong shape.** `verify()` reads
them with `Object.keys()`, so it needs a plain object of string values. Express's
`req.headers` (values `string | string[] | undefined`) does not typecheck, and a
WHATWG `Headers` — what you hold in Next.js or Hono — has **no own enumerable
keys at all**, so `Object.keys()` returns `[]` and `verify()` throws
`Missing required headers` on **every valid delivery**:
```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;
}
```
Read `webhook-id` off that normalised object too — `req.headers["webhook-id"]`
is `undefined` on a `Headers`, and an idempotency key of `undefined` funds the
first order and silently swallows every one after it.
`verify()` is typed `unknown`. Do not parse its result, but do not stop at a
cast either: narrow it with a type guard, because a signature proves the bytes
came from Canopy and nothing about their shape. A verified **empty** body
returns `undefined`.
Pass the secret as the dashboard gave it to you; the library strips a leading
`whsec_` and base64-decodes the rest. Pre-stripping is harmless. Double-prefixing,
an empty secret and an unset variable each throw a **plain `Error`**, not a
`WebhookVerificationError` — construct the `Webhook` inside the handler, not at
module scope, so a missing env var is a logged 5xx rather than a boot failure.
**A secret that is merely _wrong_ throws nothing.** Any value that is still
valid base64 builds a `Webhook` with the wrong key, and every delivery then
fails as `WebhookVerificationError: No matching signature found` — which your
handler correctly answers `400` until the event is abandoned. No exception type
separates that from a real attacker. If _every_ delivery is 400ing, suspect the
secret first: log the first 8 hex of `sha256(key)` at boot and compare it with
the dashboard.
This needs the **raw request body**. If your framework parses JSON before the
handler runs, disable it for this route (`express.raw({ type: "*/*" })` mounted
before any global `express.json()` — do not narrow it to `"application/json"`,
because `express.raw` fails _open_ and leaves `req.body` `undefined` on any other
content type; `await req.text()` in a Next.js route handler; `await c.req.text()`
in Hono). Verifying a re-serialized body always fails.
`verify()` throws if no signature matches or `webhook-timestamp` is more than
five minutes out — symmetric, so a future timestamp is rejected too. A delivery
may carry several space-separated `v1,<base64>` entries, one per active signing
secret; that is how rotation works, and it accepts as soon as one matches.
**Three different things come out of that `try`, and they need three different
answers.** `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, stale timestamp. | `400`, never retry |
| `SyntaxError` | Signature **matched** — a real delivery you cannot parse. | `2xx`, record and alert |
| plain `Error` | Your own misconfiguration. | `5xx` — you want the retry |
A bare `catch { return 400 }` turns the last two into a permanent rejection loop.
### The body, and how to tell the events apart
```ts
type SettlementEventPayload = {
intentId: string | null; // nullable — do not use as your only key
inboxAddress: string;
created: false;
state: string; // the discriminator
feeUnits: string; // base units, keep as string/BigInt
netUnits: string; // base units, keep as string/BigInt
txHash: string;
chainId: number;
merchantReference: string | null; // usually your best join key
};
```
Narrow this with a real type guard before you read it — but **leave `created`
out of the guard**. It is typed as the literal `false`, and checking
`o.created === false` means any future delivery carrying `true` is routed into
your "do not fund" branch permanently. Guard the fields you actually use.
**Branch on `state` in the verified body.** None of the three headers names an
event type, and the payload has no `type` field — there is no "outer" type to
read. `state` is the discriminator. Canopy's internal catalogue names
`payment.settled` and `payment.failed`, but **neither name appears in anything
you receive**, and `payment.failed` has no producer today.
```ts
if (payload.state === "settled") {
// fund the order
} else {
// do NOT fund — record and alert
}
```
Every delivery Canopy sends today carries `state: "settled"`, and no second
value exists yet — so there is no other string to match on and no documented
vocabulary for one. Write the `else` branch anyway and make it **refuse to
fund**. Do not write `if (state === "failed")` either: a future value that is
not the one you guessed leaves the order stuck in `pending` forever. Defaulting
to "fund on any verified delivery" is correct only by accident right now and
becomes a money bug the moment a non-settled event ships.
Never `Number()` `feeUnits` or `netUnits` into your ledger — that loses
precision above 2^53. Keep them strings or `BigInt`.
### The four rules
1. **Idempotently, in one statement.** Key on `webhook-id`, stable across
retries. Make the unique index itself the test —
`INSERT ... ON CONFLICT (event_id) DO NOTHING RETURNING event_id`, no row
means duplicate. A separate `alreadyProcessed()` read followed by a write
leaves a window where two concurrent retries both pass and both fund, which
is exactly what a retry storm produces.
2. **Order is best-effort.** A terminal event can arrive first. Every event
carries full state precisely so you can act on it alone.
3. **Durable write, then acknowledge.** Any status outside `2xx` — and any
timeout; you get **10 seconds** — is a failed delivery and is retried. Write
first, process asynchronously. On a serverless handler nothing after the
return is guaranteed to run, so there the insert must be all of your
synchronous work and processing belongs on a queue or a draining worker.
4. **Do not 5xx a payload you dislike.** A verified-but-unexpected body is your
bug, not a delivery failure: record it, alert, acknowledge. Return non-`2xx`
only when you genuinely want a retry (e.g. your database was briefly down).
A failed **signature** check is not that — return `400`.
Retries walk a fixed backoff of **0s, 5s, 30s, 5m, 30m, 2h, 10h** — seven
attempts over about twelve hours, after which the event is **abandoned**. Your
endpoint is not disabled and nothing re-drives the event later, so a handler
that keeps rejecting loses those settlements permanently. That is the real cost
of rule 4.
A verified event for an order you cannot find is case 4: record the delivery id,
alert, and acknowledge. Retrying will not make the order appear.
---
## 5. Failures you will actually hit
| Code | Status | Meaning |
| --------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `unauthorized` | 401 | Missing/invalid bearer token. |
| `insufficient_scope` | 403 | Key lacks `payment_intents:create`. Human reissues. |
| `invalid_request` | 400 | Validation failed. `error.param` may be empty or absent. |
| `invalid_json` | 400 | Body is not JSON. |
| `unsupported_version` | 400 | Unknown `Canopy-Version`. |
| `idempotency_key_reuse` | 409 | The named payout wallet already has an active intent with different terms. Settle/archive it, or use another wallet. |
| `rate_limited` | 429 | Back off; read `ratelimit-reset`. |
| `not_found` | 404 | Unknown intent, or one owned by another account. |
| `invalid_request` (`param: "metadata"`) | 400 | `metadata` broke its budget (≤16 keys, keys ≤64 chars, values ≤256 chars, ≤2048 bytes total). This — not `413` — is what oversize metadata returns. |
| `payload_too_large` | 413 | The **whole request body** is over the platform limit. Not a `metadata` problem; check your other string fields. Carries no rate-limit headers. |
| `internal_error` | 5xx | Retry with backoff — but see the note below on `merchant_routes_unprovisioned`. |
| `customer_not_bound` | 503 | Retryable. `Retry-After` may be absent — use your own backoff. |
| `source_inbox_unavailable` | 503 | Retryable, transient. `Retry-After` may be absent. |
| `source_inbox_not_active` | 503 | Not retryable. Offer a different origin. |
| `source_inbox_refused` | 500 | Not retryable. Contact support with the `request_id`. |
Note that `invalid_request` does double duty: it is both a body-validation
failure and — on `/source-inbox` — the ordinary "this origin is not accepting
deposits" refusal. See 2.4.
Apply the retry policy in section 3: decide on `error.code`, not on status.
Two of the `5xx`/`503` rows above are **not** retryable, and no status code
tells you which. Never retry a `4xx` unchanged.
**`merchant_routes_unprovisioned` is a `message`, not a `code`.** The response
is `{"error":{"code":"internal_error","message":"merchant_routes_unprovisioned",…}}`.
It means the account is not finished onboarding, and no amount of retrying will
change that — but since you must not branch on `message`, do not try to special-
case it. Treat it as a retryable `internal_error`, let your backoff cap out, and
make sure the `message` and `request_id` reach a human. This is the one place
where the rules leave you with backoff instead of a clean answer, and knowing
that in advance is better than discovering it in production.
---
## 6. Prove it works before you report done
Run these and paste the real output. Do not claim success on unrun code.
1. **Create** — assert `201`, non-empty `inboxAddress`, and a non-empty
`widgetToken`.
2. **Wallet get-or-create** — repeat the identical call with the same
`payoutWallet`. Assert the same `intentId`, `created: false`, the status is
`201` again (not `200`), and that `widgetToken` is present but **differs**
from step 1's — the token rotates on every get, and the old one is dead.
3. **Read back** — `GET /api/v1/intents/{id}`. Assert the same `inboxAddress`.
4. **Origins** — assert `origins` is an array; print each `name` /
`namespace` / `chainId` / `token.symbol` / `minimumDisplay` /
`depositIssuable`, and print `sourceDepositIssuable`. Assert every option
stays **selectable** when `sourceDepositIssuable` is false but its own
`depositIssuable` is true — gating the picker on the intent-level flag is a
fail. Assert the Solana entry is dispatched by `namespace`, not `chainId`
(which is `null`).
5. **Source inbox** — call `POST /api/v1/intents/{id}/source-inbox` for one EVM
origin **and one Solana origin**; their refusal shapes differ (2.4) and
testing only EVM hides the `503 source_inbox_not_active` path entirely. On `201`, assert `address` is present and that calling it again
returns the **same** address. On a refusal, assert you classified it by
`error.code` and did not retry a not-retryable one. Either outcome is a
pass; silently retrying `source_inbox_not_active` is a fail.
6. **The right address** — assert your checkout renders the address returned by
`source-inbox` for the origin the user chose, and reaches `inboxAddress`
only on an explicit settle-chain path. **Do not assert the two are
different**: on EVM origins they are currently the same string, so an
inequality assertion fails against correct code. Assert instead that the
value you render came _from the `source-inbox` response_, and that a Solana
origin renders the Solana-format address rather than the `0x` one.
7. **Pagination terminates** — page the list to the end. Assert the loop stops
on `has_more: false` even though `next_cursor` is still non-null.
8. **Auth is enforced** — repeat step 1 with the key removed. Assert `401` and
`error.code === "unauthorized"`.
9. **Non-JSON error survives** — request a nonsense `/api/v1/...` path. Assert
your client raises a clean error rather than a `TypeError`.
10. **Webhook handler** — unit-test it: a correctly signed body is accepted; a
tampered signature is rejected; a stale timestamp is rejected; a replayed
`webhook-id` does not double-fund, _including when the two arrive
concurrently_; a non-`settled` `state` does **not** fund; a signed body that
is not valid JSON is acknowledged rather than rejected; and verification
succeeds when the headers arrive as a WHATWG `Headers` as well as an Express
`req.headers`. Sign fixtures with the package's own `wh.sign(id, date,
body)`, which returns `"v1,<base64>"` and takes a `Date` while the
`webhook-timestamp` header is Unix **seconds** — derive one from the other.
If Canopy has not issued a real `whsec_` yet, generate one locally
(`"whsec_" + 32 random bytes, base64`) and run these against it. The
handler is the same one that receives live Canopy deliveries once the
secret is in env.
11. **No key leak** — a headless integration serves nothing to a browser, so
grep your whole source tree and any rendered output for the literal key
value and for `cnpy_sk_live`. Assert zero matches outside the environment
it is read from.
Then report: what you built, the commands you ran, their real output, and
anything you could not finish because a credential was still missing.