# 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.

### 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](https://peal.network/bonsai/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](https://peal.network/bonsai/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](https://peal.network/developers/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](https://peal.network/developers/links) is the picture: the pieces and who sees what.
-   [API reference](https://peal.network/developers/api) runs the public reads from the page.

---
Source: https://peal.network/developers/links-api
