# API Reference

The backend exposes 12 shared (browser-facing) REST/SSE endpoints: `POST /sessions`, `POST /sessions/:id/register`, `GET /sessions/:id`, `GET /sessions/:id/events`, `GET /sessions/:id/quote`, `GET /sessions/:id/token-options`, `POST /sessions/:id/commit`, `POST /sessions/:id/refund`, `POST /sessions/:id/standing-refund`, `POST /sessions/:id/refund-all`, `POST /quote` (the Flow C connected-wallet Uniswap proxy — documented in the [Push / Send flow](/docs/flow-c-send.md)), and `GET /balances` (the session-less payable-balance lookup for the Flow C push UI — the address format selects the ecosystem: `0x` → EVM via Dune, base58 → Solana via Jupiter; served server-side so the Dune key stays out of the browser bundle). An operational `GET /health` endpoint reports DB, indexer freshness, and relayer gas (`200`/`503` semantics, exempt from rate limiting). Base URL: `https://api.peltier.dev`. All request/response bodies are JSON. CORS is restricted to an explicit origin allowlist (configured via `CORS_ALLOWED_ORIGINS`) -- it is *not* permissive/wildcard. The additional merchant endpoints under `/v1/*` are documented in the [Merchants](/docs/merchant-accounts.md) section and are gated behind `MERCHANT_MODE`. Three further no-auth routes ship with that same gate: `GET /links/:slug` and `POST /links/:slug/sessions` power the hosted [Payment Links](/docs/payment-links.md) pages, and `POST /subscriptions/:sub_id/cancel` is the payer's gasless [Subscriptions](/docs/subscriptions.md) cancel. Four further endpoints for deposits coming from Solana — `POST /sessions/solana`, `POST /sessions/:id/solana-burner`, `POST /sessions/:id/solana-refund`, and `GET /solana/blockhash` — are documented in the [Solana Adapter](/docs/solana-adapter.md) section.

Every error response is `{ "error": "<message>" }` with a meaningful status; the whole surface (except `/health`) sits behind a per-IP rate limiter. See [Errors & Troubleshooting](/docs/errors.md) and [Rate Limits](/docs/rate-limits.md) — the SDK wrappers surface failures as typed `PeltierApiError`s.

## **POST** `/sessions`

Register a new deposit session (the anonymous P2P path). The backend begins indexing all chains for transfers to the burner address.

Three fields are **optional and additive**: `flow` tags the settlement path (`p2p_request` — the default on this anonymous path — `p2p_send`, `checkout`, or `stealth_request`); `standing_refund` bundles a burner-signed lost-key escape hatch (same payload as the `POST /sessions/:id/standing-refund` endpoint below); and `stealth` carries the Flow E announcement (**required** when `flow: "stealth_request"`, rejected otherwise). Omit all three for the legacy anonymous deposit session.

### Request Body

```json
{
  "burner_address": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
  "eip7702_auth": {
    "address": "0x33333A781cbe9aC82Ba510BfF7b26c47a8FDecD4",
    "chainId": 0,
    "nonce": 0,
    "r": "0x1234...abcd",
    "s": "0xabcd...1234",
    "yParity": 1
  },
  "destination_address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
  "destination_chain": 8453,
  "flow": "p2p_request"          // optional; defaults to "p2p_request" on this path
  // "standing_refund": { ... }  // optional lost-key escape hatch
  // "stealth": { "ephemeral_pubkey": "0x…", "view_tag": 42 }  // Flow E only
}
```

### Response 200 OK

```json
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "pending",
  "expires_at": 1714000000
}
```

## **GET** `/sessions/:id`

Retrieve the full session object with all fields. Useful for polling if SSE is not available.

### Response 200 OK

```json
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "created_at": "2026-04-05T12:00:00Z",
  "updated_at": "2026-04-05T12:01:30Z",
  "burner_address": "0x742d35Cc...",
  "eip7702_auth": { ... },
  "dest_address": "0xd8dA6BF2...",
  "dest_chain_id": 8453,
  "app_fee_bps": 0,             // resolved app fee — the SDK signs this into the Intent (0 for anonymous P2P)
  "app_fee_recipient": "0x0000000000000000000000000000000000000000",  // EVM app fee sink
  "app_fee_recipient_sol": null, // base58 app fee sink for Solana settlement (null when unset)
  "status": "detected",
  "source_chain_id": 42161,
  "detected_token": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
  "detected_amount": "1000000000",
  "detected_tx": "0xabc123...",
  "sweep_tx": null,
  "swap_output_amount": null,
  "fee_amount": null,
  "bridge_amount": null,
  "bridge_tx": null,
  "dest_tx": null,
  "retry_count": 0,
  "error_message": null
}
```

