API keys

Mint a key in the console settings. It looks like ctd_… and is shown once — Citadel keeps only its SHA-256 digest. Send it as a bearer token:
A missing, unknown, revoked or expired key answers 401 {"error": "unauthorized"}. An organization can hold many keys; revoke one without touching the others. GET /me shows what a key is: the organization it acts for, its scopes, vaults, IP allowlist and expiry, and the address your call arrived from. Call it first when you wire up an integration.

What a key may do

A key is narrowed when you mint it, and never widened after: its scopes, its vaults, the addresses it may call from, and its lifetime. To change any of them, mint a new key and revoke the old one.

Scopes

Every endpoint needs one scope: A write scope does not include read. A key that should read back what it wrote needs read too. A call the key has no scope for answers 403 with code: scope_missing.

Vaults

A key can be limited to some of your vaults. It then works only with wallets in them:
  • POST /wallets issues into those vaults. Without vault, the wallet goes to the oldest of them; a vault outside them answers 422. POST /wallets/batch issues each of its wallets the same way, and an item whose vault is outside them, or whose reference names a wallet outside them, refuses the batch with 422.
  • Reads of a wallet or transaction outside those vaults answer 404, as for an id that does not exist. So do POST /wallets with a reference that already names a wallet outside them, and PATCH /wallets/{reference} and POST /wallets/{reference}/retire of a wallet outside them.
  • Lists hold only what is theirs: GET /wallets the wallets in them, GET /transactions the transactions from those wallets, GET /transfers the transfers that touch them, GET /vaults the vaults themselves, GET /approvals the approvals of transactions from those wallets, and no runs. A leg of a transfer that touches a wallet outside them reads wallet: null.
  • GET /balances totals those vaults only; its vault outside them answers 404.
  • POST /transactions and POST /transactions/estimate from a wallet outside those vaults answer 403 with code: vault_limited. Nothing is created or reserved. POST /transactions/{id}/cancel and POST /transactions/{id}/speedup of a transaction outside them answer 404.
  • POST /events/{name}, GET /workflows, GET /runs, GET /runs/{id} and POST /runs/{id}/cancel answer 403 with code: vault_limited: a workflow chooses its own wallets, so a key limited to vaults cannot drive one.
  • GET /policy, POST /policy/drafts, GET /policy/drafts/{version} and POST /policy/publish answer 403 with code: vault_limited: one policy document rules every vault.
  • /webhook_endpoints, GET /webhook_deliveries and the webhook retries answer 403 with code: vault_limited: a webhook endpoint serves every vault.
  • POST /reports, GET /reports/{id} and GET /reports/{id}/file answer 403 with code: vault_limited: the organization-wide reports cover every vault, and a wallet statement names the other wallets of the organization it dealt with.
A key without vault limits reaches every vault of your organization.

IP allowlist

A key can be limited to the addresses your backend calls from: single IPv4 or IPv6 addresses or CIDR networks. A call from any other address answers 403 with code: ip_not_allowed. An empty list allows any address. Citadel checks the address the request arrives from. List the public egress addresses of your backend, as the internet sees them, not addresses inside your network.

Lifetime

A key lives 30, 90 or 365 days, or has no expiry. After its expiry it answers 401, the same as a revoked key. Mint the replacement before then: both keys work side by side until you switch.

Order of checks

Before anything else, Citadel checks the key (401), then the address (403 ip_not_allowed), then the scope (403 scope_missing), and stops at the first refusal. The vault limit is checked with the request itself. A refused request creates and reserves nothing.
An API key authenticates the caller. It does not authorize funds. A stolen key can submit a transaction, but your policy engine refuses anything your policy does not allow, and your approval rules can hold it for people.

Request signatures

For transactions, sign the request with your key A (Ed25519) and send the signature in X-Citadel-Request-Signature, as the 64 signature bytes in standard, padded base64. Citadel stores the signed bytes verbatim with the operation and hands them to your policy engine, which verifies the signature before anything is signed. The signed bytes are the JCS (RFC 8785) form of exactly these request fields, absent ones omitted: In a signed request, name wallet by its id or address, not by its reference. Your policy engine knows a wallet by id and address only, so it cannot confirm that a reference names the wallet the payout leaves from. A signed request that names its wallet by reference is refused with 422. Your signature must bind who is paid and how much. In a signed request every transfer and approve action states to and amount, every swap states to, amount, token_out and min_amount_out, and every call states method; your policy engine refuses one that does not, and any other action type. A field you leave out means “none”, not “any”: no asset is the native coin, no contract is a chain-native call, no args is no arguments, no value is "0".
Sign locally, with your own canonicalizer. Never sign bytes the platform hands you. A header that is not base64 of 64 bytes (hex, URL-safe base64, a truncated value) is refused with 422 and code: invalid_request_signature; nothing is created or reserved.

Your organization only

A key acts for its organization only. Every id you read or write is scoped to it: another organization’s id answers 404, never 403.