# Payment links — Swaps API

Request money — links, clients, products, payer sessions.

**Jump to an operation:**

- [GET /payment_links](#list-payment-links-available)
- [POST /payment_links](#draft-a-payment-link-available)
- [GET /payment_links/{id}](#get-a-payment-link-available)
- [PATCH /payment_links/{id}](#update-a-draft-payment-link-available)
- [POST /payment_links/{id}/activate](#activate-a-payment-link-available)
- [POST /payment_links/{id}/cancel](#cancel-a-payment-link-available)
- [POST /payment_links/{id}/send_invoice](#email-the-invoice-to-the-payer-available)
- [GET /payment_links/{id}/reminder_schedule](#get-the-reminder-schedule-available)
- [PATCH /payment_links/{id}/reminder_schedule](#set-the-reminder-cadence-available)
- [POST /payment_links/{id}/reminder_schedule/disable](#turn-reminders-off-available)
- [GET /payment_links/{id}/events](#list-a-links-events-available)
- [GET /payment_links/{id}/receipt](#get-the-collection-receipt-available)
- [GET /clients](#list-clients-available)
- [POST /clients](#save-a-client-available)
- [GET /clients/{id}](#get-a-client-available)
- [PATCH /clients/{id}](#update-a-client-available)
- [POST /clients/{id}/archive](#archive-a-client-available)
- [GET /products](#list-products-available)
- [POST /products](#save-a-product-available)
- [GET /products/{id}](#get-a-product-available)
- [PATCH /products/{id}](#update-a-product-available)
- [POST /products/{id}/archive](#archive-a-product-available)
- [GET /payment_sessions/{token}](#read-a-payer-session-available)
- [GET /payment_sessions/by_code/{short_code}](#resolve-a-short-code-to-a-payer-session-available)
- [POST /payment_sessions/{token}/view](#mark-the-session-viewed-available)
- [POST /payment_sessions/{token}/payments](#select-a-rail-and-start-a-payment-available)
- [GET /payment_sessions/{token}/payments/{payment_id}](#poll-a-payments-status-available)
- [POST /payment_sessions/{token}/receipt_email](#set-the-payers-receipt-email-available)
- [POST /payment_sessions/{token}/mark_sent](#payer-self-report-ive-sent-it-available)
- [POST /payment_sessions/{token}/pay_with_wallet](#prepare-a-one-tap-swaps-wallet-payment-planned)

> Generated from the bundled OpenAPI contract. The HTML page and this Markdown twin share the same processed operation descriptions and status annotations.

<a id="list-payment-links-available"></a>
## `GET /payment_links` — List payment links — available

**Operation ID:** `payment_links.list`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.read` · **Test mode:** unavailable

List this account's payment requests, newest first (business key, agent, dashboard session; test mode is unavailable (`503 temporarily_unavailable`)). `status_group` matches the dashboard's chip buckets (RESOURCE-MODEL §2.1 v2 amendment); `q` searches title, memo and invoice number; `settlement_kind` narrows to one settlement kind. Do not use this to poll one link — use `GET /v1/payment_links/{id}`.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `Limit` | component | — | — | Reusable parameter component |
| `Cursor` | component | — | — | Reusable parameter component |
| `status_group` | query | no | `open` \| `needs_attention` \| `paid` \| `ended` | This resource's own bucket set (`open`, `needs_attention`, `paid`, `ended`), not shared with other resources (CMP-6). |
| `q` | query | no | `string` | — |
| `client_id` | query | no | `string` | — |
| `settlement_kind` | query | no | `bridge` \| `crypto_only` | Only links whose published `settlement_kind` is this value (CP-T1). `crypto_only` is the Crypto processing hub's list; `bridge` is every Bridge-settled link. Applies to the page and to `?expand=summary`. A draft created with `accepted_rail_kinds: [crypto]` publishes `settlement_kind: null` until it activates, so neither value matches it. Any other value is `400 invalid_request` with `param: settlement_kind`. |
| `expand` | query | no | `string` | Comma-separated. `summary` (LIST-SUMMARY-1) adds `summary`: counts computed server-side over EVERY row matching this request's filters, not over the returned page, so a client never adds up a paginated page and calls it a total. `limit`/`cursor` never change it. An unrecognised value is ignored rather than rejected (the `GET /account` `expand` rule). |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | A page of payment links. | `application/json` [PaymentLinkList](/reference/~schemas.md#paymentlinklist) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="draft-a-payment-link-available"></a>
## `POST /payment_links` — Draft a payment link — available

**Operation ID:** `payment_links.create`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.write` · **Test mode:** unavailable

Create a draft — nothing is charged and no rail goes live until activate (D-15). Business key, agent, dashboard session; test mode is unavailable (`503 temporarily_unavailable`). Setting `accepted_rail_kinds: [crypto]` records an intent to settle to your own Swaps Wallet — there is no separate `settlement_kind` input. USD uses the Bridge-less crypto-only splitter, gated by `payment_links_crypto_only`; a closed capability answers `409 capability_unavailable`. Non-USD uses Bridge collection to the wallet, gated by `payment_links_tempo_via_bridge` alone; when off, create answers `422 tempo_via_bridge_not_enabled`. That path can be bank-payable and requires Bridge eligibility at activation. Every OTHER non-empty `accepted_rail_kinds` value (`[bank]`, `[card]`, or more than one kind) answers `409 capability_unavailable`. An invalid `allowed_rails` is refused with `400 allowed_rails_invalid_for_currency`, with usable rails in `error.details.offerable_rails`. On a USD wallet request, any non-empty restriction instead answers `400 allowed_rails_invalid_for_settlement`, with `crypto_tempo` as the offerable rail; clear the restriction. This splitter restriction does not apply to non-USD wallet requests. `settlement_destination` names an owned saved `/v1/address_book` entry, never raw bank details or an address. An unowned id answers `404 not_found`, identical to an unknown one. It can also be set later with `update`, which retires the Swaps Wallet intent. Naming both `settlement_destination` and `accepted_rail_kinds: [crypto]` on this create answers `400 settlement_conflicts_with_destination` after the currency-specific capability gate passes; a closed gate is reported first. A `settlement_destination` that is a Swaps Wallet address is admitted only as every inbound door admits one — on Tempo, with a key Swaps can confirm — before anything is written: `422 destination_rail_keyless`, `destination_wallet_disabled`, `destination_wallet_key_reported_lost`, `destination_wallet_signer_at_risk` or `destination_wallet_signer_unconfirmed` (`invalid_request`; choose another destination — the same request is refused again), and `503 destination_risk_unverifiable` (`temporarily_unavailable`) when the check could not run: nothing was written, retry after `Retry-After` with the same `Idempotency-Key`. These exact codes go only to the wallet's own holder; for anyone else's Swaps Wallet the answer is `422 destination_not_accepted`, and every caller gets `503 destination_check_unavailable` (handled like the 503 above) when the wallet or key-mark lookup itself failed.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Request body

Required: **yes**

| Media type | Schema |
| --- | --- |
| `application/json` | [PaymentLinkCreateRequest](/reference/~schemas.md#paymentlinkcreaterequest) |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `201` | The created draft. | `application/json` [PaymentLink](/reference/~schemas.md#paymentlink) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | `temporarily_unavailable`. A failed dependency read (`details.reason`: `counterparty_read_failed`, `account_read_failed`, `wallet_read_failed`, `address_book_read_failed`, …). Not side-effect free: no `Retry-After`, a same-key replay answers `409 idempotency_failed`; retry with a new `Idempotency-Key`. A kill switch also answers here. `destination_risk_unverifiable` or `destination_check_unavailable`: the settlement destination could not be checked — side-effect free, retry after `Retry-After` with the same `Idempotency-Key`. | `application/json` [Error](/reference/~schemas.md#error) |

---

<a id="get-a-payment-link-available"></a>
## `GET /payment_links/{id}` — Get a payment link — available

**Operation ID:** `payment_links.get`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.read` · **Test mode:** unavailable

Read one payment link the caller owns (business key, agent, dashboard session; test mode is unavailable (`503 temporarily_unavailable`)).

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The payment link. | `application/json` [PaymentLink](/reference/~schemas.md#paymentlink) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="update-a-draft-payment-link-available"></a>
## `PATCH /payment_links/{id}` — Update a draft payment link — available

**Operation ID:** `payment_links.update`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.write` · **Test mode:** unavailable

Draft-only (RESOURCE-MODEL §2.1) — refused with conflict once the link has left draft. A partial merge: an omitted field keeps its stored value. `allowed_rails: null` is the one way to REMOVE a rail restriction, after which the link offers every rail available for its currency; `[]` is a `400`, not a clear. A request that CHANGES the currency or the rails re-checks that pair against the same yardstick `activate` uses (`400 allowed_rails_invalid_for_currency`, with `error.details.offerable_rails`); re-sending the stored value alongside another edit changes nothing and is never refused. A `settlement_destination`-only change is NOT re-checked against the stored rail restriction here — that pairing is judged at `activate`, which can still refuse a combination this PATCH accepted. `settlement_destination` (BL-50) follows the identical three-state convention as `allowed_rails`: omit to leave it, `null` to clear it, `{address_book_id}` to set it — a foreign or unknown id is `404 not_found`, never a distinguishing error. A `settlement_destination` that is a Swaps Wallet address is refused before anything is written unless every inbound door would admit it, with the codes `create` answers: `422 destination_rail_keyless`, `destination_wallet_disabled`, `destination_wallet_key_reported_lost`, `destination_wallet_signer_at_risk` or `destination_wallet_signer_unconfirmed`, and `503 destination_risk_unverifiable` (side-effect free) when the check could not run — to the wallet's own holder; for anyone else's wallet `422 destination_not_accepted`, and `503 destination_check_unavailable` for every caller when the wallet or key-mark lookup itself failed. Business key, agent, dashboard session; test mode is unavailable (`503 temporarily_unavailable`).

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Request body

Required: **yes**

| Media type | Schema |
| --- | --- |
| `application/json` | [PaymentLinkUpdateRequest](/reference/~schemas.md#paymentlinkupdaterequest) |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The updated draft. | `application/json` [PaymentLink](/reference/~schemas.md#paymentlink) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | `temporarily_unavailable`. A failed dependency read (`details.reason`: `link_read_failed`, `items_read_failed`, `account_read_failed`, …). Not side-effect free: no `Retry-After`, a same-key replay answers `409 idempotency_failed`; read the link, then retry with a new `Idempotency-Key`. A kill switch also answers here. `destination_risk_unverifiable` or `destination_check_unavailable`: the settlement destination could not be checked — side-effect free, retry after `Retry-After` with the same `Idempotency-Key`. | `application/json` [Error](/reference/~schemas.md#error) |

---

<a id="activate-a-payment-link-available"></a>
## `POST /payment_links/{id}/activate` — Activate a payment link — available

**Operation ID:** `payment_links.activate`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.write` · **Test mode:** unavailable · **Money boundary:** per-transaction human confirmation required

The money boundary (D-15): runs the eligibility gate, records the merchant's compliance attestation as evidence, takes a settlement snapshot, mints the live url. REST-only — excluded from MCP because an agent must never set attestation_accepted on the merchant's behalf. A draft whose `allowed_rails` leaves it with no payable rail at all is refused `409 allowed_rails_invalid_for_currency` (with `error.details.offerable_rails`) and stays a draft — clear the restriction with `allowed_rails: null` or change the currency, then activate. A draft whose only payer rail would be `crypto_relay` above its per-invoice cap (the `crypto_relay` corridor's `max_amount` in `capabilities.get`) is refused `422 no_payable_rail` with `error.details` = {`rail`, `reason`: `amount_above_rail_maximum`, `max_amount`} and stays a draft; a draft any other rail can pay activates as before (Relay delivers on the Tempo leg, so `crypto_tempo` pays such a link today). A draft whose only payer rail would be `pix` while the merchant's Pix deposit approval at Bridge is not in place is refused `422 no_payable_rail` with `error.details` = {`rail`: `pix`, `reason`: `merchant_rail_not_enabled`} and stays a draft: Pix is offered to a payer only once that approval exists, for every merchant. A draft edited between the read and the write is refused `409` too, rather than activated on a value that has moved. A wallet request (`settlement: 'swaps_wallet'`, settling to the merchant's own Swaps Wallet) is routed by currency (§52.33, PL-TEMPO-BRIDGE-4-T, issue #3839): a `USD` link takes the Bridge-less `crypto_only` splitter — `currency` not `USD` there is refused `422 crypto_only_currency_not_usd` (the `crypto_tempo` rail has no FX leg, so a non-USD link would pay out its face amount in USD-stablecoins 1:1, not the honest converted figure — C4-D27; unreachable through this operation today since a non-USD wallet request never reaches this branch, kept as a defense-in-depth backstop). A NON-`USD` wallet request instead settles through the SAME Bridge collection path (`payment_rail: tempo` + the account's own wallet address, resolved server-side — no address-book entry required) an address-book Tempo destination uses, gated by `payment_links_tempo_via_bridge` alone — the SAME flag `capabilities.get`'s `settlement_currencies[currency].tempo_wallet` reads, so an `available: true` account can always activate: `off` for this merchant is refused `422 tempo_via_bridge_not_enabled` (mirrors capabilities' own `reason`), an admitted merchant with no Swaps Wallet on file is refused `422 wallet_not_provisioned`, and an admitted merchant whose Swaps Wallet is not on THIS project's Tempo network (`tempo-mainnet` in production) is refused `422 wallet_network_not_supported` — `capabilities.get` reads the SAME wallet row and reports `available: false` with the matching `reason` for both. The identical no-FX hazard is refused `422 tempo_settlement_currency_not_usd` for an ORDINARY draft whose chosen address-book settlement destination resolves to the Tempo splitter (not routed via Bridge) — the same rail, reached by a different door. All five carry `error.details.currency` (plus `required_currency` on the two `_not_usd` codes). An ORDINARY (non-wallet) draft with no `settlement_destination` set (BL-50, via `create`/`update`) is refused `400 settlement_destination_required` and stays a draft; one set but unusable for this link's rails is refused `422 settlement_rail_unsupported` (the destination's rail has no offramp route), `422 settlement_account_holder_missing` or `422 settlement_details_incomplete` (the address-book entry itself is missing a field Bridge requires) — pick or complete a different destination and retry. Business key, agent, dashboard session; test mode is unavailable (`503 temporarily_unavailable`). A USD crypto-only draft activates on the Bridge-less eligibility branch (R19 CP-G2); a non-USD wallet draft activates on the SAME Bridge eligibility (KYB/KYC, EEA, p2p cap) an address-book destination needs. A crypto-only draft (`accepted_rail_kinds: [crypto]`) activates without e-mailing its invoice, and one that still carries a reminder schedule is refused `422 invalid_request` (`param: reminder_schedule`) and stays a draft until reminders are disabled. Every crypto settlement destination — a chosen address-book entry, or the account's own Swaps Wallet on a wallet request — is checked again here, before any provider call and any write, as every inbound door checks a Swaps Wallet: `422 destination_rail_keyless`, `destination_wallet_disabled`, `destination_wallet_key_reported_lost`, `destination_wallet_signer_at_risk` or `destination_wallet_signer_unconfirmed` (`invalid_request`; the link stays a draft — change the destination, the same request is refused again), and `503 destination_risk_unverifiable` (`temporarily_unavailable`) when the check could not run: the link stays a draft, retry after `Retry-After` with the same `Idempotency-Key`. These exact codes go only to the wallet's own holder (always, for the account's own wallet on a wallet request); for anyone else's Swaps Wallet the answer is `422 destination_not_accepted`, and every caller gets `503 destination_check_unavailable`, handled the same way, when the wallet or key-mark lookup itself failed.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Request body

Required: **yes**

| Media type | Schema |
| --- | --- |
| `application/json` | [PaymentLinkActivateRequest](/reference/~schemas.md#paymentlinkactivaterequest) |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The activated link. | `application/json` [PaymentLink](/reference/~schemas.md#paymentlink) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | `temporarily_unavailable`. `details.reason` `settlement_provider_unavailable` — the payment provider did not respond while the destination was set up, and the link is still a `draft`; or a failed dependency read (`account_read_failed`, `wallet_read_failed`, `address_book_read_failed`, `link_read_failed`, `eligibility_lookup_failed`, …). Not side-effect free: no `Retry-After`, a same-key replay answers `409 idempotency_failed`; read the link, then retry with a new `Idempotency-Key`. A kill switch also answers here. `destination_risk_unverifiable` or `destination_check_unavailable`: the settlement destination could not be checked — the link is still a `draft`; side-effect free, retry after `Retry-After` with the same `Idempotency-Key`. | `application/json` [Error](/reference/~schemas.md#error) |

---

<a id="cancel-a-payment-link-available"></a>
## `POST /payment_links/{id}/cancel` — Cancel a payment link — available

**Operation ID:** `payment_links.cancel`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.write` · **Test mode:** unavailable

Cancel an unpaid link and void any payment the payer has started. One operation covers every confirm copy the dashboard shows (discard a draft, cancel a live link, cancel while funds are pending) — there is no separate discard verb (R11 PL-G10). Calling it twice is safe. Business key, agent, dashboard session; test mode is unavailable (`503 temporarily_unavailable`). A partially paid (`underpaid`) `crypto_tempo`/`crypto_relay` payment is not voided: it is kept as `unmatched` for support (money held, `amount_received` = its running total, `unmatched_reason` = `partial_before_cancel`) and `payment.unmatched` is emitted once. While a payment is being settled — including a `crypto_relay` payment whose funds Relay has received and not refunded (a Relay refund counts only once its refund transaction is recorded) — the whole cancel answers `409 conflict` and changes nothing.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The cancelled link. | `application/json` [PaymentLink](/reference/~schemas.md#paymentlink) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | `temporarily_unavailable`. A failed dependency read (`details.reason`: `attempts_read_failed`, `link_read_failed`, `eligibility_lookup_failed`). Not side-effect free: no `Retry-After`, a same-key replay answers `409 idempotency_failed`; read the link, then retry with a new `Idempotency-Key`. A kill switch also answers here. A payment provider refusing to cancel an open transfer is `500 internal_error`. | `application/json` [Error](/reference/~schemas.md#error) |

---

<a id="email-the-invoice-to-the-payer-available"></a>
## `POST /payment_links/{id}/send_invoice` — Email the invoice to the payer — available

**Operation ID:** `payment_links.send_invoice`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.write` · **Test mode:** unavailable

Email or re-email the payment link to its payer. Requires payer_email to already be set on the link; the reminder cadence is a separate call to PATCH .../reminder_schedule. A crypto-only link (`accepted_rail_kinds: [crypto]`) answers `409 conflict`: the invoice is not e-mailed on the crypto rail today, so share its `url` instead. Business key, agent, dashboard session; Test mode is unavailable (`503 temporarily_unavailable`).

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | Send accepted. | `application/json` [PaymentLink](/reference/~schemas.md#paymentlink) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | `temporarily_unavailable`. Code `mail_unavailable` (`details.reason: invoice_email_not_sent`) — the invoice email could not be confirmed as sent: it may not have left, may never be deliverable to this payer, or may already have arrived. Not side-effect free: no `Retry-After`, a same-key replay answers `409 idempotency_failed`; read the link before sending again with a new `Idempotency-Key`. Code `temporarily_unavailable` — a kill switch, or a failed dependency read (`details.reason`, e.g. `link_read_failed`); not side-effect free, no `Retry-After`. | `application/json` [Error](/reference/~schemas.md#error) |

---

<a id="get-the-reminder-schedule-available"></a>
## `GET /payment_links/{id}/reminder_schedule` — Get the reminder schedule — available

**Operation ID:** `payment_links.reminder_schedule.get`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.read` · **Test mode:** unavailable

Read the cadence and what has already fired. Business key, agent, dashboard session; test mode is unavailable (`503 temporarily_unavailable`).

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The reminder schedule. | `application/json` [ReminderSchedule](/reference/~schemas.md#reminderschedule) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="set-the-reminder-cadence-available"></a>
## `PATCH /payment_links/{id}/reminder_schedule` — Set the reminder cadence — available

**Operation ID:** `payment_links.reminder_schedule.update`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.write` · **Test mode:** unavailable

Set offsets_days[]. Editable while the link is draft, active or viewed (RESOURCE-MODEL §2.1). sent[] is server-owned and ignored if supplied. A crypto-only link (`accepted_rail_kinds: [crypto]`) takes no schedule: reminders are not delivered on the crypto rail today, so it answers `422 invalid_request` (`param: reminder_schedule`); disabling stays allowed. Business key, agent, dashboard session; test mode is unavailable (`503 temporarily_unavailable`).

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Request body

Required: **yes**

| Media type | Schema |
| --- | --- |
| `application/json` | [ReminderScheduleUpdateRequest](/reference/~schemas.md#reminderscheduleupdaterequest) |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The updated schedule. | `application/json` [ReminderSchedule](/reference/~schemas.md#reminderschedule) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | `temporarily_unavailable`. A failed dependency read (`details.reason`, e.g. `link_read_failed`). Not side-effect free: no `Retry-After`, a same-key replay answers `409 idempotency_failed`; read the schedule, then retry with a new `Idempotency-Key`. A kill switch also answers here. | `application/json` [Error](/reference/~schemas.md#error) |

---

<a id="turn-reminders-off-available"></a>
## `POST /payment_links/{id}/reminder_schedule/disable` — Turn reminders off — available

**Operation ID:** `payment_links.reminder_schedule.disable`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.write` · **Test mode:** unavailable

Sets the schedule's status to off without discarding offsets_days[], so re-enabling does not require re-entering the cadence. Business key, agent, dashboard session; test mode is unavailable (`503 temporarily_unavailable`).

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The disabled schedule. | `application/json` [ReminderSchedule](/reference/~schemas.md#reminderschedule) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | `temporarily_unavailable`. A failed dependency read (`details.reason`, e.g. `link_read_failed`). Not side-effect free: no `Retry-After`, a same-key replay answers `409 idempotency_failed`; read the schedule, then retry with a new `Idempotency-Key`. A kill switch also answers here. | `application/json` [Error](/reference/~schemas.md#error) |

---

<a id="list-a-links-events-available"></a>
## `GET /payment_links/{id}/events` — List a link's events — available

**Operation ID:** `payment_links.events.list`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.read` · **Test mode:** sandbox

This link's own event timeline, newest first (RESOURCE-MODEL §2.1 "events (per link)") — merges the K8 events outbox (`api_events`) with the older `payment_link_events` audit table, so a link's full history is returned whether or not it predates the outbox. Only the 11 publishable `payment_link.*` types are ever returned (D-12, RESOURCE-MODEL §3); 9 internal audit types have no public counterpart and are never published. Callers: business key, agent, dashboard session. Test mode: sandbox (K11-6, §4.2 drift, was declared `full`) — a test object's events are written by DEV handlers into DEV's own `api_events` outbox; a prod-local read would return an empty page forever. BL-16 (2026-09-14): closes the Timeline card's only missing read.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `Limit` | component | — | — | Reusable parameter component |
| `Cursor` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | A page of events on this link. | `application/json` [PaymentLinkEventList](/reference/~schemas.md#paymentlinkeventlist) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="get-the-collection-receipt-available"></a>
## `GET /payment_links/{id}/receipt` — Get the collection receipt — available

**Operation ID:** `payment_links.receipt.get`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.read` · **Test mode:** unavailable

The merchant's receipt for a collected link (RESOURCE-MODEL §2.1 v2 amendment: mirrors the payout receipt). Available only while the link's own status is `paid` or `settled` and exactly one payment completed it; any other state — draft, open, `processing` (including a short payment held for review), expired, cancelled, refunded — answers `409 receipt_not_available`. A link the caller does not own is `404`. JSON only: no PDF or HTML rendition exists yet; the payer's receipt is the e-mail `swaps-pl-payer-receipt`. Business key, agent, dashboard session.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The receipt. | `application/json` [PaymentLinkReceipt](/reference/~schemas.md#paymentlinkreceipt) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="list-clients-available"></a>
## `GET /clients` — List clients — available

**Operation ID:** `clients.list`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.read` · **Test mode:** unavailable

List this account's saved clients, active only — archived clients are hidden. Business key, agent, dashboard session.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `Limit` | component | — | — | Reusable parameter component |
| `Cursor` | component | — | — | Reusable parameter component |
| `q` | query | no | `string` | — |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | A page of clients. | `application/json` [ClientList](/reference/~schemas.md#clientlist) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="save-a-client-available"></a>
## `POST /clients` — Save a client — available

**Operation ID:** `clients.create`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.write` · **Test mode:** unavailable

Save a client so future payment links can be addressed to them. Business key, agent, dashboard session.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Request body

Required: **yes**

| Media type | Schema |
| --- | --- |
| `application/json` | [ClientCreateRequest](/reference/~schemas.md#clientcreaterequest) |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `201` | The created client. | `application/json` [Client](/reference/~schemas.md#client) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="get-a-client-available"></a>
## `GET /clients/{id}` — Get a client — available

**Operation ID:** `clients.get`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.read` · **Test mode:** unavailable

Read one client the caller owns. Business key, agent, dashboard session.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The client. | `application/json` [Client](/reference/~schemas.md#client) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="update-a-client-available"></a>
## `PATCH /clients/{id}` — Update a client — available

**Operation ID:** `clients.update`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.write` · **Test mode:** unavailable

Business key, agent, dashboard session.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Request body

Required: **yes**

| Media type | Schema |
| --- | --- |
| `application/json` | [ClientUpdateRequest](/reference/~schemas.md#clientupdaterequest) |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The updated client. | `application/json` [Client](/reference/~schemas.md#client) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="archive-a-client-available"></a>
## `POST /clients/{id}/archive` — Archive a client — available

**Operation ID:** `clients.archive`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.write` · **Test mode:** unavailable

Sets archived_at; never a hard delete. Business key, agent, dashboard session.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The archived client. | `application/json` [Client](/reference/~schemas.md#client) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="list-products-available"></a>
## `GET /products` — List products — available

**Operation ID:** `products.list`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.read` · **Test mode:** unavailable

List this account's saved products, active only — archived products are hidden. Business key, agent, dashboard session.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `Limit` | component | — | — | Reusable parameter component |
| `Cursor` | component | — | — | Reusable parameter component |
| `q` | query | no | `string` | — |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | A page of products. | `application/json` [ProductList](/reference/~schemas.md#productlist) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="save-a-product-available"></a>
## `POST /products` — Save a product — available

**Operation ID:** `products.create`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.write` · **Test mode:** unavailable

Save a reusable line item. Business key, agent, dashboard session.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Request body

Required: **yes**

| Media type | Schema |
| --- | --- |
| `application/json` | [ProductCreateRequest](/reference/~schemas.md#productcreaterequest) |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `201` | The created product. | `application/json` [Product](/reference/~schemas.md#product) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="get-a-product-available"></a>
## `GET /products/{id}` — Get a product — available

**Operation ID:** `products.get`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.read` · **Test mode:** unavailable

Read one product the caller owns. Business key, agent, dashboard session.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The product. | `application/json` [Product](/reference/~schemas.md#product) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="update-a-product-available"></a>
## `PATCH /products/{id}` — Update a product — available

**Operation ID:** `products.update`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.write` · **Test mode:** unavailable

Business key, agent, dashboard session. Existing line items on already-created links keep their locked price.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Request body

Required: **yes**

| Media type | Schema |
| --- | --- |
| `application/json` | [ProductUpdateRequest](/reference/~schemas.md#productupdaterequest) |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The updated product. | `application/json` [Product](/reference/~schemas.md#product) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="archive-a-product-available"></a>
## `POST /products/{id}/archive` — Archive a product — available

**Operation ID:** `products.archive`

> **Status:** Available · **Callers:** business key, agent (business key), dashboard session · **Scope:** `payment_links.write` · **Test mode:** unavailable

Sets archived_at; never a hard delete. Business key, agent, dashboard session.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The archived product. | `application/json` [Product](/reference/~schemas.md#product) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="read-a-payer-session-available"></a>
## `GET /payment_sessions/{token}` — Read a payer session — available

**Operation ID:** `payment_sessions.get`

> **Status:** Available · **Callers:** public token · **Test mode:** unavailable

A pure read (D-4) — never advances active to viewed; call POST .../view for that transition. Resolves exactly one link's payer projection off the capability token in the path; structurally unreachable with a business key or a dashboard session (RESOURCE-MODEL §0.9). Public token only, no key. Test mode does not apply: a test-mode link's token lives on DEV and is `404` here.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `token` | path | yes | `string` | A plk_ capability token — a secret, never an id: never logged, never echoed. |
| `SwapsVersion` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The payer's projection of the link. | `application/json` [PaymentSession](/reference/~schemas.md#paymentsession) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="resolve-a-short-code-to-a-payer-session-available"></a>
## `GET /payment_sessions/by_code/{short_code}` — Resolve a short code to a payer session — available

**Operation ID:** `payment_sessions.by_code.get`

> **Status:** Available · **Callers:** public token · **Test mode:** unavailable

C5-SHORT-LINK — resolves a payment link's `short_code` (the last segment of `PaymentLink.short_url`) to its payer session token and payer page `url`, so a short URL reaches the payer page. A pure read, never a status change. Public, no key — the same caller model and error envelope as `GET /v1/payment_sessions/{token}`, but its own stricter per-IP rate limit (10/min), since a hit unlocks the full payer token. Upper-case input is accepted and normalized. Every miss is one `404 not_found`: a malformed or unknown code, a draft link, and a link that has no payer page on this project's host (the other plane's code, or no payer host configured). Test mode does not apply: a test-mode link lives on DEV and resolves only there.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `short_code` | path | yes | `string` | A `PaymentLink.short_code`. Resolves to a capability token, so it is never logged. |
| `SwapsVersion` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The payer session token and payer page for this code. | `application/json` [PaymentSessionCodeResolution](/reference/~schemas.md#paymentsessioncoderesolution) |
| `400` | — | `BadRequest` |
| `404` | `not_found` — no payer page for this code on this project: unknown or malformed code, a draft link, or a link on the other plane's host. One answer for every case, so it reveals nothing about which. | `application/json` [Error](/reference/~schemas.md#error) |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="mark-the-session-viewed-available"></a>
## `POST /payment_sessions/{token}/view` — Mark the session viewed — available

**Operation ID:** `payment_sessions.view`

> **Status:** Available · **Callers:** public token · **Test mode:** unavailable

Advances the link active to viewed (D-4). Idempotent: calling it again on an already-viewed link is a no-op — no `Idempotency-Key` is accepted; the handler's own state check is the replay guard. Public token only, no key. Test mode does not apply: a test-mode link's token lives on DEV and is `404` here.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `token` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The updated session. | `application/json` [PaymentSession](/reference/~schemas.md#paymentsession) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="select-a-rail-and-start-a-payment-available"></a>
## `POST /payment_sessions/{token}/payments` — Select a rail and start a payment — available

**Operation ID:** `payment_sessions.payments.create`

> **Status:** Available · **Callers:** public token · **Test mode:** unavailable · **Money boundary:** per-transaction human confirmation required

Selects a rail — for a crypto rail, also a network and stablecoin (RESOURCE-MODEL §2.1 v2 amendment) — and returns deposit instructions. A live attempt on the same rail returns its existing instructions rather than minting a second provider transfer (RESOURCE-MODEL §2.1 invariants) — idempotent per `(token, rail)` by the handler itself, so no `Idempotency-Key` is accepted here. The payer's consent checkbox is enforced client-side, not as a field here. Public token only, no key. Test mode does not apply: a test-mode link's token lives on DEV and is `404` here. `crypto_relay` (CP-X2) is live behind the `crypto_relay_rail` flag, off in production: it needs `network` and `refund_address`, allows one open payment per link (the same `network` and `refund_address` get that payment back; anything else is `409 payment_in_progress` until Relay can no longer fill it and either saw no transfer or refunded it with the refund transaction recorded), and answers the Relay deposit in `deposit_instructions` (never the splitter address) only while the address is payable; `deposit_instructions.quote_expires_at` is the last time to send.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `token` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |

### Request body

Required: **yes**

| Media type | Schema |
| --- | --- |
| `application/json` | [PaymentSessionSelectRailRequest](/reference/~schemas.md#paymentsessionselectrailrequest) |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `201` | The started payment — the payer-safe projection (SEC-2), never the merchant's full `Payment`. | `application/json` [PaymentSessionPayment](/reference/~schemas.md#paymentsessionpayment) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | `capability_unavailable` — code `network_not_supported`: `rail: crypto_relay` with a `network` that is not one of its five Relay sources (`tempo` uses `crypto_tempo`; `solana` stays dark). `conflict` — code `payment_in_progress`: this link already has an open `crypto_relay` payment for another `network` or `refund_address`; read the session and follow that payment. Nothing was recorded. | `application/json` [Error](/reference/~schemas.md#error) |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `502` | The payment provider returned a deposit destination we could not validate for this rail (`provider_error` — `provider_instruction_invalid` or `collection_va_payee_name_missing`). This needs operator attention; a caller retry of the identical request is not expected to succeed. Code `relay_quote_shortfall` (`crypto_relay`): Relay's quote does not deliver exactly the amount due to the splitter (`details.reason`), so no deposit address is issued and nothing is recorded. | `application/json` [Error](/reference/~schemas.md#error) |
| `503` | `temporarily_unavailable`. Code `rail_unavailable` — the chosen rail is switched on but not configured to settle for this link right now (`details.rail`, `details.reason`); nothing was charged, no payment attempt is recorded and no `Retry-After` is sent, because a payer retry cannot fix it: offer another rail from the session. Once the rail is configured, the same request starts a fresh attempt. `details.reason` `tempo_rpc_unavailable` (the Tempo RPC failed) is the one transient rail case: it sends `Retry-After`. Code `temporarily_unavailable` — a kill switch is thrown or a dependency read failed (`details.reason` when it did); retry after `Retry-After`. Code `relay_route_unavailable` (`crypto_relay`) — `details.reason` `route_not_proven` (this `network` is not admitted — no `founder_accepted` or `proven` Relay route; pick one from `rails[].networks`), `provider_refused` or a `quote_*` reason (Relay refused or returned an unusable quote, including `quote_deadline_missing` / `quote_deadline_too_soon`), `provider_unreachable` (Relay did not answer; sends `Retry-After`), `link_expires_too_soon` (the link closes before a Relay payment could land), or a `tempo_*` reason for the Tempo leg (`tempo_rpc_unavailable` sends `Retry-After`). Nothing was recorded. | `application/json` [Error](/reference/~schemas.md#error) |

---

<a id="poll-a-payments-status-available"></a>
## `GET /payment_sessions/{token}/payments/{payment_id}` — Poll a payment's status — available

**Operation ID:** `payment_sessions.payments.get`

> **Status:** Available · **Callers:** public token · **Test mode:** unavailable

Read one payment attempt started on this session. The payer's own client polls this with a backoff, never the chain directly. While Swaps does not route new money to the link's settlement destination (the session's `accepting_payments` is `false`), the payment carries no instructions — `deposit_instructions` and `bank_deposit_instructions` are `null`, whatever its `status` — and no reason; `status` and the amounts that report what already arrived (`amount_received`, `amount_missing`, `surplus`) are unchanged, so a payer who already paid still sees their payment. Public token only, no key. Test mode does not apply: a test-mode link's token lives on DEV and is `404` here.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `token` | path | yes | `string` | — |
| `payment_id` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The payment — the payer-safe projection (SEC-2), never the merchant's full `Payment`. | `application/json` [PaymentSessionPayment](/reference/~schemas.md#paymentsessionpayment) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="set-the-payers-receipt-email-available"></a>
## `POST /payment_sessions/{token}/receipt_email` — Set the payer's receipt email — available

**Operation ID:** `payment_sessions.receipt_email.set`

> **Status:** Available · **Callers:** public token · **Test mode:** unavailable

An opt-in email address for a receipt. Delivery on the crypto rail is a known gap (R19 CP-G12) — the field exists and is accepted regardless. Idempotent — re-saving the same or a corrected email is a plain overwrite, so no `Idempotency-Key` is accepted. Public token only, no key. Test mode does not apply: a test-mode link's token lives on DEV and is `404` here.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `token` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |

### Request body

Required: **yes**

| Media type | Schema |
| --- | --- |
| `application/json` | [PaymentSessionReceiptEmailRequest](/reference/~schemas.md#paymentsessionreceiptemailrequest) |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | Saved. | `application/json` [PaymentSession](/reference/~schemas.md#paymentsession) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="payer-self-report-ive-sent-it-available"></a>
## `POST /payment_sessions/{token}/mark_sent` — Payer self-report: I've sent it — available

**Operation ID:** `payment_sessions.mark_sent`

> **Status:** Available · **Callers:** public token · **Test mode:** unavailable

Records the payer's own claim that they sent the transfer. authoritative:false on the resulting event (RESOURCE-MODEL §3) — it is never settlement. Idempotent (a compare-and-swap update) — no `Idempotency-Key` is accepted. Public token only, no key. Test mode does not apply: a test-mode link's token lives on DEV and is `404` here.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `token` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |

### Request body

Required: **yes**

| Media type | Schema |
| --- | --- |
| `application/json` | [PaymentSessionMarkSentRequest](/reference/~schemas.md#paymentsessionmarksentrequest) |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | Recorded. | `application/json` [PaymentSession](/reference/~schemas.md#paymentsession) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="prepare-a-one-tap-swaps-wallet-payment-planned"></a>
## `POST /payment_sessions/{token}/pay_with_wallet` — Prepare a one-tap Swaps Wallet payment — planned

**Operation ID:** `payment_sessions.pay_with_wallet`

> **Status:** Planned — not built yet · **Callers:** public token · **Test mode:** fixtures · **Money boundary:** per-transaction human confirmation required · **Non-custodial:** response carries unsigned steps — Swaps never signs
>
> This operation is **planned — not built yet**. It is documented for the contract it will carry, but it does not run today. Never call it expecting a live result.

Planned (R19 §2.4, CP-G11). Returns unsigned steps scoped to the splitter address — nothing leaves the payer's wallet until their own passkey signs on their device (RESOURCE-MODEL §0.10). This is the money boundary for the one-tap path: never chain these steps into execution without the payer's own confirmation. Public token only; test mode returns fixtures.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `token` | path | yes | `string` | — |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | Unsigned steps for the payer's own passkey to sign. | `application/json` [PaymentSessionPayWithWalletResponse](/reference/~schemas.md#paymentsessionpaywithwalletresponse) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `413` | — | `PayloadTooLarge` |
| `422` | — | `UnprocessableEntity` |
| `429` | — | `TooManyRequests` |
| `503` | — | `ServiceUnavailable` |