## **GET** `/sessions/:id/events`

Server-Sent Events (SSE) stream for real-time session updates. Sends the current state immediately on connect, then pushes updates on every status change via PostgreSQL `LISTEN/NOTIFY`. Keep-alive pings every 15 seconds.

**Checkout auth.** For checkout sessions (`merchant_id` set), the stream is gated by the publishable key + per-session `client_secret`. Because a browser `EventSource` cannot send custom headers, both travel as query params: `?client_secret=cs_…&pk=pk_…` (the `pk_` is publishable, not secret). Anonymous P2P sessions stream openly.

### Event Format

```text
event: status
data: {"id":"a1b2c3d4-...","status":"detected","detected_token":"0xaf88d065...","detected_amount":"1000000000",...}

event: status
data: {"id":"a1b2c3d4-...","status":"sweeping",...}

event: status
data: {"id":"a1b2c3d4-...","status":"swept","sweep_tx":"0xdef456...",...}
```

### Client Usage

```typescript
const source = new EventSource(`${apiUrl}/sessions/${sessionId}/events`)

source.addEventListener('status', (e) => {
  const session = JSON.parse(e.data)
  console.log(session.status) // "pending" | "detected" | "sweeping" | ...
})
```

## **POST** `/sessions/:id/register`

**Checkout only.** Attaches the buyer's freshly-generated burner + EIP-7702 authorization to a *merchant-created* checkout session (one created via `POST /v1/payment_sessions`, which starts with no burner). Anonymous P2P does *not* use this — it creates its session in one shot via `POST /sessions`. Gated by the publishable key + per-session `client_secret`; the 7702 auth is validated exactly like `commit` (target must be the PeltierDelegate, authority must equal the burner). The burner slot can only be filled once (while still `pending`) — a second attempt returns `409`, so a buyer's destination can never be redirected by re-registering.

### Request Body

```json
{
  "burner_address": "0x742d35Cc...bD18",
  "eip7702_auth": { "address", "chainId", "nonce", "r", "s", "yParity" }
}
// Headers: Authorization: Bearer pk_…   +   X-Client-Secret: cs_…
```

### Response 200 OK

```json
{ "id": "a1b2c3d4-...", "status": "pending" }
```

## **POST** `/sessions/:id/commit`

Commit a **burner-signed EIP-712 Intent** to the session. In the v2 **detect-then-sign** flow, commit happens *after* the deposit is detected, so the Intent is bound to the actually-deposited token and amount on the now-known source chain. The Intent is what the on-chain contract enforces (recipient, slippage floor, deposit identity), so a session cannot be swept until a valid Intent is committed.

The signature is an EIP-712 typed-data signature over the `Intent` struct, signed by the in-memory burner key. The EIP-712 domain binds `block.chainid`, so the signature is **per source chain**. Because detect-then-sign already knows the source chain, the client signs exactly **one** signature and submits it as a single-entry map keyed by the source chain id. (The endpoint accepts a map shape to support multi-source pre-signing, but the detect-then-sign client only ever sends one entry.)

For checkout sessions (`merchant_id` set) the request also requires the publishable key + per-session `client_secret` guard, and the server **rejects the commit** if `intent.destAddr` / `intent.destChainId` do not match the merchant's server-pinned destination (see [Checkout flow](/docs/flow-a-checkout.md)). Anonymous P2P sessions skip this guard.

### Request Body

The body carries the `burner` and its `eip7702_auth` (re-validated exactly like `register`) alongside the Intent. The Intent's uint256 fields are **decimal strings** and every key is **snake_case** on the wire (`dest_chain_id`, `dest_addr`, `min_out`, `in_token`, `max_in`, `app_fee_bps`, `app_fee_recipient`) — the SDK's `CommitIntentJson` shape, not the camelCase `Intent` struct.

