Apps and keys

How authentication works here, and why an app is the unit everything else 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:

  • a mode, test or live, fixed at creation and never changeable
  • its own API keys
  • its own webhook URL and signing secret
  • its own ledger and its own balance

Why mode is a property of the app, not the key

Because it makes the dangerous mistake impossible instead of unlikely. A key cannot be pointed at the other world, a test key cannot read live payments, and the question “is this transaction real?” is answered by which app it belongs to rather than by remembering how the request was made.

Two kinds of key

App keyAccount key
ScopeOne appEvery app in your company
Can move moneyYesNo, not a franc
Can administer appsNoYes: list and create
Endpoints/v1/payments, /v1/payouts, /v1/balance/v1/apps
Where it livesThe service that takes paymentsYour provisioning script or internal tooling

The split exists because breadth and power should not travel together. The key that spans your whole company cannot spend anything; the key that can spend is confined to one app in one mode. A leak of either is bad. A leak of one key that was both would be a different order of bad.

What a key looks like

xpay_live_a3f19c2e_7Kq2mVx9RtLp4WzB8sNyHdF6jC1gQaEu
xpay_test_9c2e41b7_Qm8xTv3NpZs6YdK1wLaB5rHgJc0FuEXo

The only part worth reading is the second word. A key that says live moves real money; a key that says test cannot. The rest is a public prefix we look the record up by and a random secret.

The mode is in the string on purpose. A key pasted into a chat, a log line or a config file announces which world it belongs to without anybody having to look it up. We check that the mode in the string matches the mode on the record too, so a hand-edited key is treated as a forgery rather than a typo.

We store a hash, not the key

A key is shown once, at creation. After that we hold a SHA-256 of the secret and nothing else. Nobody at Xendly can read your key back to you, including when you ask nicely. Lose it and you revoke it and mint another. Revoking is instant and the old key is dead on the next request.

Sending it

Either header. Both work, because both are what people actually send:

Authorization: Bearer xpay_live_a3f19c2e_7Kq2mVx9…

X-Api-Key: xpay_live_a3f19c2e_7Kq2mVx9…

Over 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.

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, mode mismatch: 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 is a free enumeration of our merchants. It is less helpful to you and much less helpful to them.

A 403 is different and is worth reading: it means the key was fine but the action was not. An account key on a money endpoint, a live key on an unverified business, or a disabled app.

Housekeeping

  • One key per service, not one per company. Rotating then affects one deployment instead of all of them.
  • Rotate by overlap. Mint the new key, deploy it, confirm traffic, then revoke the old one. Both work at once, so there is no window where nothing does.
  • Watch last-used. The dashboard shows when each key last authenticated. A key nobody has used in months is a key to revoke.