# Payment links

**Status:** Available

Request money with a hosted link the payer opens themselves — no terminal, no manual reconciliation.

## Concept

A payment link is a request for money that lives as a `draft` until you activate it. Once active, the payer opens a hosted page and pays by bank or by crypto — whichever rails you allowed.

## Objects

| Object | What it is |
|---|---|
| `payment_links` | The request itself — status, amount, allowed rails, the merchant's own view. |
| `clients` | Who you're billing, saved so future links can be addressed to them. |
| `products` | A reusable line item — name and unit price — for links built from a catalogue. |
| `payments` | One attempt to pay a link. Only the payer creates one; there is no refund operation. |
| `payment_sessions` | The payer's own projection at the public link — never the merchant field names verbatim. |

## Lifecycle

The statuses and edges below are the ones a link can take. A link mostly moves forward, with two backward edges:

- A payment attempt that fails before any money arrives sends the link from `processing` back to `viewed`, once, so the payer can try again.
- A deposit sent back on a collection account moves a `paid` link back to `processing`. The link is payable again, and its events record `funds_returned` with `reopened: true`.

`refunded` means the payer's money came back to them after it reached the provider — for example a bank transfer returned for a payee-name mismatch. Swaps has no refund operation: you cannot start one, and this status is set only by the provider's return. The event is `payment_link.refunded` (recorded as `funds_returned` in the link's own events). A `funds_returned` event does not always mean `refunded`: on a returned collection-account deposit the link stays payable, as above. You see "Refunded"; the payer sees "Returned" — one stored value, two audience labels. A `settled` link is final and never moves to `refunded`.

**Lifecycle**

Steps: draft → active → viewed → processing → paid → settled

- **active, viewed or processing → expired** — expires_at passes before the payer finishes.
- **draft, active, viewed or processing → cancelled** — you cancel; discarding a draft is the same call. While processing, open attempts that are still waiting for funds are cancelled at the provider first. If the provider refuses, the call fails and the link stays as it was.
- **processing → viewed** — an attempt fails before any funds arrive; the payer can pick again (once).
- **processing or paid → refunded** — the provider returns the payer's funds; there is no merchant refund operation.
- **paid → processing** — a deposit is returned on a collection account; the link is payable again.

```mermaid
stateDiagram-v2
  [*] --> draft
  draft --> active : activate
  draft --> cancelled : cancel
  active --> viewed : payer session view
  active --> expired : expires_at passes
  active --> cancelled : cancel
  viewed --> processing : payer starts a payment
  viewed --> expired : expires_at passes
  viewed --> cancelled : cancel
  processing --> paid : funds confirmed
  processing --> viewed : attempt failed, no funds received (once)
  processing --> refunded : provider returned the funds
  processing --> expired : expires_at passes
  processing --> cancelled : cancel
  paid --> settled : provider settlement lands
  paid --> refunded : provider returned the funds
  paid --> processing : deposit returned on a collection account; the link is payable again
  expired --> [*]
  cancelled --> [*]
  refunded --> [*]
  settled --> [*]
```

`cancel` on a `paid` or `settled` link is refused with `409 conflict`. On an `expired`, `refunded` or already-`cancelled` link it is a no-op: `200` with the link unchanged. Read `status` from the response.

`cancel` also answers `409 conflict`, and changes nothing, while a payment of the link is being settled. That includes a `crypto_relay` payment whose funds Relay has already received: that money is not closed out from under the bridge. Once Relay refunded them and the refund transaction is recorded, that payment no longer blocks the cancel. Cancelling a link whose `crypto_tempo` payment arrived short is allowed; that payment becomes `unmatched` with `unmatched_reason: partial_before_cancel` (see [When money arrives and no payment matches](#when-money-arrives-and-no-payment-matches)).

## Minimal flow

Activation needs to know where the money lands. Draft the link with one of these two, or `activate` refuses it:

- **A saved destination**: `settlement_destination: { "address_book_id": "adr_…" }`. It points to a bank account or crypto address you saved in `/v1/address_book`. That resource takes a dashboard session or an agent key, never a business key. Save the destination once, under Addresses in the dashboard or with `POST /v1/address_book` (below). No dashboard screen shows its `adr_…` id today: read it from `GET /v1/address_book` with an agent key or a dashboard session (the `id` field, `adr_` plus a UUID, for example `adr_3f1c2e4a-8b6d-4f2a-9c1e-5d7b3a9e0f21`), then reuse it from your key. Without one, use the crypto-only path. You never send the raw address or bank details here.
- **Your Swaps Wallet, crypto only**: `accepted_rail_kinds: ["crypto"]`, **USD only**. It is open only where the crypto-only capability is enabled for your account; otherwise create answers `409 capability_unavailable`. Don't send both on the same create (`400 settlement_conflicts_with_destination`).

To settle to a bank account, save it first. Every bank shape (SWIFT excepted for settlement: `activate` answers `422 settlement_rail_unsupported`) requires `account_holder`, the account owner's legal name as the bank holds it; the link settles under that name. An IBAN entry, with a dashboard session or an agent key:

```bash
curl https://api.swaps.app/v1/address_book \
  -X POST \
  -H "Authorization: Bearer $SWAPS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "rail": "iban",
    "label": "Business EUR account",
    "account_holder": "Example Trading OU",
    "iban": "DE89370400440532013000",
    "bic": "COBADEFFXXX"
  }'
```

The answer is `201` with the entry's `id` (`adr_…`), bank details masked. A link that settles to a bank account can also be paid in USDC (`crypto_bridge`), converted to your bank's currency, where that rail is enabled.

1. `POST /v1/payment_links` with `amount` and `settlement_destination` (or `accepted_rail_kinds: ["crypto"]` on a USD link). See [Get started](/get-started) for a full request and response. The response is a `draft` with `url: null`.
2. `POST /v1/payment_links/{id}/activate` with `{ "attestation_accepted": true }` and an `Idempotency-Key`. This is REST-only: a human compliance step, excluded from MCP. On success `status` is `active`, and `url`, `payable_rails` and `payable_rail_kinds` are set. A refused activation leaves the link a `draft`:

   | Answer | Meaning | Fix |
   |---|---|---|
   | `400 settlement_destination_required` | An ordinary (not crypto-only) draft has no `settlement_destination`. | `PATCH` it with `settlement_destination: { "address_book_id": "adr_…" }` and activate again. |
   | `409 allowed_rails_invalid_for_currency` | The stored `allowed_rails` leaves the link with no rail a payer could use. `error.details.offerable_rails` lists the ones that would work. | `PATCH` with `allowed_rails: null` or a rail from that list, or change the currency. |
   | `422 crypto_only_currency_not_usd` | A crypto-only (Swaps Wallet) draft is not in USD. That rail pays out in USD stablecoins with no FX step. | Draft it in USD, or use a saved destination instead. |
   | `422 settlement_rail_unsupported` / `settlement_account_holder_missing` / `settlement_details_incomplete` | The saved destination can't receive this link's funds, or is missing a field the provider needs. | Complete that address-book entry or choose another one. |
   | `422 tempo_settlement_currency_not_usd` | The saved destination is on the Tempo network and the link is not in USD. | Same fix as `crypto_only_currency_not_usd`. |
   | `422 no_payable_rail` | The only rail a payer could use is `crypto_relay` above its per-invoice cap, so every payer would be refused `amount_above_rail_maximum`. `error.details` carries `rail` (`crypto_relay`), `reason` (`amount_above_rail_maximum`) and `max_amount`, the same `Money` the `crypto_relay` corridor publishes. The link stays a `draft`. | Lower the amount, or let another rail pay it with `PATCH` and `allowed_rails`. A link that any other rail can pay activates as before. |
   | `400 invalid_request` | `attestation_accepted` is not `true`. | Send `true`: the account holder's own attestation. |
   | `403 permission_error` | The account is not eligible to activate links: Bridge verification and terms are incomplete. Checked before the destination. | Finish verification and accept the terms in the dashboard. |
   | `409 wallet_not_found` | Crypto-only draft, and the account has no Swaps Wallet yet. | Create your Swaps Wallet first. |
   | `409 capability_unavailable` | Crypto-only draft, and the crypto rail is off right now. | Try again later, or use a saved destination. |
   | `409 conflict` | The link is no longer a `draft`. | Read it with `GET /v1/payment_links/{id}`; it may already be active. |
   | `503 temporarily_unavailable` | A dependency did not answer: `error.details.reason` is `settlement_provider_unavailable` (the payment provider) or a failed read such as `account_read_failed`. | Read the link, then activate again with a new `Idempotency-Key`. |

3. Share `url` with the payer. On production it is `https://swaps.app/pay/plk_…`; on the development project it is `https://staging.swaps.app/pay/plk_…` (see [Environments](/environments)); it is `null` while a project has no payer host configured. The last path segment is the payer's `plk_…` session token. Treat it as a secret.
4. The payer side uses public `payment_sessions` calls. They need no key and no `Idempotency-Key`:
   - `GET /v1/payment_sessions/{token}` is a pure read of the payer's view: amount, `rails[]` and status.
   - `POST /v1/payment_sessions/{token}/view` moves the link `active → viewed`. Only this call does that.
   - `POST /v1/payment_sessions/{token}/payments` selects a rail, for example `{ "rail": "ach", "payer_type": "business" }` on a USD link. `crypto_bridge` also needs `source_chain` and `source_asset: "USDC"` (`source_address` is optional); `crypto_tempo` needs nothing more, because the network comes from the link's settlement. The answer is `201` with deposit instructions. Calling it again for the same rail returns the same instructions.
   - While that payment waits for the money, `GET /v1/payment_sessions/{token}` also returns `pending_payment`: its `rail`, `status` (`awaiting`, `detected` or `processing`) and the same receiving instructions (`deposit_instructions` for a crypto rail, `bank_deposit_instructions` for a bank rail). A payer who opens the link again in another browser still sees where to send the money. `payer_marked_sent` is `true` once the payer reported sending: show the instructions as reference, not as a call to pay. It is `null` once the payment is paid, fails, expires or gets a verdict, while money already sits on another payment of the link, when a Relay quote's send-by time passes, and on a closed or expired link. For `crypto_relay`, send exactly the amount on that network before `expires_at`; any Relay refund goes to the refund address given when the payment was started. It never carries the payment id or anything else about the payer.
   - `pending_payment` is always in the answer: `null` when nothing is waiting, otherwise the object above. A `crypto_tempo` payment that arrived short is not in it; it is in `underpaid_payment`. At most one of the two is non-null.
   - `underpaid_payment` is `null` or `{ rail, amount_missing, amount_received, deposit_instructions, created_at }`. It is set only while the link is `processing` and not past `expires_at`, and the payer's latest payment is a `crypto_tempo` payment that arrived short. `amount_missing` is what is still owed (the invoice plus the 1% fee, minus `amount_received`), at the token's own scale and always positive. `deposit_instructions` name the one token the running total counts, and their `amount` is `amount_missing`: send that now. A deposit in another accepted token is held for support and never added. It is `null` for `crypto_relay` (a Relay deposit address belongs to one quote, so there is no top-up), for bank rails and `crypto_bridge`, and on a cancelled, paid, closed or expired link. It never carries the payment id.
   - `amount_verdict` is one verdict on the money, taken from the payment that settled the link or holds its money, not from the newest payment: `exact`, `underpaid`, `overpaid`, `unmatched` or `null`. `null` means no verdict can be proven, and never means `exact`: a Bridge payment settled as paid reads `null`, and so does a link where nothing has arrived, where payments hold money under different verdicts, or where an `exact` match sits beside other money held for the link (an `overpaid` or `underpaid` verdict is still published then). It is an open enum; treat an unknown value like `null`. Read it. Do not compute a verdict from `amount_received` and `amount_expected`.
5. `GET /v1/payment_links/{id}` or the MCP tool `check_payment_request` shows whether the link is paid. `GET /v1/payment_links/{id}/payments` lists each attempt.

Test mode doesn't cover this flow. The keyed steps (1, 2 and 5) are `x-swaps-test-mode: unavailable`, so a `sk_test_` key gets `503 temporarily_unavailable` there (see [Test mode](/test-mode)). The payer steps (4) take no key, so test mode does not apply: a test-mode link's token lives on DEV and is `404` on production.

## When a dependency is down

Merchant calls (`create`, `update`, `activate`, `cancel`, `send_invoice`, `reminder_schedule.*` and the reads) answer `503 temporarily_unavailable` with `error.details.reason` when something they depend on did not answer, instead of `500 internal_error`:

| `error.details.reason` | Meaning |
|---|---|
| `settlement_provider_unavailable` | The payment provider did not respond while `activate` set up your destination. The link is still a `draft`. |
| `link_read_failed`, `account_read_failed`, `wallet_read_failed`, `address_book_read_failed`, `counterparty_read_failed`, `items_read_failed`, `attempts_read_failed`, `links_list_failed`, `events_read_failed`, `eligibility_lookup_failed` | A read failed on our side. |

A `GET` sends `Retry-After`. A call that took an `Idempotency-Key` does not, because that key now answers `409 idempotency_failed`: read the link to see where it stands, then retry with a new key.

`send_invoice` answers `503 mail_unavailable` (`error.details.reason: invoice_email_not_sent`) when the email could not be confirmed as sent. It may not have left, the payer's address may not accept mail, or it may already have arrived. There is no `Retry-After` and the same `Idempotency-Key` answers `409 idempotency_failed`: read the link before sending again with a new key.

If the payment provider refuses to cancel an open transfer (for example because the payer's funds are already moving), `cancel` answers `500 internal_error` and the link stays as it was. Anything else that fails on our side is also `500 internal_error`, including a failed write: quote its `request_id`.

## Rails

`allowed_rails` narrows what the payer may pick; leave it unset and the link offers every rail available for its currency. It is a three-state field on `PATCH`, and the states are distinct on purpose:

| You send | What happens |
|---|---|
| nothing | The stored restriction is left exactly as it is. |
| `"allowed_rails": null` | The restriction is removed — the link goes back to every rail its currency offers. |
| `"allowed_rails": ["ach"]` | The restriction is replaced. |
| `"allowed_rails": []` | `400`. A link restricted to no rail cannot be paid, so this is a mistake, not a way to clear it. |

A restriction can only narrow what the link can be paid on, so one that leaves it with nothing a payer could pick is refused rather than stored — `400 allowed_rails_invalid_for_currency` at create and at any `PATCH` that changes the currency or the rails, `409` at activation, where the offending value is the stored draft. Every one of those answers carries the rails that would work in `error.details.offerable_rails`.

All three use the same yardstick: what the link could actually be paid on, exactly as the payer page computes it. So a rail you are endorsed for counts even when it is not your link's own currency (a SEPA-endorsed merchant can keep `["sepa"]` on a USD link — the payer pays in EUR), and a link that settles to crypto is never refused for a bank-rail restriction, because it can still be paid in stablecoin.

### Which rails a link lists

`payable_rails` (and `payable_rail_kinds`) on the merchant's read are computed live on every read. They are not frozen at activation: they follow what the payer page would offer right now, so the list can change after the link is active. `crypto_relay` is listed exactly when the payer session offers it. That needs the Relay switch on for this merchant, the amount within the per-invoice cap, a USD link, and a Tempo mainnet settlement: on a Tempo test network `GET /v1/capabilities?product=payment_links` reads the `crypto_relay` corridor `not_enabled` with `blocked_reason: relay_requires_tempo_mainnet` and `source_chains: []`, and the payer is not offered Relay. If the switch or the cap cannot be read, the merchant read leaves `crypto_relay` out instead of claiming it.

One known exception (#3977): for an individual merchant whose bank pay-in is not active yet, `payable_rails` still lists the bank rails, while the payer session withholds them (`merchant_fiat_payin_pending`, below). For what a payer can actually pick, read `rails[]` on the payer session.

### Rails the payer cannot pick

`GET /v1/payment_sessions/{token}` returns `rails[]`, the rails the payer can pick, and `unavailable_rails[]`, the rails this link could take in principle but this session cannot. Each entry is `{rail, reason_code}`, so a payer page can show the row muted with an honest reason instead of hiding it. Both lists come from the same eligibility check, never overlap, and `POST …/payments` refuses every rail in `unavailable_rails` with `400 rail_not_allowed`. A rail your `allowed_rails` excludes is in neither list.

| `reason_code` | Meaning |
|---|---|
| `merchant_fiat_payin_pending` | The merchant is an individual whose bank pay-in is not active yet. The payment refusal carries the same value in `error.details.reason`. A link that settles to a bank account gets `settlement_requires_crypto` instead, because its bank rails never open. |
| `settlement_requires_crypto` | The link settles to a bank account. There is no bank-to-bank route, so only a crypto pay-in can settle it. |
| `merchant_individual_rail_blocked` | This rail is never payable to an individual merchant (`pix`, `faster_payments`). |
| `merchant_rail_not_enabled` | The merchant's account is not enabled for this rail. |
| `link_currency_unsupported` | The rail cannot settle this currency (`crypto_tempo` is USD only). |
| `amount_below_rail_minimum` | The invoice is under the rail's minimum. |
| `rail_disabled` | Swaps has switched this rail off for the link. It is an operator switch, not the merchant's choice, so do not word it as one. A rail Swaps has not launched for this merchant (`crypto_bridge` before it is switched on) is in neither list. |
| `rail_temporarily_unavailable` | Eligibility could not be checked right now (for example, no FX rate), so the rail is held back. |
| `rail_not_offered` | No more specific reason applies. |

Limits that depend on who pays (the individual-payer caps) are not in this list. Those rails stay in `rails[]`, and the payment answers `422` with the specific code once the payer declares `payer_type`.

A rail that is switched on but not configured to settle right now answers `503 rail_unavailable` (`error.details.rail`, `error.details.reason`). Nothing is charged and no payment attempt is recorded, so selecting the same rail again once it is configured starts a fresh attempt; until then, offer the payer another rail. A Tempo RPC outage (`error.details.reason` `tempo_rpc_unavailable`) is transient and sends `Retry-After`.

## The Swaps fee

The fee is 1% of the invoice, paid by the payer on top of it, on `crypto_tempo` and `crypto_relay` only. You receive the full invoice. `GET /v1/capabilities?product=payment_links` publishes it as `fee`: `kind: "percentage"`, `bps: 100`, `applies_to: "payer"`, `rails: ["crypto_tempo", "crypto_relay"]`, `rounding` (`floor`) and `rounding_decimals` (`6`). A rail that is not in `rails` carries no fee claim from this object: any fee on a bank rail or `crypto_bridge` is applied by Bridge and is not published here, and `fee` on the payment is `null` there. Provider costs (Relay relay and gas, Bridge network and processing) are not this fee and are quoted per payment. The fee is a price, so it is published even while `crypto_tempo` or `crypto_relay` read `not_enabled` in `corridors[]`.

The payer is asked for `floor(amount × 10^rounding_decimals × bps / 10000)` base units of the settlement token on top of the invoice. On these two rails the payment's `fee` and `amount_expected` are at the token scale (six decimals), not in cents: a 12.63 USD invoice reads `fee` 0.1263 and `amount_expected` 12.7563, the figures in the deposit instructions. A USD invoice with at most two decimals is never rounded. Show a `Money` at its own `decimals`.

## When money arrives and no payment matches

A payment ends `unmatched` in exactly two ways. `unmatched_reason` says which. It is on the payment (`GET /v1/payments`, `GET /v1/payments/{id}`, `GET /v1/payment_links/{id}/payments`, the MCP tools `list_payments` and `get_payment`), on the payer's own payment, and in `data.object` of the `payment.unmatched` event (see [Events and webhooks](/events-webhooks)).

| `unmatched_reason` | Meaning |
|---|---|
| `partial_before_cancel` | The payer had already sent part of the amount (the payment was `underpaid`) when you cancelled the link. `amount_received` is that partial amount. |
| `deposit_after_close` | Money was first seen at the payment's settlement address after the payment had closed unpaid: it expired, or the link was cancelled before any money was seen. `amount_received` is what arrived. It may have been sent just before the close, because Swaps polls and a `crypto_relay` payment can still be bridging. |

`unmatched_reason` is set only when `status` is `unmatched` and is `null` on every other status. It is also `null` on a payment that became `unmatched` before the field existed: no reason was recorded, so show neutral wording. The list is an open enum; treat an unknown value like `null`. Either way the money is held and handled by support. Swaps never releases or refunds it automatically.

On the same payment, `amount_received` is what Swaps has observed and `amount_missing` is what an `underpaid` payment still owes. `amount_missing` is `null` when Swaps cannot prove one positive figure in one token, never a figure across two token scales.

## Common tasks

| Task | How |
|---|---|
| Create a link | `POST /v1/payment_links` — see the [reference](/reference). |
| Limit it to one rail | `PATCH …` with `allowed_rails: ["sepa"]` on a EUR link. |
| Drop the rail limit again | `PATCH …` with `allowed_rails: null` — `[]` is a `400`, not a clear. |
| Choose where funds land | `settlement_destination: { "address_book_id": "adr_…" }` at create or `PATCH`; `activate` refuses an ordinary draft without one (`400 settlement_destination_required`). |
| Make it crypto-only | Set `accepted_rail_kinds: ["crypto"]` at create, USD only. There is no separate `settlement_kind` input; the router sets it at activation. |
| Activate it | `POST …/activate` with `attestation_accepted: true` — REST-only, excluded from MCP. |
| Email it to a client | `POST …/send_invoice`, optionally setting the reminder schedule. |
| Check whether it's paid | `GET /v1/payment_links/{id}`, or `check_payment_request` from an agent. |
| See if a payment came up short | `partial_payment` on the same response, set only while `status` is `processing` (§8.4) — `received` below `expected`, or `null` while the settled amount is still unconfirmed. |
| See what's still open | `GET /v1/payment_links?status_group=open`. |
| See which rails the link offers now | `payable_rails` on `GET /v1/payment_links/{id}` or `check_payment_request`: computed live, and it lists `crypto_relay` when the payer session offers it. |
| See how much a payer still owes | `underpaid_payment.amount_missing` on the payer session, or `amount_missing` on the payment (`get_payment`). |
| Read the verdict on the amount | `amount_verdict` on `GET /v1/payment_sessions/{token}`. Read it; never compute it from `amount_received` and `amount_expected`. |
| See why a payment is `unmatched` | `unmatched_reason` on `get_payment` or `GET /v1/payments/{id}`, and in the `payment.unmatched` event. |
| Read the Swaps fee before you create the link | `fee` in `GET /v1/capabilities?product=payment_links`; see [The Swaps fee](#the-swaps-fee). |
| Get the receipt for a paid link | `GET /v1/payment_links/{id}/receipt` — see [Receipt](#receipt) below. |

## Receipt

`GET /v1/payment_links/{id}/receipt` returns the merchant's receipt as JSON. It exists only while the link's own
`status` is `paid` or `settled` and exactly one payment completed it. Every other state — draft, open, `processing`
(a short payment held for review included), expired, cancelled, refunded — answers `409 receipt_not_available`; a link
you do not own is `404`.

| Field | What it is |
|---|---|
| `amount` | What the link asked for — the ask, not an observation. |
| `payment_id`, `payment_rail` | The payment that completed the link and its rail. |
| `paid_at`, `settled_at` | When the link became `paid`, and `settled` (`null` until then). |
| `provider_reference` | The provider's transfer id, or the deposit id for a bank payment collected on a collection account; `null` for `crypto_tempo`. |
| `onchain_tx_hash`, `amount_received` | The on-chain hash and the observed deposit, for `crypto_tempo` only. The deposit includes the payer-borne fee, so it exceeds `amount`. |
| `fee`, `net_amount` | The same values `GET /v1/payments/{payment_id}` returns: the 1% payer-borne fee, and what you receive once the payment settles (`null` before). |
| `settlement_kind`, `settlement_destination` | Where the funds land, as on the link — a saved address book reference, never a raw address or bank detail. |
| `recipient_amount` | Omitted today. The received figure is recorded on the link's `paid` event but is not yet published as a provider-confirmed amount, and it is never filled with the invoice amount. |
| `title`, `invoice_number`, `note` | Copied from the link; `note` is its memo. |

There is no PDF or HTML version yet. The payer gets an e-mail receipt when they
left an address on `/pay`. Test mode refuses this operation.

Next: the [Create reference](/reference) · [Errors](/conventions#errors) for what a failed create looks like.
