# Payment Links

A payment link is a reusable, shareable checkout entry point: one `POST` from your server mints a hosted page at `{APP_BASE_URL}/l/{slug}` that anyone can pay, with no code on your side beyond the create call. Each payment through a link is an ordinary [checkout session](/docs/flow-a-checkout.md) -- same server-pinned destination, same statuses, same [amount classification](/docs/amount-classification.md), same webhooks -- the link just mints the sessions for you.

Slugs are 10 base62 characters from the OS CSPRNG (≈59 bits -- unguessable enough for a public shareable URL). Like the rest of the `/v1/*` surface, links are gated behind `MERCHANT_MODE`; the public `/links/*` routes live inside the same gate.

## Per-Link Publishable Key

Every link auto-mints its **own dedicated `pk_` publishable key** at creation (name `payment-link:<slug>`), in the same transaction as the link row. Publishable keys are browser-safe by definition, and a per-link key means revoking one link's key never breaks another link or the merchant's own integrations. The raw key is persisted on the link row itself (only prefix + hash live in `api_keys`, so it would otherwise be unrecoverable) and served verbatim on the public resolve -- the hosted page needs it to drive the existing checkout surface.

## Merchant CRUD

| Method | Path | Auth | Description |
| --- | --- | --- | --- |
| **POST** | `/v1/payment_links` | `sk_` | Create a link (auto-mints its `pk_`). Requires at least one pinned settlement destination -- fails loud at create time instead of dead-ending every checkout at commit. |
| **GET** | `/v1/payment_links` | `sk_` | List newest-first: `{ data: [row… + session_count], total }`. Query: `limit` (default 20, max 100), `offset`. |
| **GET** | `/v1/payment_links/:id` | `sk_` | One link. 404 for foreign rows (IDOR opacity). |
| `PATCH` | `/v1/payment_links/:id` | `sk_` | Body is exactly `{ "active": bool }` -- the merchant's kill switch. |

Create body (`POST /v1/payment_links`) -- all fields optional:

```json
{
  "amount": "25.00",
  "currency": "USDC",
  "title": "Pro plan",
  "description": "One month of Pro",
  "metadata": { "plan": "pro" },
  "success_url": "https://acme.com/thanks",
  "expires_at": 1760000000
}
```

- `amount` is a **human decimal string** (`"25.00"`, at most 6 decimal places), unlike session amounts, which are USDC base units -- conversion happens once, at session creation. Omit it for an **open-amount** link (the payer chooses).
- `currency` defaults to `"USDC"`. `title` ≤ 200 chars, `description` ≤ 2000 chars, `metadata` ≤ 16 KiB encoded (it is copied onto every session the link spawns, and from there into webhook payloads).
- `success_url` is the post-payment redirect on the hosted page -- https-only, SSRF-guarded with the same rules as webhook URLs (the page redirects the *buyer* there, so an unsafe URL is a phishing hazard, not just a broken redirect).
- `expires_at` is unix seconds.

Response (also the GET/PATCH shape, and the list `data` element plus `session_count`):

```json
{
  "id": "a1b2c3d4-…",
  "slug": "Xk3nR7pQw2",
  "url": "https://peltier.dev/l/Xk3nR7pQw2",
  "amount": "25.00",
  "currency": "USDC",
  "title": "Pro plan",
  "description": "One month of Pro",
  "active": true,
  "expires_at": null,
  "created_at": "…"
}
```

`url` is built from the backend's `APP_BASE_URL` (normalized, no trailing slash).

## Public Surface

The hosted page drives two no-auth routes -- everything they return is buyer-visible by design.

**GET** `/links/:slug` -- resolve a link:

```json
{
  "slug": "Xk3nR7pQw2",
  "title": "Pro plan",
  "description": "One month of Pro",
  "amount": "25.00",
  "currency": "USDC",
  "active": true,
  "expired": false,
  "merchant_name": "Acme Inc",
  "publishable_key": "pk_…",
  "success_url": "https://acme.com/thanks",
  "supported_chains": [1, 10, 137, 8453, 42161, 143]
}
```

Inactive and expired links **still resolve**, with their flags set, so the page can render a proper "link inactive" state -- only session creation rejects. `supported_chains` lists deposit-capable chains only (HyperEVM is destination-only and is deliberately absent). The hosted page is EVM-only: Solana deposits are not offered on links today (the session-level Solana attach endpoints exist, but that path is unadvertised and unsupported on this surface).

**POST** `/links/:slug/sessions` -- mint a checkout session bound to the link's merchant. Body: `{ "amount": "…" }`, accepted **only** for open-amount links. Rejects with `410` when the link is inactive or expired. The amount matrix:

| Link | Body `amount` | Result |
| --- | --- | --- |
| Fixed | present | `400` -- the price is the merchant's. |
| Fixed | absent | The link's amount. |
| Open | present | The payer's amount. |
| Open | absent | `NULL` -- the payer sends what they want; classification's open-amount branch marks any sweep `succeeded`. |

Response, same shape as `POST /v1/payment_sessions` (30-minute TTL):

```json
{ "session_id": "…", "client_secret": "cs_…", "expires_at": 1751719800 }
```

The page then drives the **existing** checkout endpoints with the link's `pk_` + this session's `client_secret` -- there is no separate payment rail.

## Attribution & Webhooks

The webhook flow is unchanged: link payments emit the normal `payment.*` taxonomy ([Webhooks](/docs/webhooks.md)) to the merchant's endpoint. Three things tag a link payment for attribution:

- The session row carries `payment_link_id` (this also drives `session_count` in the list view; the FK is `ON DELETE SET NULL`, so deleting a link never erases session history -- the session row is the financial record).
- `order_id` is generated as `pl_<slug>_<8 base62>`, so link payments are greppable in webhook payloads.
- The session's `metadata` is the link's metadata plus `"payment_link": "<slug>"` -- every webhook consumer can attribute the payment without a second lookup.

## Expiry & Deactivation

`PATCH /v1/payment_links/:id { "active": false }` turns a link off; `expires_at` does the same on a schedule. Both degrade identically: the public resolve keeps working (the page renders the inactive state), new sessions get `410`, and sessions already in flight complete normally.
