Private Links API
Every route the Peal Links node serves under /links/v1, with what authenticates it, what it takes, what it returns and how it fails. Public reads work from any language today; writes need a published client, which is coming.
Base URL and conventions
The hosted node answers at https://peal.network/links/v1, behind the same origin as
this page. A node you run yourself serves the same paths on its own port. Bodies are JSON,
responses are JSON, and errors are application/problem+json.
- Amounts are decimal strings of integer base units:
"12500000"is 12.50 USDC. One exception is called out below. - 32-byte values (namespace ids, account ids, keys, tags, roots) are 64
lowercase hex characters without
0x. Field elements are canonical little-endian encodings below the BLS12-381 scalar modulus; a value at or above it is rejected asmalformed. - Addresses are lowercase
0xstrings. Timestamps are Unix seconds. Proofs are exactly 128 bytes, as hex. - A namespace is one asset on one chain. Its id is
SHA-256("peal-links/v1/namespace" || 0x00 || label), sosepolia/USDCalways has the same id on every node that serves it. - An account id is a field element derived from the namespace and the account's ed25519 public key. A position is the index of a receipt in the namespace's append-only log.
Read the node
One call configures a client: the namespaces (an asset on a chain), the circuit id the proving keys must match, and the settlement signers. This runs against the live node.
const res = await fetch('https://peal.network/links/v1/status');
const status = await res.json();
for (const ns of status.namespaces) {
console.log(ns.label, ns.chain_name, ns.token_address, ns.decimals, 'decimals');
}
console.log('circuit', status.circuit_id);
console.log('signers', status.signers.length, 'threshold', status.signer_threshold);
What you can call today
| kind | routes | from your own code |
|---|---|---|
| public reads | every GET marked none below: status, params, the ledger, receipt paths, history, accounting, a request by id, inbox key bindings, deposit intent and withdrawal status | yes, plain HTTP from any language, no key |
| session reads | sign-in, GET /requests, GET /directory/{ns}/{address}, GET /backups/{ns} (an encrypted blob) | yes, after a wallet sign-in from any client (below) |
| writes | ledger register and ops, deposit intents, withdrawals, requests (create, reserve, fulfill, archive), the directory profile, backups, inbox keys and envelopes | not yet: each carries a zero-knowledge proof, an account signature or an encrypted envelope, made today by the hosted app. A published client is coming. |
The tables below document every route, writes included, so the wire format is on record. To make a payment today, use the hosted app.
Three ways a call is authenticated
| mechanism | how | routes |
|---|---|---|
| none | public reads, and writes that carry their own proof | status, params, ledger, a request by id, reserve, deposit intents, withdrawal status, inbox writes |
| session | Authorization: Bearer <token> from a wallet sign-in (EIP-4361 message, EIP-191 signature). 12 hours. Externally owned accounts only. | requests (create, list, archive), directory, backups, /auth/me |
| account signature | an ed25519 signature by the private account's spending key over domain-tagged bytes, inside the body (or, for inbox reads, in the x-peal-inbox-auth header) | ledger register and ops, fulfill, inbox key binding, inbox reads, withdrawals |
A session authorizes product metadata only: requests, a profile, a backup. It confers no spending authority. Anything that moves value carries a proof and an account signature. The node cannot forge the signature, and cannot forge the proof under the proof system's soundness assumption; today's per-process key setup and unreviewed circuits mean that assumption is itself unaudited (see "What you are trusting" in Peal Private Links).
Sign-in is bound to a domain.
The domain line of the sign-in
message must be one the node accepts; the hosted node accepts peal.network. A
third-party app on its own origin should run its own node with its domain in
PEAL_LINKS_AUTH_DOMAINS, rather than ask a wallet to sign in to a site it is not
on. Running your own node is not public yet.
// 1. a nonce, good for ten minutes
const { nonce } = await (await fetch('https://peal.network/links/v1/auth/nonce')).json();
// 2. the wallet signs an EIP-4361 message naming the domain, the address and the nonce
const message = 'peal.network wants you to sign in with your Ethereum account:\n' + address + '\n\n'
+ 'URI: https://peal.network\nVersion: 1\nChain ID: ' + ns.chain_id // the namespace's chain
+ '\nNonce: ' + nonce + '\nIssued At: ' + new Date().toISOString();
const signature = await wallet.signMessage(message);
// 3. a bearer token
const res = await fetch('https://peal.network/links/v1/auth/session', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message, signature }),
});
const { token, expires_at } = await res.json();
// then: headers: { authorization: 'Bearer ' + token }
Node
| route | auth | what |
|---|---|---|
GET /status | none | ok, version, circuit_id, ledger_mode, signers[], signer_threshold, dev_mint, namespaces[] (id, label, chain_id, chain_name, token_symbol, token_address, decimals, gateway, confirmations, available, environment, explorer_url, rpc_url), ledgers[] (namespace, seq, receipt_count, state_root, receipt_root) |
GET /params | none | circuit_id, setup, files: op.pk, op.vk, deposit.pk, deposit.vk, each with digest and size |
GET /params/{name} | none | the raw key bytes; ETag is the digest, Cache-Control: immutable, honours If-None-Match. 404 unknown_param. |
GET /consensus | none | the validator view when the node runs under consensus; 404 single_node on the hosted node |
GET /healthz | none | at the root, not under /links/v1: {"ok":true} |
Ledger
The ledger is what the proofs are about. It is public: a record names the acting account, its old and new commitment, one receipt, the root the proof was made against, and the proof. It carries no amount and no counterparty. Which account acted, and when, is intentionally public, and the directory lets anyone who knows a wallet address link it to that account's activity.
| route | auth | what |
|---|---|---|
GET /ledger/{ns} | none | seq, receipt_count, state_root, receipt_root, recent_roots[], minted_total |
GET /ledger/{ns}/accounts/{acct} | none | account, com (the current commitment), updated_seq. 404 unknown_account. |
POST /ledger/{ns}/register | account signature | body namespace, pubkey, randomness, signature. Returns seq, state_root, receipt_root, position: null. 409 account_exists. |
POST /ledger/{ns}/ops | account signature | body namespace, circuit_id, account, com, com_new, receipt, root, proof, pubkey, signature. Returns seq and the position of the appended receipt. 422 invalid_proof, 409 stale_commitment (the account moved since com), 409 root_not_recent (older than the last 1024 roots), 503 log_full. |
GET /ledger/{ns}/receipts/{pos}/path?size= | none | a Merkle path for the receipt at pos, against the root at size leaves (default: now): root, leaf, siblings[], index_bits[] |
GET /ledger/{ns}/history?from=&limit= | none | ops[] of seq, kind (register, op, mint), envelope, position. from defaults to 1, limit to 100. |
GET /ledger/{ns}/accounting | none | minted_total, withdrawn_total, outstanding_liability, receipt_count: the reserve the gateway must hold, as public numbers |
Read the public ledger
The ledger and its accounting are public and need no session. Notice what a record is: an account id, a sequence number, a proof. No amount, no counterparty.
const status = await (await fetch('https://peal.network/links/v1/status')).json();
const ns = status.namespaces[0].id;
const ledger = await (await fetch('https://peal.network/links/v1/ledger/' + ns)).json();
console.log('seq', ledger.seq, 'receipts', ledger.receipt_count, 'state root', ledger.state_root);
const books = await (await fetch('https://peal.network/links/v1/ledger/' + ns + '/accounting')).json();
console.log('minted', books.minted_total, 'withdrawn', books.withdrawn_total, 'outstanding', books.outstanding_liability);
const history = await (await fetch('https://peal.network/links/v1/ledger/' + ns + '/history?limit=3')).json();
for (const op of history.ops) {
// a register names a pubkey, an op names an account, a mint names a deposit tag
console.log(op.seq, op.kind, op.envelope.account ?? op.envelope.pubkey ?? op.envelope.intent.receipt);
}
Requests
A request is a manifest signed by the receiver's private account: the link id, the namespace, the receiving account and key, the amount, a title, a display name, the receiver's wallet address, an optional reference and expiry. The node stores it in clear, because the payer has to read it. The payer verifies the signature; the node also does, on creation.
| route | auth | what |
|---|---|---|
POST /requests | session + account signature | body: the manifest (version: 2, request_id of 24 base32 characters, namespace, receiver_account, receiver_enc_key, amount, title 1 to 140 characters, display_name 1 to 60, receiver_address, reference up to 64, expires_at, created_at within the last hour, signer_pubkey, signature). 201 with manifest, status: "active", fulfilled_at, reserved. Idempotent: the same manifest again is 200; a different one under the same id is 409 request_exists. 400 bad_manifest, 400 unregistered_receiver. |
GET /requests | session | the caller's requests, newest first, up to 500. status is active, expired, fulfilled or archived. |
GET /requests/{id}?intent= | none | what a payer reads. reserved is true while another payer holds it; pass your own intent id to see your own reservation as free. 404 unknown_request. |
POST /requests/{id}/reserve | none | body intent_id (8 to 64 characters). A ten minute soft lock so two payers do not race; the same id renews it. Returns reserved_until. 409 reserved, 409 not_payable. |
POST /requests/{id}/fulfill | account signature | body: an acknowledgement signed by the receiving account (request_id, namespace, receiver_account, position, claimed_at, signer_pubkey, signature). Marks the request fulfilled. |
POST /requests/{id}/archive | session | owner only, active only |
The reservation is a courtesy between payers, not a rule the ledger enforces. Two clients that ignore it can both pay a request; the receiver gets both receipts.
Directory
The directory maps a wallet address to a private account, a receiving key and a display
name, signed by the wallet as EIP-712 PealLinksAccount. Versions form a hash
chain: each names the previous one's hash, and the node refuses a version that does not.
| route | auth | what |
|---|---|---|
PUT /directory | session + wallet signature | body: the profile (version, wallet, chain_id, namespace, account, enc_key, profile_key, display_name, recovery, nonce, issued_at, expiry, prev, revoked, signature). wallet must be the session's address, and sessions are for externally owned accounts, so although the node can verify an ERC-1271 signature here, a contract wallet cannot publish today. 201 with hash, version, verified (eoa or erc1271). 409 profile_rejected, 400 wrong_chain, 400 unregistered_account. |
GET /directory/{ns}/{address} | session | profile, hash, verified, log[] of every version. Rate limited to 60 lookups per session per minute: 429 rate_limited. 404 not_registered. |
Inbox
A receipt is delivered as an envelope encrypted to the receiver's key: the node stores ciphertext, a recipient id and a time. Writing is open, so a payer needs no session; reading needs a fresh signature by the receiving account.
| route | auth | what |
|---|---|---|
POST /inbox/keys | account signature | bind an encryption key to an account: namespace, account, enc_pubkey, seq, pubkey, signature. An equal or lower seq is 409 stale_binding. |
GET /inbox/keys/{ns}/{acct} | none | the current binding. 404 no_key. |
POST /inbox/{ns}/{acct} | none | body up to 8 KiB: envelope (version, namespace, recipient, ephemeral, nonce of 24 bytes, ciphertext) and an optional request_id. 201 with id. 400 too_large, 404 unknown_account. |
GET /inbox/{ns}/{acct}?after= | header x-peal-inbox-auth | the header is JSON: namespace, account, timestamp within two minutes, pubkey, signature. Up to 200 items after the cursor, each id, envelope, request_id, posted_at. |
Deposits
A deposit is public on the chain and private on the ledger. The client proves a deposit
relation over the amount and a random tag, registers the intent, then calls
gateway.deposit(token, amount, tag). The node's watcher credits the tag after the
namespace's confirmations. The tag hides the account; only the holder of the witness can
claim the credit.
| route | auth | what |
|---|---|---|
POST /deposits/intents | proof | body namespace, circuit_id, amount, receipt (the tag), proof. The proof is verified here. 201 with receipt, status: "pending"; the same tag again is 200. 422 invalid_proof, 400 wrong_circuit. |
GET /deposits/intents/{ns}/{receipt} | none | status: pending or minted, with deposit_id and position once credited. 404 unknown_intent. |
Register the intent before sending tokens, and keep the witness.
A
deposit that arrives with a tag the node has never seen is held as unregistered and credited
when the intent appears. A tag whose witness was lost can never be claimed. And one
exception to the encoding rule: amount in this body is a JSON number, not a
string, because the wasm prover emits the envelope. Every other amount on this API is a
string.
Withdrawals
A withdrawal is a send to a fixed burn account, proven like any payment, followed by a
claim that reveals that one receipt's opening to the node. The settlement signers each check
the opening against the ledger leaf and sign an EIP-712 Withdrawal for the
gateway; two of three signatures make a certificate. Anyone can submit it to the chain; the
recipient is inside the signed message.
| route | auth | what |
|---|---|---|
POST /withdrawals | account signature | body namespace, account, position (of the burn), opening (amount, sender, receiver, randomness), recipient, pubkey, signature. 201 with the certificate: message (chain_id, gateway, token, recipient, amount, withdrawal_id, epoch), signatures[], signers[], threshold. Idempotent per burn; re-certified after a signer rotation. 400 bad_opening, 409 already_attested, 503 not_enough_signers, 503 chain_unreachable. |
GET /withdrawals/{ns}/{position} | none | status, tx_hash once the watcher sees it on the chain, and the certificate again. 404 unknown_withdrawal. |
Backups
| route | auth | what |
|---|---|---|
PUT /backups/{ns} | session | body seq, mechanism (wallet-signature or recovery-code), blob: opaque ciphertext up to 1 MiB, keyed by the session's address. A lower seq is 409 backup_rejected. The node keeps the last 8 versions. |
GET /backups/{ns} | session | seq, mechanism, blob, created_at. 404 no_backup. |
Errors
Every error is application/problem+json with the same five fields. Match on
code; the detail is for a human.
{
"type": "https://peal.network/problems/stale_commitment",
"title": "stale commitment",
"status": 409,
"code": "stale_commitment",
"detail": "the account's commitment moved since this proof was made"
}
| status | codes |
|---|---|
| 400 | malformed, unknown_namespace (in a body; 404 on a path), wrong_namespace, wrong_circuit, wrong_chain, bad_siwe, bad_manifest, bad_profile, bad_opening, too_large, unregistered_receiver, unregistered_account, wallet |
| 401 | unauthorized: no session, an expired one, a bad account signature, a stale inbox header |
| 404 | unknown_namespace, unknown_account, unknown_request, unknown_intent, unknown_withdrawal, unknown_param, not_registered, no_key, no_backup, single_node |
| 409 | account_exists, stale_commitment, root_not_recent, request_exists, reserved, not_payable, stale_binding, profile_rejected, backup_rejected, already_attested, already_consumed, duplicate_deposit |
| 422 | invalid_proof |
| 429 | rate_limited |
| 503 | log_full, no_gateway, no_committee, no_consensus, not_enough_signers, chain_unreachable |
Limits
| what | limit |
|---|---|
| any request body | 256 KiB |
| an inbox envelope | 8 KiB |
| a backup blob | 1 MiB, last 8 versions kept |
| a sign-in message | 4096 characters; nonce good for 10 minutes; session 12 hours |
| directory lookups | 60 per session per minute |
| a request reservation | 10 minutes |
| a payment intent | 10 minutes |
| inbox page, request list | 200 items, 500 requests |
| proof age | the root must be among the last 1024 |
Cross-origin calls are allowed from any origin with GET, POST,
PUT and OPTIONS, and the request headers content-type,
accept, authorization, if-none-match and
x-peal-inbox-auth; etag and x-peal-digest are exposed.
There is no API key and no per-IP limit beyond the ones above.
Running your own node
Not public yet. The node is one Rust binary with a JSON profile: chains,
tokens, gateways, confirmations, the accepted sign-in domains, and the settlement signer keys.
With dev_mint on, it also mounts POST /dev/mint, which credits a
pending deposit intent without a chain event, for local development only; the node refuses
that flag with any mainnet namespace. Until the binary is published, the hosted node at
https://peal.network/links/v1 is the only one you can call.
Where to go next
- Peal Private Links is the picture: the pieces and who sees what.
- API reference runs the public reads from the page.