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
| Setting | Value |
|---|---|
| Default address | 127.0.0.1:9420, on mainnet and on the testnet alike |
| Change it | zycordd --rpc ADDR |
| Turn it off | zycordd --no-rpc |
| Authentication | None. The node holds no key, and no route grants authority |
| Timeouts | 5 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.
| Unit | Drops |
|---|---|
| 1 ZCD | 100000000 (eight decimals) |
| 1 drop | 1, 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.
| Status | Meaning |
|---|---|
200 | Answered |
304 | Not modified: the If-None-Match matched the ETag (see Caching) |
400 | A malformed request, or a write the node refused (the text says why) |
401 | /peers without the trust token |
403 | The request came through a browser or a non-loopback Host (see Access) |
404 | A 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 |
405 | Wrong method on a read, or on /call |
429 | Rate limited (see Rate limits). No Retry-After is sent |
500 | The 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:
| Route | The ETag changes when | Cache-Control |
|---|---|---|
/block (JSON) | the tip moves | no-cache |
/block?id=…&format=ssz | never: a block's bytes do not change | public, max-age=31536000, immutable |
/block?height=…&format=ssz | a different block holds that height | no-cache |
/params | the consensus root changes | no-cache |
/events | the tip moves | no-cache |
/maturing | the tip moves | no-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.
| Bucket | Routes | Limit |
|---|---|---|
| Reads | every GET route | 600 requests a minute |
| Writes | /submit, /openintent, /servicecard | 600 requests a minute, separate from reads |
| Calls | /call | 60 requests a minute |
| Block bytes | /block (both formats), and /events and /cert when they have to read a block | 256 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.
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#
| Route | Method | Answers |
|---|---|---|
/status | GET | Network, height, tip, sync state, checkpoints |
/head | GET | The tip header |
/block | GET | One block, as JSON or as canonical bytes |
/params | GET | The parameter set and its consensus root |
/fees | GET | Base fees and the ceilings in force |
/maturing | GET | Block rewards not yet mature |
/balance | GET | An address's balance and whether it is spent |
/cell | GET | One raw state word |
/code | GET | Whether a contract is deployed, and its code hash |
/storage | GET | One contract storage slot |
/sequencer | GET | The sequencer key each contract registered |
/directory | GET | The endpoint each sequencer key announced |
/cert | GET | What became of a certificate |
/mempool | GET | Pool counters and pending ids |
/events | GET | Per-certificate outcomes and contract logs of one block |
/submit | POST | Submit a signed certificate |
/call | POST | Dry-run a contract call against the tip |
/openintent | POST | Submit an open intent for the forced path |
/network | GET | Connection counts and reachability |
/metrics | GET | Counters, as JSON or Prometheus text |
/peers | GET | Every live connection (trust token required) |
/bond, /underwriter, /services, /underwriters, /servicecard | GET / POST | The 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…"
}
| Field | Meaning |
|---|---|
network, chain_id | zycord and 1 on mainnet, zycord-testnet and 2 on the testnet |
height, tip | Height and id of the node's tip |
time | The tip header's timestamp, in Unix seconds, as its miner declared it |
chain_work | Total work of the chain, decimal string |
body_floor | Blocks below this height have had their bodies pruned on this node |
syncing | true 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_id | The newest checkpoint built into the binary, present only when there is one |
GET /head#
The tip header. No parameters.
{"height": 12345, "id": "0x…", "parent": "0x…", "time": 1791700000,
"cert_root": "0x…", "target": "…"}
GET /block#
| Parameter | Meaning | |
|---|---|---|
height | one of the two | A height on the canonical chain |
id | one of the two | A block id. Also finds blocks that lost a reorg |
format | optional | json (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"}]
}
| Field | Meaning |
|---|---|
canonical, orphaned | Whether 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 |
confirmations | tip height − height + 1 for a canonical block, 0 otherwise |
certificates | The ids of the certificates in the block, in order |
bytes | Size of the canonical block |
outcomes | What 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_pruned | true when the block is below this node's body_floor |
launch_message_blake3 | Block 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#
| Parameter | Meaning | |
|---|---|---|
addr | required | The address |
asset | optional | An 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"}
| Status | Meaning |
|---|---|
pending | In this node's pool |
included | In 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 |
dropped | This node took it out of its pool. reason is a stable code; height is the tip when it decided |
unknown | The node has no record of it |
included does not last foreverThe 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:
/balancereads 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.