ZYCORD docs
ZycordDocsRPC API reference

RPC API reference

Every route the node's HTTP API answers, with its parameters and the fields it returns. Read alongside Running a node, which covers how to run zycordd; an exchange listing ZCD should also read Exchange integration.

Overview#

The API is plain HTTP and JSON, not JSON-RPC: each route is a path, reads are GET with query parameters, and the four writes are POST with a hex body. There is no envelope and no method name in the body.

curl -s http://127.0.0.1:9420/status
SettingValue
Default address127.0.0.1:9420, on mainnet and on the testnet alike
Change itzycordd --rpc ADDR
Turn it offzycordd --no-rpc
AuthenticationNone. The node holds no key, and no route grants authority
Timeouts5 s to read the request headers, 15 s to read the request, 15 s to write the answer

A mainnet node and a testnet node on the same machine both default to port 9420, so give one of them --rpc 127.0.0.1:PORT. The P2P ports do differ (9421 on mainnet, 19421 on the testnet, the ports the seeds listen on), and a node accepts connections only with --listen.

Conventions#

Hex values#

Every hash, id and address the API returns is 0x followed by 64 lowercase hex characters. Inputs take the same thing with or without the 0x prefix, in either case, and must decode to exactly 32 bytes; anything else is 400 "expected a 32-byte hex value".

Amounts#

Every amount is a decimal string in drops, never a JSON number: balances are 256-bit integers, and a JSON number loses precision long before that. Parse them with a big-integer type.

UnitDrops
1 ZCD100000000 (eight decimals)
1 drop1, the smallest unit

Issued assets carry their own decimals; their amounts are still integers in the asset's smallest unit.

The tip object#

State reads say which block they answered from, so a caller can tell two answers from two different tips apart:

"tip": {"id": "0x…", "height": 12345}

Every state read answers from the current tip only. There is no historical state and no pending view: a balance is the balance after the last block this node applied.

Errors and status codes#

An error is always {"error": "<text>"}. The text is for people; branch on the status code.

StatusMeaning
200Answered
304Not modified: the If-None-Match matched the ETag (see Caching)
400A malformed request, or a write the node refused (the text says why)
401/peers without the trust token
403The request came through a browser or a non-loopback Host (see Access)
404A well-formed question with a negative answer: a height not reached, an unknown id, a pruned body, or a feature not active yet at this height
405Wrong method on a read, or on /call
429Rate limited (see Rate limits). No Retry-After is sent
500The node could not read back a record it wrote. The fault is the node's, not the request's: retry, or ask another node
503/events for a block whose body or outcomes this node no longer holds

A path that is not a route gets a plain-text 404, not JSON.

Methods#

Every read answers GET and HEAD, and any other verb gets 405. /call, /submit, /openintent and /servicecard take POST only: /call answers another verb with 405, the other three with 400 "POST required".

Caching#

Answers default to Cache-Control: no-store. A few routes send an ETag; repeat the request with If-None-Match and an unchanged answer comes back as an empty 304:

RouteThe ETag changes whenCache-Control
/block (JSON)the tip movesno-cache
/block?id=…&format=ssznever: a block's bytes do not changepublic, max-age=31536000, immutable
/block?height=…&format=ssza different block holds that heightno-cache
/paramsthe consensus root changesno-cache
/eventsthe tip movesno-cache
/maturingthe tip movesno-cache

Access#

The RPC answers only requests whose Host header is localhost or a loopback address; anything else is 403. That stops DNS rebinding from a web page, and it is not authentication. To reach a node from another machine, use an SSH tunnel:

ssh -N -L 9420:127.0.0.1:9420 <node-host>
curl -s http://127.0.0.1:9420/status

or a reverse proxy that authenticates its callers and sets Host: 127.0.0.1 on the way through. zycordd --rpc 0.0.0.0:9420 is allowed and logs a warning; with no authentication in front of it, anyone who can reach the port can read and submit.

The API sends no CORS headers, and a request carrying a Sec-Fetch-Site other than same-origin or none, or a non-loopback Origin, gets 403. Browsers cannot call it from a web page; servers and command-line tools are unaffected.

The trust token#

zycordd --rpc-trust-token-file /path/to/token ...
curl -s -H "Authorization: Bearer $(cat /path/to/token)" http://127.0.0.1:9420/peers

The file is read once at start, with surrounding whitespace trimmed. A request that carries the token is exempt from the per-client rate limits, and /peers answers only such a request. No other route needs it.

Rate limits#

Each client address has its own buckets over a sliding one-minute window. IPv6 clients are grouped by /64. The limits are fixed in the node and have no flags.

