# Merchant Accounts & API Keys

Merchant mode adds server-side checkout on top of the anonymous P2P flow. It is gated behind `MERCHANT_MODE` and exposed under `/v1/*`. The anonymous `/sessions` flow is unchanged: it stays no-auth with `merchant_id = NULL`. Authentication is **API-key only** -- there is no password or dashboard login this milestone.

## Key Types

| Key | Prefix | Use |
| --- | --- | --- |
| Secret key | `sk_` | Server-side only. Bearer token for every `/v1/*` endpoint (create sessions, manage keys/destinations/webhooks). **Never expose to the browser.** |
| Publishable key | `pk_` | Browser-safe. Combined with the per-session `client_secret` to authorize browser reads/commit/SSE on a checkout session. |
| Webhook secret | `whsec_` | Server-generated signing secret for HMAC-verifying webhook deliveries. Returned once on endpoint creation. |

Keys are high-entropy CSPRNG bearer tokens (`sk_`/`pk_` ≈ 142 bits of random body). The server stores only `SHA-256(full_key ‖ pepper)` plus a non-secret `key_prefix` for lookup and `key_last4` for display. The raw key is returned **exactly once** at mint time and is never logged or recoverable. Validation is timing-safe (`subtle::ConstantTimeEq`).

## Create a Merchant

**POST** `/v1/merchants` `no auth`

The only no-auth mint. Returns the secret and publishable keys once -- store the `secret_key` immediately.

```json
// POST body
{ "email": "ops@acme.com", "name": "Acme Inc" }

// 200 OK -- all four keys returned ONCE
{
  "id": "m_a1b2...",
  "email": "ops@acme.com",
  "name": "Acme Inc",
  "created_at": "2026-06-07T12:00:00Z",
  "keys": {
    "live": { "secret_key": "sk_live_…", "publishable_key": "pk_live_…" },
    "test": { "secret_key": "sk_test_…", "publishable_key": "pk_test_…" }
  },
  "secret_key": "sk_live_…",       // back-compat mirror of keys.live
  "publishable_key": "pk_live_…"   // back-compat mirror of keys.live
}
```

## Test vs Live Keys

`POST /v1/merchants` actually mints **two** key pairs at once -- a live pair (`sk_live_`/`pk_live_`, mainnet, real money) and a test pair (`sk_test_`/`pk_test_`, testnets, no real money) -- returned under `keys.live` and `keys.test` (the top-level `secret_key`/`publishable_key` mirror the **live** pair for back-compat). The mode is encoded in the key prefix: any `sk_test_`/`pk_test_` key is test, any `sk_live_`/`pk_live_` key is live, and the two modes are fully isolated server-side. Keys minted via `POST /v1/api_keys` report their resolved `"mode"`. See [Test mode](/docs/test-mode.md) for the full dual-mint response, the testnet chains, and a no-real-funds Quick Start.

## Manage Keys

| Method | Path | Auth | Description |
| --- | --- | --- | --- |
| **POST** | `/v1/api_keys` | `sk_` | Mint an additional `secret` or `publishable` key. Raw key returned once. |
| **GET** | `/v1/api_keys` | `sk_` | List keys (prefix + last4 only, never the raw key), including `revoked_at`. |
| `DELETE` | `/v1/api_keys/:id` | `sk_` | Revoke a key. Revoked keys are rejected immediately on the next request (no cache). |

> **Payment-link keys.** Every [payment link](/docs/payment-links.md) auto-mints its own dedicated `pk_` key, named `payment-link:<slug>` — these show up in the `GET /v1/api_keys` listing alongside your own keys. Revoking one (`DELETE /v1/api_keys/:id`) **bricks that link's hosted page**: the public resolve keeps serving the now-dead key (the link row stores the raw key, with no FK to the key row), so every checkout attempt from the page fails. Deactivate the link itself (`PATCH /v1/payment_links/:id`) instead; only revoke its key if you mean to kill the link's payments immediately and for good.

