Payment links
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
processingback toviewed, once, so the payer can try again. - A deposit sent back on a collection account moves a
paidlink back toprocessing. The link is payable again, and its events recordfunds_returnedwithreopened: 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.
View as a Mermaid state diagram (source)
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).
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 withPOST /v1/address_book(below). No dashboard screen shows itsadr_…id today: read it fromGET /v1/address_bookwith an agent key or a dashboard session (theidfield,adr_plus a UUID, for exampleadr_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 answers409 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:
Code
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.
-
POST /v1/payment_linkswithamountandsettlement_destination(oraccepted_rail_kinds: ["crypto"]on a USD link). See Get started for a full request and response. The response is adraftwithurl: null. -
POST /v1/payment_links/{id}/activatewith{ "attestation_accepted": true }and anIdempotency-Key. This is REST-only: a human compliance step, excluded from MCP. On successstatusisactive, andurl,payable_railsandpayable_rail_kindsare set. A refused activation leaves the link adraft:Answer Meaning Fix 400 settlement_destination_requiredAn ordinary (not crypto-only) draft has no settlement_destination.PATCHit withsettlement_destination: { "address_book_id": "adr_…" }and activate again.409 allowed_rails_invalid_for_currencyThe stored allowed_railsleaves the link with no rail a payer could use.error.details.offerable_railslists the ones that would work.PATCHwithallowed_rails: nullor a rail from that list, or change the currency.422 crypto_only_currency_not_usdA 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_incompleteThe 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_usdThe 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_railThe only rail a payer could use is crypto_relayabove its per-invoice cap, so every payer would be refusedamount_above_rail_maximum.error.detailscarriesrail(crypto_relay),reason(amount_above_rail_maximum) andmax_amount, the sameMoneythecrypto_relaycorridor publishes. The link stays adraft.Lower the amount, or let another rail pay it with PATCHandallowed_rails. A link that any other rail can pay activates as before.400 invalid_requestattestation_acceptedis nottrue.Send true: the account holder's own attestation.403 permission_errorThe 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_foundCrypto-only draft, and the account has no Swaps Wallet yet. Create your Swaps Wallet first. 409 capability_unavailableCrypto-only draft, and the crypto rail is off right now. Try again later, or use a saved destination. 409 conflictThe link is no longer a draft.Read it with GET /v1/payment_links/{id}; it may already be active.503 temporarily_unavailableA dependency did not answer: error.details.reasonissettlement_provider_unavailable(the payment provider) or a failed read such asaccount_read_failed.Read the link, then activate again with a new Idempotency-Key. -
Share
urlwith the payer. On production it ishttps://swaps.app/pay/plk_…; on the development project it ishttps://staging.swaps.app/pay/plk_…(see Environments); it isnullwhile a project has no payer host configured. The last path segment is the payer'splk_…session token. Treat it as a secret. -
The payer side uses public
payment_sessionscalls. They need no key and noIdempotency-Key:GET /v1/payment_sessions/{token}is a pure read of the payer's view: amount,rails[]and status.POST /v1/payment_sessions/{token}/viewmoves the linkactive → viewed. Only this call does that.POST /v1/payment_sessions/{token}/paymentsselects a rail, for example{ "rail": "ach", "payer_type": "business" }on a USD link.crypto_bridgealso needssource_chainandsource_asset: "USDC"(source_addressis optional);crypto_temponeeds nothing more, because the network comes from the link's settlement. The answer is201with 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 returnspending_payment: itsrail,status(awaiting,detectedorprocessing) and the same receiving instructions (deposit_instructionsfor a crypto rail,bank_deposit_instructionsfor a bank rail). A payer who opens the link again in another browser still sees where to send the money.payer_marked_sentistrueonce the payer reported sending: show the instructions as reference, not as a call to pay. It isnullonce 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. Forcrypto_relay, send exactly the amount on that network beforeexpires_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_paymentis always in the answer:nullwhen nothing is waiting, otherwise the object above. Acrypto_tempopayment that arrived short is not in it; it is inunderpaid_payment. At most one of the two is non-null.underpaid_paymentisnullor{ rail, amount_missing, amount_received, deposit_instructions, created_at }. It is set only while the link isprocessingand not pastexpires_at, and the payer's latest payment is acrypto_tempopayment that arrived short.amount_missingis what is still owed (the invoice plus the 1% fee, minusamount_received), at the token's own scale and always positive.deposit_instructionsname the one token the running total counts, and theiramountisamount_missing: send that now. A deposit in another accepted token is held for support and never added. It isnullforcrypto_relay(a Relay deposit address belongs to one quote, so there is no top-up), for bank rails andcrypto_bridge, and on a cancelled, paid, closed or expired link. It never carries the payment id.amount_verdictis 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,unmatchedornull.nullmeans no verdict can be proven, and never meansexact: a Bridge payment settled as paid readsnull, and so does a link where nothing has arrived, where payments hold money under different verdicts, or where anexactmatch sits beside other money held for the link (anoverpaidorunderpaidverdict is still published then). It is an open enum; treat an unknown value likenull. Read it. Do not compute a verdict fromamount_receivedandamount_expected.
-
GET /v1/payment_links/{id}or the MCP toolcheck_payment_requestshows whether the link is paid.GET /v1/payment_links/{id}/paymentslists 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). 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).
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. |
| 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. |
| Get the receipt for a paid link | GET /v1/payment_links/{id}/receipt — see 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 · Errors for what a failed create looks like.