BucketRoutesLimit
Readsevery GET route600 requests a minute
Writes/submit, /openintent, /servicecard600 requests a minute, separate from reads
Calls/call60 requests a minute
Block bytes/block (both formats), and /events and /cert when they have to read a block256 MiB a minute

Two limits hold for every caller, token or not: one /call runs at a time, and at most four /events derivations of different blocks run at once. Past either, the answer is 429.

Behind a proxy, every client is one client

The limiter keys on the address of the connection and never on X-Forwarded-For. Behind a reverse proxy every request arrives from the proxy, so all callers share one bucket. Rate-limit at the proxy, or give your own services the trust token.

Request bodies are limited to 1 MiB of hex text, which is about 512 KiB of certificate.

Route index#

RouteMethodAnswers
/statusGETNetwork, height, tip, sync state, checkpoints
/headGETThe tip header
/blockGETOne block, as JSON or as canonical bytes
/paramsGETThe parameter set and its consensus root
/feesGETBase fees and the ceilings in force
/maturingGETBlock rewards not yet mature
/balanceGETAn address's balance and whether it is spent
/cellGETOne raw state word
/codeGETWhether a contract is deployed, and its code hash
/storageGETOne contract storage slot
/sequencerGETThe sequencer key each contract registered
/directoryGETThe endpoint each sequencer key announced
/certGETWhat became of a certificate
/mempoolGETPool counters and pending ids
/eventsGETPer-certificate outcomes and contract logs of one block
/submitPOSTSubmit a signed certificate
/callPOSTDry-run a contract call against the tip
/openintentPOSTSubmit an open intent for the forced path
/networkGETConnection counts and reachability
/metricsGETCounters, as JSON or Prometheus text
/peersGETEvery live connection (trust token required)
/bond, /underwriter, /services, /underwriters, /servicecardGET / POSTThe retired underwriting machinery, always empty on mainnet and the testnet

Chain#

GET /status#

Which network the node is on, where its tip is, and whether it is still catching up. No parameters.

{
  "network": "zycord",
  "chain_id": 1,
  "network_id": "0x…",
  "height": 12345,
  "tip": "0x…",
  "state_root": "0x…",
  "time": 1791700000,
  "chain_work": "123456789",
  "body_floor": 0,
  "ahead_peers": 0,
  "best_peer_height": 12345,
  "syncing": false,
  "min_chain_work": "…",
  "min_chain_work_height": 0,
  "checkpoint_count": 1,
  "checkpoint_sunset_height": 0,
  "checkpoint_height": 0,
  "checkpoint_id": "0x…"
}
FieldMeaning
network, chain_idzycord and 1 on mainnet, zycord-testnet and 2 on the testnet
height, tipHeight and id of the node's tip
timeThe tip header's timestamp, in Unix seconds, as its miner declared it
chain_workTotal work of the chain, decimal string
body_floorBlocks below this height have had their bodies pruned on this node
syncingtrue when a peer claims a chain more than two blocks ahead. Do not trust balances from a node that is syncing. Present only when networking is on, with ahead_peers and best_peer_height
checkpoint_height, checkpoint_idThe newest checkpoint built into the binary, present only when there is one

The tip header. No parameters.

{"height": 12345, "id": "0x…", "parent": "0x…", "time": 1791700000,
 "cert_root": "0x…", "target": "…"}

GET /block#

ParameterMeaning
heightone of the twoA height on the canonical chain
idone of the twoA block id. Also finds blocks that lost a reorg
formatoptionaljson (default) or ssz, the canonical bytes as application/octet-stream

With both, height wins; with neither, 400. An unknown height or id is 404.

{
  "height": 12345,
  "id": "0x…",
  "parent": "0x…",
  "time": 1791700000,
  "canonical": true,
  "orphaned": false,
  "confirmations": 21,
  "body_retained": true,
  "certificates": ["0x…", "0x…"],
  "bytes": 1234,
  "outcomes": [{"id": "0x…", "outcome": "APPLIED"}]
}
FieldMeaning
canonical, orphanedWhether the block is on this node's best chain. A block that lost a reorg keeps its header, comes back orphaned, and has no body
confirmationstip height − height + 1 for a canonical block, 0 otherwise
certificatesThe ids of the certificates in the block, in order
bytesSize of the canonical block
outcomesWhat the fold did with each certificate: APPLIED, SKIPPED_STALE, SKIPPED_OVERFLOW, DROPPED or FORCED_FAILED (a devnet adds QUEUED, SKIPPED_QUOTA and SKIPPED_UNPAYABLE). Absent when there are none
body_prunedtrue when the block is below this node's body_floor
launch_message_blake3Block 1 only: the digest of the launch message

The JSON carries ids, not contents. A certificate's moves, amounts and addresses are in the format=ssz bytes, laid out in the wire protocol. Only APPLIED changed state: a skipped certificate paid its fee and moved nothing else.

