Authentication
Every request is signed. You generate a P-256 key pair, register the public half with us, and keep the private half — it never crosses the wire, so a copy of our entire database is not enough to move your money.
This is the model Lightspark's Grid uses, and it is stronger than an API key in two specific ways:
A stolen credential is useless. The header names your key; it does not authorize anything. Without your private key it moves nothing.
A captured request cannot be edited. The signature covers the method, the path, the body and the idempotency key, so a request intercepted in flight cannot be replayed against a different amount or a different beneficiary.
Getting a key#
Issue it in the dashboard. Your browser generates the pair locally and sends us only the public half — we never receive your private key, at any point.
You get back a key id (akid_live_… or akid_test_…) and a .pem. Store the PEM the way you store a database password. If you lose it, rotate; we cannot recover it, because we never had it.
Or generate it yourself, where it will actually be used#
The dashboard flow is the fastest, but it does mean your private key is created in a browser tab — an environment that also contains your extensions, your clipboard and your downloads folder.
If you would rather it never touch a browser, generate the pair on the machine that will hold it and register only the public half. This is the same convention as an SSH key or a TLS CSR, and it is the option we would pick for a production credential:
openssl ecparam -name prime256v1 -genkey -noout -out avvio-live.pem
openssl ec -in avvio-live.pem -pubout -out avvio-live.pubOr from Node, with no OpenSSL:
const { generateSigningKeyPair } = require('@avvio/payments');
const { publicKeyPem, privateKeyPem } = generateSigningKeyPair();Then paste avvio-live.pub into the dashboard when you issue the key. We accept any P-256 SPKI public key; where it came from is your business.
We validate it at registration rather than at your first payout — a wrong curve or the wrong half of the pair is a typo to fix in the dashboard, not an outage at 3am. If you paste a private key by mistake we refuse it and tell you to rotate, because at that point you have handed it to us.
export AVVIO_API_KEY=akid_live_…
export AVVIO_PRIVATE_KEY="$(cat avvio-live.pem)"The easy path: let us tell you what to sign#
You do not have to build a canonical string to get started. Send your request without a signature and we answer 202 with the exact bytes to sign:
POST /payments/organizations/org_123/payouts
X-Api-Key: akid_live_…
202 Accepted
{
"type": "SIGNATURE_CHALLENGE",
"requestId": "chal_9f2…",
"payloadToSign": "…"
}Sign payloadToSign, then send the same request again with the signature and the request id:
POST /payments/organizations/org_123/payouts
X-Api-Key: akid_live_…
X-Anzo-Request-Id: chal_9f2…
X-Anzo-Signature: <base64 ECDSA-SHA256 of payloadToSign>
200 OKThere is nothing to construct and nothing to get wrong. The challenge is bound to the method, path and body it was issued for — one minted for a balance read cannot be spent on a payout — it is single-use, and it expires in five minutes.
The 202 is not an authenticated response. Nothing ran, no money moved, and no handler was reached. It is a question, not a result.
The direct path: sign it yourself#
One extra round trip per request is a real cost at volume. Skip it by building the string yourself — seven fields, newline-separated, in this exact order:
keyId
METHOD
/path?including=query
sha256(body) as lowercase hex
idempotencyKey (empty string if you did not send one)
timestamp (Unix seconds)
nonce (unique per request)Sign with ECDSA P-256 over SHA-256, DER-encoded, then base64. Send:
| Header | Value |
|---|---|
X-Api-Key |
Your key id |
X-Anzo-Signature |
The base64 signature |
X-Anzo-Timestamp |
The same Unix seconds you signed |
X-Anzo-Nonce |
The same nonce you signed — any unique string; a UUID is fine |
const canonical = [
keyId,
'POST',
path,
sha256Hex(body),
idempotencyKey ?? '',
timestamp,
nonce,
].join('\n');
const signature = crypto
.createSign('sha256')
.update(canonical)
.end()
.sign(privateKey)
.toString('base64');Three things that are easy to get wrong, and each produces the same SIGNATURE_INVALID:
Sign the exact bytes you send. Serialize the body once, sign that string, send that string. Re-serializing after signing changes it.
Sign the full request path, including the API prefix and query string. For the published base URL, a payout path begins
/business/api/v1/payments/...; signing only/payments/...will not match the bytes the server verifies.Send the same timestamp and nonce you signed. Regenerating them for the header is the classic mistake.
The avvio CLI and the Node SDK do all of this for you.
If you already hold keys with us through Turnkey#
Register your Turnkey public key instead of a PEM and authenticate with a Turnkey stamp. A key registers one or the other, never both.
This is convenient — one credential system instead of two, and rotation is something your team already does. But be clear about what it is and is not: Turnkey's enclave holds your wallet keys, and the stamping key is a separate credential your client holds. A stamp is exactly as safe as wherever you put that key.
If you use the plain API-key stamper and keep the key in an environment variable, that is comparable to the PEM path above, not stronger than it.
The strongest option: a key that cannot be copied#
The property worth optimizing for is not who issued the key. It is whether the private half can be extracted by code running alongside it:
| Extractable | |
|---|---|
| Passkey / platform authenticator | No — hardware-backed |
Turnkey indexed-db-stamper |
No — generated non-exportable via SubtleCrypto |
Turnkey api-key-stamper |
Yes — it is a value you stored somewhere |
| A PEM, however you generated it | Yes — you hold the file |
A non-extractable key can be _used_ by the page but never _copied_ off it, which closes the whole category of attacks where a script, an extension or a clipboard manager walks away with your credential.
Note that our dashboard flow cannot reach that tier by construction: WebCrypto must mark the key extractable for us to hand it to you, and a key you can keep is a key that can be copied. That is the trade the convenience buys, and it is why a production credential is better generated on the server that will use it.
Freshness and replay#
Your timestamp must be within 5 minutes of ours. A signature outside that window is refused — almost always NTP drift on the sending machine, not a bad key.
Each nonce may be used once. Sending the identical signed request twice returns SIGNATURE_REPLAYED, and the second one does not execute.
Idempotency-Key does not provide this. You choose that value, and so can anyone who captured your request — which is why it is signed but not trusted as a replay defence. It remains the right tool for _your_ retries; see Errors.
Expiry, rotation, and pinning#
Keys expire. One year by default, two at most. The expiry is visible in the dashboard from the day the key is issued.
Rotation overlaps. POST /api-keys/{id}/rotate issues a successor and gives the predecessor a deadline — 24 hours by default. Deploy the new key, confirm traffic has moved, and let the old one lapse. There is no window where neither works, which is the reason rotation that cuts immediately never actually gets performed.
You can pin a key to source addresses. Requests from anywhere else are refused rather than silently allowed — an allowlist that quietly does nothing is worse than none, because it is believed.
Errors#
type |
Meaning |
|---|---|
SIGNATURE_CHALLENGE |
202. Sign payloadToSign and retry |
SIGNATURE_REQUIRED |
Send the signature headers |
SIGNATURE_INVALID |
The signature does not match this request |
SIGNATURE_REPLAYED |
Already received. Nothing ran twice |
SIGNATURE_TIMESTAMP_SKEW |
Your clock is more than 5 minutes from ours |
KEY_EXPIRED |
Rotate — the successor overlaps the predecessor |
KEY_IP_NOT_ALLOWED |
This key is pinned, and this request came from elsewhere |
SIGNATURE_INVALID is deliberately one answer for several causes. Telling them apart would only help someone guessing. Every error type is listed with whether your money moved and whether retrying is safe.
What a key cannot do#
A key can move money. It deliberately cannot accept provider terms, manage your team, or issue further keys — those stay human actions in the dashboard. Read-only keys are available if you want a reconciliation service that cannot spend.