## Settlement Destinations

A merchant pins one settlement address per chain. The checkout commit step enforces that the buyer's Intent destination matches the pinned destination -- the operator cannot redirect funds.

| Method | Path | Auth | Description |
| --- | --- | --- | --- |
| **GET** | `/v1/destinations` | `sk_` | List pinned `{ chain_id, address }` destinations. |
| `PUT` | `/v1/destinations/:chain_id` | `sk_` | Upsert the settlement address for a supported chain. |

## App Fee (your own cut)

Peltier charges a **platform fee** — its own cut, the admin-controlled `commission`, applied default 0.8%, hard-capped on-chain at 1% (`MAX_FEE_BPS`). On top of that a merchant may set its **own app fee**: an additional cut at any rate you choose, sunk to your own address. The app fee is **pinned into the buyer-signed Intent** (and the Solana routing hash), so the operator can never redirect or inflate it; it carries **no** on-chain cap and is bounded only by the payment's net floor (`net = received − platform fee − app fee`). Default is `0` — apps opt in.

| Method | Path | Auth | Description |
| --- | --- | --- | --- |
| **GET** | `/v1/app-fee` | `sk_` | Read your app fee: `{ app_fee_bps, app_fee_address_evm, app_fee_address_sol }`. |
| `PUT` | `/v1/app-fee` | `sk_` | Set it: `{ app_fee_bps, app_fee_address_evm?, app_fee_address_sol? }`. `app_fee_bps >= 0`; when `app_fee_bps > 0` you must supply at least one recipient address (EVM `0x…` and/or Solana base58), validated on write. |

```json
// PUT /v1/app-fee
{
  "app_fee_bps": 250,
  "app_fee_address_evm": "0x3333333333333333333333333333333333333333",
  "app_fee_address_sol": null
}
```

At checkout commit the server resolves your configured `app_fee_bps` + recipient into the session, and the SDK signs them into the Intent (echoed back as `app_fee_bps` / `app_fee_recipient` / `app_fee_recipient_sol` on the session object). Anonymous P2P sessions (`merchant_id = NULL`) always carry `app_fee_bps = 0`. The **platform** fee is controlled separately by the platform admin via `/admin/merchants/{id}/commission`, not by this endpoint.

## Sessions & Requests

| Method | Path | Auth | Description |
| --- | --- | --- | --- |
| **POST** | `/v1/payment_sessions` | `sk_` | Create a fixed-amount checkout session — request/response shapes in [Flow A](/docs/flow-a-checkout.md). |
| **GET** | `/v1/payment_sessions/:id` | `sk_` | Read one session's `status` + `payment_outcome` (404 if not yours — IDOR mitigation). |
| **GET** | `/v1/payment_sessions` | `sk_` | List your checkout sessions, newest first. Params: `limit`/`offset`, optional `status` (exact) and `q` (substring over session id / destination / burner / order id). |
| **GET** | `/v1/requests` | `sk_` | List the p2p requests created in your app via a `pk_`-tagged widget, newest first, incl. the on-chain trail (`detected_tx`/`sweep_tx`/`bridge_tx`/`dest_tx`). Params: `limit`/`offset`, optional `status` and `q`. SDK helper `listRequests(apiUrl, sk_, params?)` from `@peltier/core` / `@peltier/node` (no longer exported from the browser package) — see [Flow B](/docs/flow-b-request.md). |

All four are scoped to the authenticated merchant **and its mode** (a `sk_test_` key only ever sees test resources).

## Idempotency

`POST /v1/payment_sessions` accepts **no idempotency key** — a retried create mints a brand-new session, so dedupe on your own `order_id` before calling (the list endpoint's `q` param searches order ids). `POST /v1/subscriptions` is naturally idempotent in practice: the mandate digest (`sub_id`) is unique and re-POSTing the same payer-signed mandate returns `409` rather than a duplicate. The `Idempotency-Key` header you see on [webhook deliveries](/docs/webhooks.md) is outbound only.
