Apps and keys
One app, one key, one switch. How authentication works here and why an app is the unit everything hangs off.
An app is one integration
Not one company, not one product line. One integration. Your storefront checkout is an app. The nightly job that pays your drivers is an app. Each one owns:
- one API key, created with the app
- a mode,
testorlive, that you change with a button - its own webhook URL and signing secret
- its own ledger and its own balance
One key, and it never changes when you go live
xpay_a3f19c2e_••••••••••••••••••••••••••••••••
│ │ └── secret 32 random characters. Never leaves your server.
│ └─────────── prefix Public. How we find the record to check against.
└──────────────── product So a key found in a log is identifiable as ours.Three parts, and no fourth. There is no mode in it, because the mode is not the key’s: it is the app’s, and you move it in the dashboard.
Yours is on your app’s page in the dashboard, once, when the app is created. The one above is a shape, not a key: the secret half is blanked out because a real one has no business being in a document anybody can read.
So going live is not a deploy. You build the integration with this key, you press Switch to live on the app once your business is verified, and the exact same key starts moving real money. Nothing in your code, your config or your secret manager changes.
What this replaced, and why
xpay_test_… and xpay_live_…, and one of them always sat in the dashboard labelled “not in use”. The idea was that a key announcing its own mode makes the expensive mistake visible. What it actually produced was a failed deploy whose cause you had to reason about, and a go-live that meant swapping a production secret at the exact moment you could least afford to get it wrong. There is one key now. There is nothing to pick wrong.We store a hash, not the key
Test mode and live mode
| Test | Live | |
|---|---|---|
| Payments are | Real prompts, real money | Real prompts, real money |
| Who you can charge | Only numbers on your test list | Anybody |
| Books | Separate ledger and balance | Your real ledger |
| Withdrawable | No | Yes, once you are verified |
| Needs verification | No | Yes |
Test mode is not a sandbox
Sending it
Either header. Both work, because both are what people actually send:
Authorization: Bearer xpay_a3f19c2e_YOUR_SECRET
X-Api-Key: xpay_a3f19c2e_YOUR_SECRETOver HTTPS, from your server. Never from a browser, a mobile app, or anything else a user can open the developer tools on. A key that reaches a client device is a key that has been published. That is what the payment widget exists for: it hands the browser a session token that is good for one payment of one amount, and never your key.
Every rejection looks the same
401 Unauthorized
{
"status": "error",
"message": "Invalid API key",
"timestamp": "2026-09-12T09:14:02.000Z"
}Malformed, unknown, revoked, wrong secret: one message for all of them. Distinguishing them would turn the endpoint into an oracle. “Invalid” versus “revoked” tells somebody guessing prefixes that they found a real one, which hands them a list of our merchants. It is less helpful to you and much less helpful to them.
A 403 is different and worth reading: the key was fine but the action was not. A live app on an unverified business, or a disabled app.
Housekeeping
- One app per service, not one per company. Rotating then affects one deployment instead of all of them.
- Rotating is immediate. The new key exists and the old one stops working in the same instant, so copy it somewhere before you press the button. That is deliberate: the reason to rotate is usually that a key might have leaked, and an overlap where both still work is exactly what you do not want then.
- Watch last-used. The app’s page shows when its key last authenticated. A key nobody has used in months belongs to an app you can delete.