API keys
Mint a key in the console settings. It looks likectd_… and is shown
once — Citadel keeps only its SHA-256 digest. Send it as a bearer token:
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 /walletsissues into those vaults. Withoutvault, the wallet goes to the oldest of them; avaultoutside them answers422.POST /wallets/batchissues each of its wallets the same way, and an item whosevaultis outside them, or whose reference names a wallet outside them, refuses the batch with422.- Reads of a wallet or transaction outside those vaults answer
404, as for an id that does not exist. So doPOST /walletswith a reference that already names a wallet outside them, andPATCH /wallets/{reference}andPOST /wallets/{reference}/retireof a wallet outside them. - Lists hold only what is theirs:
GET /walletsthe wallets in them,GET /transactionsthe transactions from those wallets,GET /transfersthe transfers that touch them,GET /vaultsthe vaults themselves,GET /approvalsthe approvals of transactions from those wallets, and no runs. A leg of a transfer that touches a wallet outside them readswallet: null. GET /balancestotals those vaults only; itsvaultoutside them answers404.POST /transactionsandPOST /transactions/estimatefrom a wallet outside those vaults answer403withcode: vault_limited. Nothing is created or reserved.POST /transactions/{id}/cancelandPOST /transactions/{id}/speedupof a transaction outside them answer404.POST /events/{name},GET /workflows,GET /runs,GET /runs/{id}andPOST /runs/{id}/cancelanswer403withcode: 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}andPOST /policy/publishanswer403withcode: vault_limited: one policy document rules every vault./webhook_endpoints,GET /webhook_deliveriesand the webhook retries answer403withcode: vault_limited: a webhook endpoint serves every vault.POST /reports,GET /reports/{id}andGET /reports/{id}/fileanswer403withcode: vault_limited: the organization-wide reports cover every vault, and a wallet statement names the other wallets of the organization it dealt with.
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 answers403 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 answers401, 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.
Request signatures
For transactions, sign the request with your key A (Ed25519) and send the signature inX-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".
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 answers404, never 403.