```json
{
  "burner": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
  "eip7702_auth": { "address": "0x3333…ecD4", "chainId": 0, "nonce": 0, "r": "0x…", "s": "0x…", "yParity": 1 },
  // EIP-712 Intent struct (snake_case wire keys), bound to the detected deposit
  "intent": {
    "dest_chain_id": 8453,
    "dest_addr": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
    "min_out": "995000",            // on-chain slippage floor (NOT the checkout amount)
    "deadline": 1714003600,
    "salt": "0x9f1c...e2",
    "in_token": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // the DETECTED token
    "max_in": "1000000000",      // the DETECTED amount (tight RT-08 bind)
    "app_fee_bps": 0,            // app fee (0 for anonymous P2P; merchant sessions carry the merchant's configured bps)
    "app_fee_recipient": "0x0000000000000000000000000000000000000000" // app fee sink (zero when app_fee_bps == 0)
  },
  // one signature per source chain; detect-then-sign sends a single entry
  "intent_sigs": {
    "42161": "0x1b8e...c401"
  }
}
// Checkout sessions additionally send: Authorization: Bearer pk_…  +  X-Client-Secret: cs_…
```

### Response 200 OK

```json
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "detected",
  "fee_bps": 30,                // the PLATFORM fee resolved at commit (default 80 = 0.8%, capped 1%)
  "app_fee_bps": 0,             // the APP fee committed into the signed Intent (echoed back for display)
  "app_fee_recipient": "0x0000000000000000000000000000000000000000"
}
```

## **GET** `/sessions/:id/quote`

Returns a slippage floor for the detected `token → USDC` swap on the source chain, used by the client to derive `minOut` before committing the Intent. `suggested_min_out = floor(estimated_out × (1 - slippage_tolerance))`. For USDC-in deposits there is no swap, so the client skips the quote and uses the detected balance directly. Checkout sessions require the pk_ + `client_secret` guard; anonymous P2P is open like the rest of `/sessions/:id`.

### Response 200 OK

```json
{
  "estimated_out": "1000000",
  "suggested_min_out": "995000"
}
```

## **GET** `/sessions/:id/token-options?wallet=…`

“What can this wallet pay with” — the payer's balances, server-side. Pass the connected wallet as `wallet`: a `0x…` address is looked up across every supported EVM chain via Dune Sim (requires `DUNE_SIM_API_KEY` on the backend — keeping the key out of the browser bundle); a base58 pubkey is looked up via Jupiter's keyless API. Solana results include only Jupiter-known mints: the sweep settles through a Jupiter swap, so an unknown mint couldn't be swept anyway — the filter doubles as a routability check. The session id must name a real session; the endpoint sits under the same anonymous-route rate limiter as the rest of `/sessions/*`.

### Response 200 OK

```json
{
  "wallet": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
  "ecosystem": "evm",
  "options": [
    {
      "chain_id": 8453,
      "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
      "symbol": "USDC",
      "name": "USD Coin",
      "decimals": 6,
      "amount": "125000000",
      "price_usd": 0.9998,
      "value_usd": 124.97,
      "logo": "https://…/logo.png"
    }
  ]
}
```

`token` is a `0x` ERC-20 or `"native"` on EVM chains, a base58 mint or `"native"` on Solana. Options are sorted by USD value, descending.

## **POST** `/sessions/:id/refund`

Move a stranded balance at the burner back to the payer using a **burner-signed EIP-712 Refund**. In v2 the recipient is verified **on-chain** against the signed `Refund` struct (distinct `REFUND_TYPEHASH`, chain-bound) and pinned by the contract -- it is never operator-chosen. There is no `rpc_url` in the request; the backend submits via its own provider. The signature is EIP-712 typed data over `(token, to, amount, deadline, salt)`, signed by the in-memory burner key, and is therefore bound to a single `chain_id`.

### Request Body

```json
{
  "token": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
  "to": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
  "amount": "1000000000",
  "deadline": 1714003600,
  "salt": "0x9f1c...e2",
  "signature": "0x1b8e...c401",
  "chain_id": 42161
}
```

### Response 200 OK

```json
{
  "tx_hash": "0x7891def...456abc",
  "to": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
  "token": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"
}
```

## **POST** `/sessions/:id/standing-refund`

