/ Payouts v1 Dashboard ↗

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:

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:

bash
openssl ecparam -name prime256v1 -genkey -noout -out avvio-live.pem
openssl ec -in avvio-live.pem -pubout -out avvio-live.pub

Or from Node, with no OpenSSL:

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

bash
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:

http
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:

http
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 OK

There 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:

code
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
js
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:

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.