Credentials
Canopy issues two kinds of credential. No type system catches you using the wrong one; you get a plausible-looking failure instead.
This guide is where the quickstart's get-a-secret-key step sends you for the full picture.
The two credentials
It never reaches a browser, a client bundle, a log line, or a URL. Anything less strict is a leak, not a convenience.
- Secret key (
cnpy_sk_live_...): server-side only, never sent to a browser. It authenticates every/api/v1/*request as anAuthorization: Bearertoken, and it is the credential that creates intents and lists them. - Publishable key (
cnpy_pk_live_...): safe in a browser, meant to be visible in your page's source. Pass it as themerchantoption to the checkout SDK'smount(). It cannot authenticate a REST API call: the API refuses it withunauthorized, and the loader refuses to mount unlessmerchantstarts with this exact prefix.
A newly issued secret key carries two scopes, not one: payment_intents:create, which every documented /api/v1 route requires, and customer_deposits:manage, which no documented /api/v1 route names. The second is not decorative: it is the scope Canopy's payout-route endpoints check, so a key carrying it can read and request changes to your payout route, and mint a payout nonce.
Changing where your money lands still requires a signature from the payout wallet itself, which an API key alone cannot produce. If you do not need that scope, issue your key with payment_intents:create alone.
There is no third credential to issue. To mount checkout you pass the SDK an intentId -- the id your own POST /api/v1/intents call returned.
Every detail of the checkout resolves server-side from Canopy's own records, so there is nothing else to fetch or forward. But the id itself is sensitive: see the intent id below.
The intent id
An intent id is a bearer-grade handle to one order. Anyone holding it can open that checkout and read that order's status and deposit history. It is not an inert reference number.
/customer-pay takes no Authorization header and no cookie. It authenticates the payer by the fact that they were given the id -- the same trade a hosted /pay/{id} link makes. That is deliberate: the payer is your user, not a Canopy account, and asking them to authenticate would be asking them to sign up.
Holding an intent id lets someone:
- open the checkout for that one intent;
- read that intent's status and its deposit history -- the amounts and chains that order has been funded from;
- pay it.
It does not let them:
- redirect the funds. The destination resolves from Canopy's own storage off the intent row, never from anything on the request;
- change the platform fee or the treasury. Both resolve from on-chain route storage and are never arguments to a settlement;
- reach any other intent, or any other customer of yours. A resolved session covers that intent's own deposit customer and no other;
- authenticate an
/api/v1/*call. That still needs your secret key.
Your publishable key narrows this, but does not make the id public. /customer-pay refuses unless the merchant key on the URL belongs to the account that owns the intent, so another merchant cannot mount your checkout under their own registered embed origin.
A publishable key is public by construction -- it is meant to be visible in your page's source -- so it scopes an intent id to your account. It does not keep one secret.
So handle an intent id the way you would handle a password reset link:
- do not ship it to third-party logging, analytics or error-reporting systems;
- do not put it in a URL you share, bookmark, or paste into a support ticket;
- do not use it as the human-readable order reference in tooling other people can read. Use your own order id for that, and keep the mapping on your server.
If an intent id does leak, archive the intent (POST /api/v1/intents creates a fresh one) -- an archived intent resolves to the same refusal as an id that never existed.
Which one goes where
| Credential | Lives in | Never appears in |
|---|---|---|
| Secret key | Your server's environment/secrets store | A browser, a client bundle, a log line, a URL |
| Publishable key | Your page's source, the mount() call | An Authorization header |
| Intent id | Your mount() call, as intentId | An Authorization header, a URL you share, a third-party log |
Why this is not enforced by types
Both credentials are ordinary strings. Nothing in TypeScript stops you from passing a secret key where merchant is expected, or a publishable key as a bearer token. A prefix check catches both at runtime, and both produce a response that reads like an unrelated bug unless you are already looking for a swapped credential.
- A secret key passed as
merchanttomount()is refused by the loader, with aninvalid_merchant_keyerror, before anything is built from it. It is also the worst way to leak a secret key: anything passed tomount()is visible in your page's own source. - A publishable key passed as the bearer token on a REST call lands on the unauthorized error page. It authenticates as nothing, because the API accepts only a secret key.
When either of these errors seems to appear from nowhere, check which credential the failing call actually used before looking anywhere else.
See also
- Quickstart: the full walkthrough this guide hangs off.
- API reference: every operation, with the scope each credential authorizes.