Skip to main content

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.

CredentialUsed byAgainstHeader
zkl_live_* / zkl_test_*Your banking applicationYour own EdgeX-API-Key
flk_live_* / flk_test_*Your EdgeCloudX-API-Key
JWT bearerThe consoleCloudAuthorization: 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

HTTPCodeMeaning
400INVALID_INPUTMalformed or missing field
400INVALID_CHAINUnsupported chain slug
400INVALID_ADDRESSFails that chain's format validation
400INVALID_AMOUNTZero, negative, or exceeds balance
400KEY_MATERIAL_REJECTEDThe request looked like it contained a recovery phrase or a private key, and was refused before anything read it. See Migration
400KEY_SOURCE_MISMATCHAn 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
401UNAUTHORIZEDMissing, invalid or revoked key
401EDGE_SIGNATURE_INVALIDEd25519 verification failed
401EDGE_SIGNATURE_SKEWTimestamp outside the 300-second window
401EDGE_NONCE_REPLAYNonce already seen
403TOTP_REQUIREDThe 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.
403NO_SUBSCRIPTIONNo active subscription
403CHAIN_NOT_READYReadiness red or amber — see Edge liveness
403ADDRESS_LIMIT_EXCEEDEDBand capacity reached
403TIER_FEATURE_LOCKEDFeature requires Premium or Ultimate
404NOT_FOUNDResource does not exist
409ALREADY_ENROLLEDA full Edge sync would register nothing new
409RESYNC_REQUIREDCloud has no enrolment for this Edge
409IDENTITY_ROTATION_PENDINGEdge identity changed, awaiting owner approval
409CROSS_ENVIRONMENT_IDENTITYSame mnemonic seen in both live and sandbox
409IDEMPOTENCY_CONFLICTKey reused with a different body
409LEASE_HELDAnother Edge session holds the leader lease
409LEASE_EPOCH_STALEFrame carries an epoch below current — stream closed
409DRY_RUN_STALEAn import commit quoted a dry run that is no longer current
409IMPORT_NOT_READYThe import batch is not in a state this operation can act on
409ADDRESS_CONFLICTAnother ChainOS account already claims one of these addresses
409ROLLBACK_WINDOW_CLOSEDPast 72 hours, or a transaction has landed
409KEY_SOURCE_PURGEDThe key material behind this wallet is gone from your Edge. Its addresses are watch-only and still monitored
422CHAIN_REJECTEDThe node rejected the transaction
422ADDRESS_WATCH_ONLYNo key can sign for this address. Monitored and credited; withdrawals refused
422IMPORT_TOO_LARGEAbove the self-service import ceiling. Not refused — a Ziklag engineer will run it with you
423EDGE_OFFLINEOperation requires a live Edge
423EDGE_NO_LEADERReplicas live but none holds the lease
423ADDRESS_IN_OBSERVE_MODEThe address belongs to a migration project that has not cut over
429RATE_LIMIT_EXCEEDEDFair-use limit for your band
503EDGE_NOT_SYNCEDEdge is up but Cloud has not confirmed its registration
503UPSTREAM_UNAVAILABLECloud unreachable from the Edge
503CHAIN_UNAVAILABLEChain RPC down or not configured
507EDGE_OUTBOX_FULLThe 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:

BandLimit
Launch300 req/min
Growth1,200 req/min
Scale6,000 req/min
Enterprise20,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.