Attach a **standing refund** to an EVM session — the lost-key escape hatch. The manual refund above needs the burner key at claim time; a buyer who cleared their browser no longer has it. The standing refund is signed *once, up front*, while the key is still in hand: an EIP-712 `StandingRefund(to, deadline)` per source chain, saying "anything ever stranded at this burner may be returned to `to`, and nowhere else." The backend verifies every signature recovers to the burner and stores them, so the rescue authorization survives the buyer's browser. It can also be bundled directly into `POST /sessions` / `POST /sessions/:id/register` as `standing_refund`. Reusable and amount-free by design: the contract only ever moves the *current* balance to the pinned `to` (keeper-only before `deadline`, permissionless after — an unreachable operator can't strand funds).

### Request Body

```json
{
  "to": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
  "deadline": 1714086400,
  "signatures": { "1": "0x…", "8453": "0x…", "42161": "0x…" }
}
```

## **POST** `/sessions/:id/refund-all`

Execute a stored standing refund: bounce the burner's **full balance** of one asset to the pinned recipient. **No client signature needed** — works even after the buyer's browser (and burner key) are gone. The relayer submits `refundAll`; the contract re-verifies the stored signature on-chain and pins the recipient, so this endpoint cannot redirect anything. Only `expired` / `failed` sessions qualify. Sessions with a standing refund are also bounced *automatically* on terminal failure by a per-chain poller (the EVM twin of the Solana bounce) — this endpoint covers everything else, e.g. a late deposit found weeks later.

### Request Body

```json
{
  "token": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
  "chain_id": 42161
}
```

`token` is an ERC-20 address, or `"native"` for raw ETH (a native deposit that never got wrapped).

### Response 200 OK

```json
{
  "tx_hash": "0x7891def...456abc",
  "refund_address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
  "token": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
  "amount": "full"
}
```

## Lunar: stealth settle `experimental`

Two flows settle to an **ERC-5564 stealth address** so nothing on-chain links the payment to the recipient: **Flow E** (`flow: "stealth_request"` on `POST /sessions`, with a client-derived `stealth` announcement `{ ephemeral_pubkey, view_tag }`) and **Flow D** (`stealth: true` on `POST /v1/payment_sessions` after the merchant registers a meta-address via `PUT /v1/stealth-meta` — the backend derives a fresh one-time destination per session, which overrides the destinations table at commit). This is *confidential receiving*, not anonymity: privacy holds against chain observers and the payer, never against the operator (announcements are stored off-chain here AND client-side, redundantly — losing every copy of the ephemeral pubkey strands the funds).

`GET /sessions/:id/stealth` returns the announcement(s) (an `announcements` array; a Flow D session can carry one per scheme). Withdrawal on EVM is gasless via the `StealthForwarder` contract: the recipient recovers the stealth key, signs an EIP-3009 `receiveWithAuthorization` plus the forwarder's EIP-712 `Withdraw` (pinning destination + exact relay fee), and `POST /stealth/withdraw` relays it — the relayer pays gas and can change nothing. Settlement itself rides the untouched enforced-Intent machinery, so all non-custodial guarantees hold.

**Solana flavor (scheme 2)**: the same dual-key derivation on ed25519 — a stealth address IS a base58 Solana wallet, so settlement (same-chain program transfer or Across fill) simply targets its USDC ATA. There is *no contract* on this rail: the sweep rides native fee-payer separation. `POST /stealth/withdraw/solana/prepare` builds the full-balance sweep (relayer = fee payer; destination ATA created idempotently; stealth ATA closed, its rent to the relayer as the only relay compensation), the SDK signs the message with the raw stealth scalar (`sweepSolanaStealth` — a derived scalar has no seed, so wallets like Phantom cannot import it), and `POST /stealth/withdraw/solana` deterministically rebuilds the message from the structured params, verifies the stealth signature (`verify_strict`), co-signs and relays — when, never where or how much. Flow D derives the ed25519 leg automatically once the merchant registers `sol_spend_pubkey`/`sol_view_pubkey` on `PUT /v1/stealth-meta` (returned as `stealth_solana` at session creation); Flow E passes a 32-byte ed25519 `ephemeral_pubkey` with a base58 stealth destination.

Native USDC only on both rails. Stealth withdrawal is still **experimental** and mainnet-gated (see the [availability matrix](/docs/availability.md)). See the [Flow E — Stealth Request](/docs/flow-e-stealth-request.md) and [Flow D — Stealth Checkout](/docs/flow-d-stealth-checkout.md) guides for the full client-side flow.