GET /params#

The parameter set the node runs, and the consensus root that every signature commits to. No parameters.

{"params": {"name": "zycord", "chain_id": 1, "coinbase_maturity": 240,
            "undo_depth": 1024, "target_block_seconds": 30, "ttl_max": 240,
            "skip_fee": "1000000", …},
 "consensus_root": "0x…",
 "network_id": "0x…",
 "elastic_ceilings_at": "/fees"}

The block ceilings in params are genesis values, not the limits in force; those are in /fees.

GET /fees#

The two base fees, in drops per unit of gas, and the ceilings the next block is held to. No parameters.

{"seq_base_fee": "7", "par_base_fee": "7", "skip_fee": "1000000",
 "seq_gas_target": 1600000, "seq_gas_limit": …, "par_gas_limit": …,
 "block_byte_limit": …, "max_certs_per_block": …}

Zycord has two fee markets, sequential and parallel, each with its own base fee. The base fee is burned, moves by at most an eighth a block (or one drop per gas, when that is more), and is what a certificate's maximum must cover; Exchange integration shows how the wallet chooses a fee. skip_fee is what a certificate pays when it is included but skipped.

GET /maturing#

Every block reward still waiting for coinbase_maturity (240 blocks on mainnet, 100 on the testnet), oldest first. No parameters: it always answers the whole list, so asking does not tell the node which reward is yours. ETag on the tip.

{"tip": {"id": "0x…", "height": 12345},
 "coinbase_maturity": 240,
 "rewards": [{"height": 12200, "address": "0x…", "amount": "1974000000",
              "matures_at_height": 12440, "blocks_left": 95, "payee_spent": false}]}

A reward is credited when block matures_at_height is applied. If its payee address is spent by then, the reward is burned rather than credited.

State#

GET /balance#

ParameterMeaning
addrrequiredThe address
assetoptionalAn issued asset's id. Default: ZCD
curl -s "http://127.0.0.1:9420/balance?addr=0x01…"

{"balance": "250000000", "spent": false, "tip": {"id": "0x…", "height": 12345}}

spent: true means the address was a one-shot whose signing authority has been used. Nothing can debit it again, and a payment sent to it is skipped. Never send to an address that answers spent: true.

GET /cell#

One raw state word, for tools that work at the level of the state layout. Required: addr and word, both 32-byte hex.

{"value": "0", "spent": false}

GET /code#

Whether an address holds a deployed contract. Required: addr.

{"tip": {…}, "addr": "0x…", "contract_address": true, "deployed": true,
 "code_hash": "0x…", "spent": false, "code_available": false}

The node keeps the code hash, not the code: code_available is always false.

GET /storage#

One storage slot of a contract. Required: addr and key.

{"tip": {…}, "addr": "0x…", "key": "0x…", "value": "42", "word": "0x…"}

GET /sequencer#

The sequencer key each contract's record holds. Required: contracts, up to 64 addresses separated by commas.

{"tip": {…}, "contracts": [{"contract": "0x…", "deployed": true, "key": "0x…",
                           "sequenced": true, "sequencer": "0x…"}]}

A zero key is an open contract. sequencer, the persistent address the key fills from, is present only for a non-zero key.

GET /directory#

The endpoint each sequencer key announced on chain. Required: keys, up to 64 separated by commas. A key that never announced, or whose entry lapsed, is absent.

{"tip": {…}, "entries": {"0x<key>": {"address": "0x…", "url": "https://…",
                                       "since": 12000, "expires": 14000}}}

Certificates#

A Zycord transaction is a certificate, and its id is blake3("zcd/certid/v1" ‖ ssz(certificate without signatures)). Signatures are left out, so the id is known before signing and does not change if the certificate is signed again.

GET /cert#

What became of a certificate. Required: id. The answer is one of four:

{"id": "0x…", "status": "pending"}
{"id": "0x…", "status": "included", "height": 12345, "outcome": "APPLIED"}
{"id": "0x…", "status": "dropped", "reason": "below_base_fee", "height": 12340, "detail": "…"}
{"id": "0x…", "status": "unknown"}
StatusMeaning
pendingIn this node's pool
includedIn a canonical block at height, with the fold's outcome. An applied contract call adds execution (SUCCEEDED, REVERTED, FAILED or UNKNOWN) and, for a revert, revert_data
droppedThis node took it out of its pool. reason is a stable code; height is the tip when it decided
unknownThe node has no record of it
included does not last forever

