API conventions
The generated reference describes every endpoint, every field and every schema. It does not explain the six things that apply to all of them. Those are here.
Base URLs
Edge (yours, local) http://localhost:8787/v1
Cloud (SaaS) https://api.chainos.cloud/v1
The paths are identical, deliberately. An integration can be pointed at either without a code change. In practice you want the Edge: it is the endpoint your application holds a credential for, and it keeps your Ziklag key out of your application entirely.
The envelope
Every response, success or failure, is wrapped.
{
"success": true,
"data": { },
"meta": { "requestId": "01J8XK…", "timestamp": "2026-08-19T10:32:00Z" }
}
{
"success": false,
"data": null,
"error": {
"code": "ADDRESS_LIMIT_EXCEEDED",
"message": "Capacity band 'launch' permits 1000 active addresses.",
"details": { "band": "launch", "limit": 1000, "current": 1000 }
},
"meta": { "requestId": "01J8XK…", "timestamp": "2026-08-19T10:32:00Z" }
}
Branch on error.code, never on error.message. The code is part of the contract; the
message is written for a human reading a log and will be reworded.
meta.requestId is worth logging on every call. It is the only thing that lets support
find your request on our side.
Authentication
There are three credential planes and they are not interchangeable.
| Credential | Used by | Against | Header |
|---|---|---|---|
zkl_live_* / zkl_test_* | Your banking application | Your own Edge | X-API-Key |
flk_live_* / flk_test_* | Your Edge | Cloud | X-API-Key |
| JWT bearer | The console | Cloud | Authorization: Bearer |
Your application should only ever hold the first. zkl_* keys are minted by your Edge
(POST /v1/edge/keys), shown once, revocable individually, and propagated to every Edge
replica within one heartbeat.
The Edge additionally signs every request to Cloud with an Ed25519 key derived from your mnemonic. That signature — not the API key — is what pins your identity; see Identity rotation.
The prefix determines the environment, at both the Edge and Cloud, immutably for the process lifetime. A test key cannot produce live data and a live key cannot reach the sandbox endpoints.
Enums are lowercase slugs
Chains, environments, readiness states and webhook event names all serialise as the lowercase slug that appears in URLs, and they are accepted in the same form.
{ "chain": "eth", "environment": "live", "status": "active" }
This matters more than it looks: it means a response can be round-tripped straight back
into a request without transforming it. An earlier revision accepted "eth" and returned
"ETH", which quietly forced every client to carry a mapping layer.
The ten chain slugs are btc, eth, bsc, polygon, avax, base, tron, sol, xrp, xlm.
Money is a string, in the smallest unit
{ "amount": "1000000", "amountFormatted": "1.00", "decimals": 6 }
amount is authoritative and is the integer count of the smallest unit — wei, satoshi,
drops, lamports, sun. It is a string because 10^18 does not survive a JavaScript
Number, and a rounded balance in a fintech console is not a cosmetic defect.
amountFormatted is a convenience for display. Do not do arithmetic on it. Nothing in
ChainOS parses a monetary value into a floating-point type, and neither should your
integration — use your language's arbitrary-precision integer or decimal type.
Errors
| HTTP | Code | Meaning |
|---|---|---|
| 400 | INVALID_INPUT | Malformed or missing field |
| 400 | INVALID_CHAIN | Unsupported chain slug |
| 400 | INVALID_ADDRESS | Fails that chain's format validation |
| 400 | INVALID_AMOUNT | Zero, negative, or exceeds balance |
| 400 | KEY_MATERIAL_REJECTED | The request looked like it contained a recovery phrase or a private key, and was refused before anything read it. See Migration |
| 400 | KEY_SOURCE_MISMATCH | An Edge registered a wallet whose addresses do not derive from the extended public key sent with them. Nothing was written; the detail names the first index that disagreed |
| 401 | UNAUTHORIZED | Missing, invalid or revoked key |
| 401 | EDGE_SIGNATURE_INVALID | Ed25519 verification failed |
| 401 | EDGE_SIGNATURE_SKEW | Timestamp outside the 300-second window |
| 401 | EDGE_NONCE_REPLAY | Nonce already seen |
| 403 | TOTP_REQUIRED | The session has no second factor, or none recent enough. Re-authenticate through the identity provider — the portal does this with POST /v1/auth/step-up. |
| 403 | NO_SUBSCRIPTION | No active subscription |
| 403 | CHAIN_NOT_READY | Readiness red or amber — see Edge liveness |
| 403 | ADDRESS_LIMIT_EXCEEDED | Band capacity reached |
| 403 | TIER_FEATURE_LOCKED | Feature requires Premium or Ultimate |
| 404 | NOT_FOUND | Resource does not exist |
| 409 | ALREADY_ENROLLED | A full Edge sync would register nothing new |
| 409 | RESYNC_REQUIRED | Cloud has no enrolment for this Edge |
| 409 | IDENTITY_ROTATION_PENDING | Edge identity changed, awaiting owner approval |
| 409 | CROSS_ENVIRONMENT_IDENTITY | Same mnemonic seen in both live and sandbox |
| 409 | IDEMPOTENCY_CONFLICT | Key reused with a different body |
| 409 | LEASE_HELD | Another Edge session holds the leader lease |
| 409 | LEASE_EPOCH_STALE | Frame carries an epoch below current — stream closed |
| 409 | DRY_RUN_STALE | An import commit quoted a dry run that is no longer current |
| 409 | IMPORT_NOT_READY | The import batch is not in a state this operation can act on |
| 409 | ADDRESS_CONFLICT | Another ChainOS account already claims one of these addresses |
| 409 | ROLLBACK_WINDOW_CLOSED | Past 72 hours, or a transaction has landed |
| 409 | KEY_SOURCE_PURGED | The key material behind this wallet is gone from your Edge. Its addresses are watch-only and still monitored |
| 422 | CHAIN_REJECTED | The node rejected the transaction |
| 422 | ADDRESS_WATCH_ONLY | No key can sign for this address. Monitored and credited; withdrawals refused |
| 422 | IMPORT_TOO_LARGE | Above the self-service import ceiling. Not refused — a Ziklag engineer will run it with you |
| 423 | EDGE_OFFLINE | Operation requires a live Edge |
| 423 | EDGE_NO_LEADER | Replicas live but none holds the lease |
| 423 | ADDRESS_IN_OBSERVE_MODE | The address belongs to a migration project that has not cut over |
| 429 | RATE_LIMIT_EXCEEDED | Fair-use limit for your band |
| 503 | EDGE_NOT_SYNCED | Edge is up but Cloud has not confirmed its registration |
| 503 | UPSTREAM_UNAVAILABLE | Cloud unreachable from the Edge |
| 503 | CHAIN_UNAVAILABLE | Chain RPC down or not configured |
| 507 | EDGE_OUTBOX_FULL | The Edge's local queue is at capacity |
Five of these deserve a note.
CHAIN_UNAVAILABLE is never reported as a zero balance. A confident zero is
indistinguishable from an emptied wallet, so an unreachable chain returns an error rather
than a number.
The three insufficient-funds codes are separate on purpose, because they have three
different remedies: INSUFFICIENT_FUNDS, INSUFFICIENT_GAS and
INSUFFICIENT_RESERVE. See Withdrawals.
EDGE_SIGNATURE_INVALID and IDENTITY_ROTATION_PENDING are security events, not
transient failures. Do not retry them; escalate. See
Identity rotation.
ADDRESS_WATCH_ONLY and ADDRESS_IN_OBSERVE_MODE are different refusals of the same
request, and they are kept apart because their remedies have nothing in common. Watch-only
is about the key and may be permanent; observe mode is about the project and ends when
somebody clicks cut over. One code for the two would answer "register your keys" to a
customer who has them and is deliberately still comparing. See
Migration.
KEY_SOURCE_MISMATCH is a 400 rather than a 422, because the two halves of the request
contradict each other and no state on our side would make them agree. Nothing is written: a
partially recorded source would claim to cover addresses that were never checked against its
key, and you would find out when a withdrawal was accepted here and refused at your own Edge.
The index in the detail is the useful part — index 0 usually means the base path is wrong
for the wallet, and a mismatch deep into the range usually means the key and the address list
came from different wallets.
KEY_SOURCE_PURGED is a 409 and not a 404. The source is still there, still named, and
still the record of what those addresses used to be spendable through. Answering "not found"
would send an operator to register it again under a second fingerprint, which is how one
wallet becomes two rows nobody can tell apart.
DRY_RUN_STALE is not a malformed request. Nothing about it is wrong — the world moved.
Re-validating an import mints a new dryRunId, so a commit quoting the previous one is
committing against a report nobody read, describing a different set of addresses and a
different bill. Fetch the current dry run, show it, and ask again.
Pagination
List endpoints take page (zero-based) and size, and return a page envelope:
{
"success": true,
"data": {
"content": [ ],
"page": 0,
"size": 50,
"totalElements": 1284,
"totalPages": 26
}
}
Addresses and transactions also accept filters — chain, userRef, tag, status,
direction — and combining them is cheaper than paging through everything and filtering
client-side.
Rate limits
Per band, on the Cloud API:
| Band | Limit |
|---|---|
| Launch | 300 req/min |
| Growth | 1,200 req/min |
| Scale | 6,000 req/min |
| Enterprise | 20,000 req/min |
A 429 carries Retry-After. Honour it — retrying immediately in a loop is how a
temporary limit becomes a sustained one.
Requests your application makes to your own Edge are not rate limited by Ziklag. The Edge does call Cloud on your behalf, so a burst still lands on the limit eventually, but balance reads served from the Edge's view do not.
Idempotency
Send an Idempotency-Key header on any state-changing request you might retry —
address creation and transaction construction especially. Replaying the same key with the
same body returns the original response; replaying it with a different body is
409 IDEMPOTENCY_CONFLICT rather than a silent second effect.
The obligation runs the other way too: webhook delivery is at-least-once, so your
consumers must be idempotent on event.id. Idempotency covers
both directions.