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 as malformed.
  • Addresses are lowercase 0x strings. 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), so sepolia/USDC always 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.

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

kindroutesfrom your own code
public readsevery GET marked none below: status, params, the ledger, receipt paths, history, accounting, a request by id, inbox key bindings, deposit intent and withdrawal statusyes, plain HTTP from any language, no key
session readssign-in, GET /requests, GET /directory/{ns}/{address}, GET /backups/{ns} (an encrypted blob)yes, after a wallet sign-in from any client (below)
writesledger register and ops, deposit intents, withdrawals, requests (create, reserve, fulfill, archive), the directory profile, backups, inbox keys and envelopesnot 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

mechanismhowroutes
nonepublic reads, and writes that carry their own proofstatus, params, ledger, a request by id, reserve, deposit intents, withdrawal status, inbox writes
sessionAuthorization: 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 signaturean 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

routeauthwhat
GET /statusnoneok, 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 /paramsnonecircuit_id, setup, files: op.pk, op.vk, deposit.pk, deposit.vk, each with digest and size
GET /params/{name}nonethe raw key bytes; ETag is the digest, Cache-Control: immutable, honours If-None-Match. 404 unknown_param.
GET /consensusnonethe validator view when the node runs under consensus; 404 single_node on the hosted node
GET /healthznoneat 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.

routeauthwhat
GET /ledger/{ns}noneseq, receipt_count, state_root, receipt_root, recent_roots[], minted_total
GET /ledger/{ns}/accounts/{acct}noneaccount, com (the current commitment), updated_seq. 404 unknown_account.
POST /ledger/{ns}/registeraccount signaturebody namespace, pubkey, randomness, signature. Returns seq, state_root, receipt_root, position: null. 409 account_exists.
POST /ledger/{ns}/opsaccount signaturebody 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=nonea 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=noneops[] of seq, kind (register, op, mint), envelope, position. from defaults to 1, limit to 100.
GET /ledger/{ns}/accountingnoneminted_total, withdrawn_total, outstanding_liability, receipt_count: the reserve the gateway must hold, as public numbers

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.

routeauthwhat
POST /requestssession + account signaturebody: 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 /requestssessionthe caller's requests, newest first, up to 500. status is active, expired, fulfilled or archived.
GET /requests/{id}?intent=nonewhat 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}/reservenonebody 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}/fulfillaccount signaturebody: 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}/archivesessionowner 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.

routeauthwhat
PUT /directorysession + wallet signaturebody: 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}sessionprofile, 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.

routeauthwhat
POST /inbox/keysaccount signaturebind 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}nonethe current binding. 404 no_key.
POST /inbox/{ns}/{acct}nonebody 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-auththe 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.

routeauthwhat
POST /deposits/intentsproofbody 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}nonestatus: 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.

routeauthwhat
POST /withdrawalsaccount signaturebody 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}nonestatus, tx_hash once the watcher sees it on the chain, and the certificate again. 404 unknown_withdrawal.

Backups

routeauthwhat
PUT /backups/{ns}sessionbody 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}sessionseq, 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"
}
statuscodes
400malformed, 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
401unauthorized: no session, an expired one, a bad account signature, a stale inbox header
404unknown_namespace, unknown_account, unknown_request, unknown_intent, unknown_withdrawal, unknown_param, not_registered, no_key, no_backup, single_node
409account_exists, stale_commitment, root_not_recent, request_exists, reserved, not_payable, stale_binding, profile_rejected, backup_rejected, already_attested, already_consumed, duplicate_deposit
422invalid_proof
429rate_limited
503log_full, no_gateway, no_committee, no_consensus, not_enough_signers, chain_unreachable

Limits

whatlimit
any request body256 KiB
an inbox envelope8 KiB
a backup blob1 MiB, last 8 versions kept
a sign-in message4096 characters; nonce good for 10 minutes; session 12 hours
directory lookups60 per session per minute
a request reservation10 minutes
a payment intent10 minutes
inbox page, request list200 items, 500 requests
proof agethe 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