The node remembers a certificate as included only while its expiry is in reach: about two blocks past its TTL (the wallet's default TTL is 30 blocks), it answers unknown. The dropped record is this node's alone and holds the latest 4096. Record the height when you first see included, and after that follow the block, by id.

Drop reasons: ttl_passed, ttl_beyond_bound, not_yet_active, already_committed, below_base_fee, over_parallel_gas_ceiling, read_no_longer_holds, deposit_no_longer_covers, evicted_for_a_higher_bid, dropped_by_dry_run, refused_by_screen, pipeline_passed_its_seq, fence_counter_moved, cannot_enter_a_block, payer_spent_by_its_owner, waiting_for_the_seq_below, retired_at_v1, co_signature_revoked, underwriter_authority_burned.

GET /mempool#

Pool counters. Optional: limit, which adds up to that many pending ids, lowest first; values above 1000 are treated as 1000.

{"size": 3, "admitted": 120, "rejected": 2, "evicted": 0, "seq_gas": 2400,
 "par_gas": 9000, "bytes": 4100, "open_intents": 0, "held": 0,
 "refused": {…}, "dry_run_removed": {…},
 "ids": ["0x…"], "truncated": false}

The pool gives ids, never certificate bodies.

GET /events#

The outcome of every certificate in one canonical block, with the logs contract calls emitted. Required: height or block (an id). Optional: cert, to answer for one certificate.

{"block": {"id": "0x…", "height": 12345},
 "certificates": [{"id": "0x…", "position": 0, "outcome": "APPLIED",
                   "execution": "SUCCEEDED",
                   "events": [{"log_index": 0, "emitter": "0x…",
                               "topics": ["0x…"], "data": "0x…"}]}]}

Plain transfers emit no logs: their events is empty and they have no execution. A block that is not canonical, or whose body this node has pruned, is 503.

Writes#

The writes grant nothing: /submit and /openintent take something already signed, check it exactly as one arriving from a peer, and gossip it. /call is a dry run that changes nothing. The body is the hex of the canonical bytes, with or without 0x; surrounding whitespace is ignored and the content type is not checked.

POST /submit#

curl -s -X POST --data-binary @cert.hex http://127.0.0.1:9420/submit

{"id": "0x…", "accepted": true}

accepted means the node admitted the certificate to its pool and gossiped it, not that a block included it. Follow it with /cert. A certificate the node will not take is 400, with the reason in error.

POST /call#

Runs a contract program against the tip and returns what it read and would write, without changing anything. The body is the hex of the program body. Optional query: seq, and underwriter (retired, see below). 404 before the machine's activation height.

{"tip": {"id": "0x…", "height": 12345, "state_root": "0x…"},
 "reads": […], "writes": […], "artifact": […], "artifact_truncated": false,
 "par_gas_used": 52000, "par_units_declared": …, "min_par_units_hint": …}

POST /openintent#

An open intent for the forced path, which any producer may execute at the end of a block. Answers {"id": "0x…", "accepted": true}.

Node#

GET /network#

{"enabled": true, "known_peers": 40, "banned_peers": 0, "min_score": …,
 "peers": 8, "listening": true, "inbound": 5, "outbound": 3, "reachable": true,
 "inbound_listening": 2, "inbound_not_listening": 3,
 "bytes_in": …, "bytes_out": …, "frames_in": …, "frames_out": …}

GET /metrics#

Counters since the node started. format=json, or format=prometheus for the Prometheus text format (metrics named zycord_*); without format, the Accept header decides.

{"blocks_applied": …, "blocks_undone": …, "blocks_rejected": …,
 "certs_applied": …, "certs_skipped": …, "certs_dropped": …,
 "reorg_events": 0, "deepest_reorg": 0, "reorg_history": {…},
 "submit_accepted": …, "submit_rejected": …, "mempool_size": 3, "height": 12345}

GET /peers#

Every live connection. It names the people running the peers, so it needs the trust token; without it, 401.

{"enabled": true, "peers": [{"addr": "…", "direction": "outbound",
                             "listen_addr": "…", "claimed_height": 12345,
                             "connected_seconds": 3600}]}

Retired routes#

/bond, /underwriter, /services (and its alias /underwriters) and POST /servicecard serve the underwriting machinery that v1.0 of the protocol described and v2.0 retired. On mainnet and the testnet they never hold anything, and integrations should not use them. /bond and /underwriter answer 404 below height 80,640 on mainnet (2,880 on the testnet).

What the API does not have#

The node keeps current state and the chain, and nothing indexed from them. There is:

  • no history by address: no route lists the payments an address received or sent;
  • no decoded transactions: certificate contents are in the block's canonical bytes;
  • no historical state: /balance reads the tip;
  • no wallet: the node holds no key, so there is no "new address" or "send" call. Keys and signing live in zcd.

An integration builds what it needs at ingest, in its own database. Exchange integration shows how.