Payouts
A payout pays one of your customers out. You choose how much the customer should receive in USDC and which token you will fund it with. Peer Pay returns a deposit address and an exact funding amount.
A deposit address is a one-off crypto address created for this payout only. Whatever you send there is converted to USDC on Base and delivered to the customer's Peer app wallet. You never need the customer's wallet address; the customer is identified by email.
| Endpoint | Auth | Use it when |
|---|---|---|
POST /api/v1/payouts | API key | A customer asks to withdraw |
GET /api/v1/payouts/{id} | API key | You want funding progress and status for one payout |
GET /api/v1/payouts | API key | You want a filtered page of payouts |
POST /api/v1/payouts/{id}/cancel | API key | You no longer want to fund a payout |
Once you fund the deposit address, Peer Pay detects the funds on its own; see Funding.
Every route returns the same PayoutView shape, and so do the
payout webhooks.
Every live merchant can create payouts: every platform is on by default. A sandbox merchant
can't. A merchant whose account setup isn't finished gets 409 MERCHANT_CONFIG_MISSING;
turning every platform off in Settings → Payouts gives 403 PAYOUTS_DISABLED. See the
merchant payouts guide.
Money rules
- The payout is funded and owed in USDC on Base (chain
8453, token0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913). The customer can take it as any supported coin and network. - The payout must be at least one payout step, a whole multiple of that step (100 USDC by default), and at most 1000 USDC.
- Your merchant fee is X% of the amount funded, set in basis points (100 bps = 1%, at most 1000 bps). The Peer fee is set for your merchant by Peer (200 bps of the amount funded by default). Both fees come out of the deposit, so the funding amount is grossed up to cover them. Your fee goes to your merchant wallet and the Peer fee to Peer's fee wallet.
- Customers can use only the payout apps you turned on that Peer also allows, and you can fund only with tokens Peer allows for your merchant. Peer can turn your payout apps on or off for your merchant, like your checkout payment methods, and switch an app or token off for every merchant. Tokens can also be limited for your merchant alone.
- Crypto payouts use
relay_<chainId>rails andnear_intents_133701for Zcash, grouped as one Crypto option for the customer. Only networks in the payout’srailsare offered. See the rail values. - Rails and fee wallets are copied when the payout is created. The payout apps the customer can pick, both fee wallets, the fees and the payout step are copied onto the payout, so a later settings change applies to new payouts only. Peer can also remove a network from an open payout; see crypto payouts.
- A small buffer covers price movement while the deposit is converted: 1% of the payout, capped at 5 USDC for stablecoins; 2%, capped at 10 USDC for other tokens, BTC included. A Bitcoin deposit is converted only after the network confirms it, so its price can move for longer.
Payout currencies
Players can receive the catalog currencies on PayPal and Revolut; you still fund and see USD. All payout amounts, fees, limits, refunds and settlement accounting stay in USDC on Base. Fiat fields are information only; never use them to decide how much to fund or refund.
- USD is always fixed at 1.00 USD per USDC and never uses an oracle. Crypto payouts are unchanged.
- Non-USD listings use the same oracle parameters as Peer web and mobile: the SDK's
spread-oracle config with the per-environment Chainlink adapter, at 0% spread.
The adapter, adapter config and maximum staleness are taken unchanged from the SDK;
maxStalenessis 86,400 seconds today. Pay applies no staleness logic of its own; the contracts and Curator handle whether buyers can signal. - Before a buyer signals, the “≈” amount is display only, from batched oracle reads cached for 60 seconds with a two-second timeout. A failed read only removes the “≈” estimate: it never hides a currency or blocks saving a method or confirming. Once a buyer signals, their rate binds; each payment keeps its own bound rate, even if the market moves or the rest is paid later.
- PayPal and Revolut offer every currency in their catalog by default.
No admin or merchant currency setting is required.
Changing a listed method or currency withdraws the listing, returns to
FUNDED, and preserves paid parts in their original currencies.
The table below is the payout catalog exported as PAYOUT_RAIL_CURRENCIES and
PAYOUT_CURRENCY_INFO from @zkp2p/pay-shared.
Read the Rails column to get each rail's currencies; USD comes first, then table order.
All entries have two minor units. Prefixes follow the SDK, except MX$, CN¥ and
CHF disambiguate the display (CHF includes a trailing space).
| Code | Name | Prefix | Rails |
|---|---|---|---|
| USD | United States Dollar | $ | venmo, cashapp, paypal, zelle, revolut, chime |
| EUR | Euro | € | paypal, revolut |
| GBP | British Pound | £ | paypal, revolut |
| AUD | Australian Dollar | A$ | paypal, revolut |
| CAD | Canadian Dollar | C$ | paypal, revolut |
| CHF | Swiss Franc | CHF | revolut |
| CNY | Chinese Yuan | CN¥ | revolut |
| MXN | Mexican Peso | MX$ | revolut |
| NZD | New Zealand Dollar | NZ$ | paypal, revolut |
| SGD | Singapore Dollar | S$ | paypal, revolut |
| TRY | Turkish Lira | ₺ | revolut |
| ZAR | South African Rand | R | revolut |
Limits stay USDC-denominated: the minimum payout (10 USDC in production) and 1,000 USDC per buyer payment. There are no per-currency fiat limits. USD is fixed 1:1; every other entry uses the SDK's Base oracle at 0% spread.
Peer web's packages/core/src/platforms/{paypal,revolut,venmo,cashapp,zelle,chime}.ts
and mobile's src/helpers/paymentPlatforms/*.ts match the SDK 0.14.5/0.14.7 rail
catalogs in production and staging (research verified 2026-10-08). Revolut also
supports JPY, HKD, SAR, AED, THB, PLN, CZK, DKK, HUF, NOK, RON and SEK in Peer,
but the SDK has no Base oracle feed for them, so payouts cannot price them under
this rule. BRL, IDR, INR and PHP have feeds but are not supported by these payout rails.
PayPal buyer proof and SAR verify receipt currency; Curator forwards the intent's
currency hash to attestation-service. Revolut buyer proof also verifies currency,
and neither payee hash includes it. Wrong-currency risk: an otherwise valid
buyer payment in another currency is converted by attestation with a 3% penalty
(attestation-service/src/transformers/operations/crossCurrency.ts), potentially
leaving a partial fill and relisting the remainder. This is existing protocol behavior.
Worked example
You ask for a 100 USDC payout, fund it with USDC and charge a 100 bps (1%) merchant fee.
- Buffer: 1% of 100 = 1. The conversion must deliver 100 + 1 = 101 USDC.
- Fees take 100 + 200 = 300 bps (3%) of the deposit, so the deposit must be 101 ÷ 0.97 = 104.123712 USDC (rounded up to the last unit).
- Your merchant fee is 1% of 104.123712 = 1.041237 USDC (rounded down).
A 1000 USDC payout hits the 5 USDC buffer cap: 1005 ÷ 0.97 = 1036.082475 funded, with a 10.360824 merchant fee.
The response carries the final numbers. Fund exactly funding.amount; do not recompute it.
Idempotency-Key header
POST /api/v1/payouts requires an Idempotency-Key header: 8–128 characters of letters,
digits, _ or -. Use one key per withdrawal and reuse it on retries.
| You send | You get |
|---|---|
| A new key | 201 and a new payout, with checkoutUrl and idempotentReplay: false |
| The same key and the same body | 200 with the existing payout, the current checkoutUrl and idempotentReplay: true |
| The same key and a different body | 409 IDEMPOTENCY_KEY_CONFLICT |
| The same key while the first request is still running | 409 IDEMPOTENCY_REQUEST_IN_PROGRESS |
checkoutUrl is the link you give the customer to follow their payout. A replay with the same key
and body returns the current link, so a lost response can be retried safely. Owners and managers
can also copy it from the payout's page in the dashboard while the payout is open. The customer
can act through the link until the payout ends (settled, cancelled or expired); after that it only shows how the
payout ended. If Peer rotates the key that signs these links, links handed out earlier stop
working; a replay of the create request returns the new link, as does the dashboard while the
payout is open.
Create a payout
POST /api/v1/payouts
X-API-Key: <your key>
Idempotency-Key: <your key for this withdrawal>
Content-Type: application/json
Request body
| Field | Type | Description |
|---|---|---|
customerEmail | string | The customer's email, up to 254 characters. Lower-cased before use |
payout.amount | string | USDC owed to the customer, a decimal with at most 6 decimals |
payout.chainId | number | Must be 8453 |
payout.tokenAddress | string | Must be Base USDC |
funding.chainId | number | Chain you fund from: Ethereum (1), Optimism (10), BNB Smart Chain (56), Polygon (137), World Chain (480), Hyperliquid (999), Arc (5042), Base (8453), Arbitrum (42161) or Bitcoin (8253038). Solana, Tron and Zcash can't fund a payout |
funding.tokenAddress | string | Token you fund with. Use 0x0000000000000000000000000000000000000000 for a chain's native token, and bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmql8k8 for BTC on Bitcoin |
funding.refundAddress | string? | Where Relay sends the funding back if it can't be converted. Required for Bitcoin: a Bitcoin address you control, bech32 (bc1…) or legacy (1…, 3…); a bc1 address is stored lower-case. Optional on EVM chains: leave it out to refund the wallet that sent the funding, and set it when you fund from an exchange withdrawal, because Relay doesn't refund exchange wallets. |
merchantReference | string? | Your own reference, 1–128 characters. List matches it exactly |
returnUrl | string? | An https:// URL the customer returns to when they are done |
rails | PayoutRailType[]? | Non-empty list of unique payout rail ids. Limits the effective merchant/platform rails; never enables a rail either turned off. Unavailable requested rails are dropped. If none remain: 422 PAYOUT_REQUESTED_RAILS_UNAVAILABLE |
Unknown fields are rejected. Unknown rail ids, the retired crypto value, an empty rails
list or repeated rails return 400 (duplicates: “Each rail can be listed once”). Rail order is
normalized, so reordered rails replay with the same idempotency key. Different rails conflict.
A request without rails replays exactly as before (same key, same body).
Response
{
"success": true,
"message": "Payout created",
"responseObject": {
"payoutId": "cmg1x7k2p0003s60h4f9z2q8d",
"merchantReference": "withdrawal-8841",
"status": "AWAITING_FUNDING",
"fundingStatus": "AWAITING_FUNDING",
"payout": {
"amount": "100.00",
"chainId": 8453,
"tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"decimals": 6
},
"merchantFee": { "bps": 100, "amount": "1.041237" },
"funding": {
"chainId": 8453,
"tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"decimals": 6,
"amount": "104.123712",
"depositAddress": "0xe69f55d15f396f82e5da402a475d5fa72c3bd3db",
"quoteExpiresAt": "2026-09-25T18:06:36.911Z",
"receivedAmount": "0.00",
"sentAmount": "0.00",
"missingAmount": "104.123712",
"expectedAmount": "101.00"
},
"depositAmount": null,
"paidAmount": "0.00",
"payoutCurrency": null,
"partialFills": [],
"settlement": null,
"attempt": null,
"payoutTransfer": null,
"cancelSource": null,
"returnUrl": "https://casino.example.com/cashier",
"expiresAt": "2026-09-25T18:06:36.911Z",
"fundedAt": null,
"settledAt": null,
"cancelledAt": null,
"createdAt": "2026-09-24T18:06:36.911Z",
"checkoutUrl": "https://<checkout host>/c/cmg1x7k2p0003s60h4f9z2q8d?t=…",
"idempotentReplay": false
},
"statusCode": 201
}
Send funding.amount of funding.tokenAddress on funding.chainId to
funding.depositAddress (the bc1… address for BTC on Bitcoin), in one or more transfers,
before funding.quoteExpiresAt; see Funding for the deadline and what happens next.
Never send funds after quoteExpiresAt. Create a new payout with a new idempotency key.
PayoutView fields
| Field | Description |
|---|---|
status | AWAITING_FUNDING, FUNDED, READY, LISTING, PAYING, SETTLED, CANCELLED, or EXPIRED |
fundingStatus | AWAITING_FUNDING, PARTIALLY_FUNDED, FUNDED, or FUNDING_EXPIRED |
payoutId, merchantReference, payout, returnUrl | The payout id, your reference, the requested payout (amount, chainId, tokenAddress, decimals) and your return URL |
merchantFee | bps, and amount in decimal USDC estimated at quote time from the quoted deposit, rounded down. Relay charges the bps on what is actually deposited |
funding.chainId, funding.tokenAddress, funding.decimals | The funding chain, token address and token decimals |
funding.amount | What you send, in the funding token |
funding.depositAddress | Where you send it. null once funding.quoteExpiresAt passes, once fundingStatus is FUNDED, or once the payout is CANCELLED or EXPIRED; never send when it is null |
funding.receivedAmount | USDC that has arrived on Base so far, after conversion and fees |
funding.sentAmount | What you sent before the deadline, in the funding token. For Bitcoin, a transaction counts as sent once Relay records its sweep of the deposit, which can wait for the network to confirm it; until then it reads 0. |
funding.missingAmount | What you have not sent yet, in the funding token: funding.amount minus funding.sentAmount, never below 0. It stays above 0 on a payout that was funded short. For Bitcoin it stays at the full amount until Relay records the sweep. |
funding.expectedAmount | The USDC the quote delivers on Base: the payout plus the buffer |
depositAmount | The USDC the payout pays out, set when it is funded; null until then. A Peer-app withdraw after listing (CANCELLED) replaces it with the USDC the withdraw returned to the customer's Peer wallet. Later listings or payouts subtract paidAmount and refund losses reported in settlement.refundFeeAmount; see deposit size |
paidAmount | Decimal USDC buyers have paid the customer across every listing, partial payments included; "0.00" until a buyer pays. A crypto payout transfer never counts in it; payoutTransfer and settlement.sentAmount report it instead |
payoutCurrency | Saved fiat currency from the catalog above, or null for crypto or no method |
partialFills[].fiat | { currency, amount } at that payment’s bound rate, with a two-decimal fiat amount; null if its currency or rate is unknown |
settlement.buyerPaidFiat | Fiat totals across all buyer payments, one entry per currency in first-paid order. null if any payment’s fiat is unknown; [] if no buyer paid. This supplements the USDC breakdown |
partialFills | One entry per buyer payment that paid only part of what was listed, oldest first, with amount (decimal USDC), txHash (the payment on Base, lower-case) and paidAt (when Peer Pay recorded it, in ISO time). [] until a buyer pays part. The payment that pays the rest counts in paidAmount only. See partial payments |
settlement | Only when SETTLED: decimal USDC buyerPaidAmount, sentAmount (USDC sent for a crypto payout or remainder), returnedAmount (left in or returned to the customer’s Peer wallet), dustAmount (swept by the escrow when it closed the deposit), and refundFeeAmount (USDC lost on bridge refunds, "0.00" when none). sentTo is the customer’s recipient, never a deposit address: EVM lower-case; Solana, Tron, Bitcoin and transparent Zcash canonical; null when nothing was sent |
attempt | Latest payout attempt (kind, rail, status), or null. kind is ESCROW for a buyer-paid listing and CRYPTO for a crypto payout; a CRYPTO attempt’s rail is its network id, such as relay_42161. rail can be null. status is ACTIVE, CLOSED, SETTLED or FAILED |
payoutTransfer | Set only when SETTLED with a sent amount, including a send-to-address remainder. Destination chainId, tokenAddress, decimals, customer address, delivered amount in destination tokens, delivering txHash, Base usdcTxHash, and provider (RELAY, NEAR_INTENTS, or null for direct Base USDC). Otherwise null. address is canonical: EVM checksummed, bech32 lower-case, base58 as entered. Compare it with settlement.sentTo case-insensitively on EVM |
cancelSource | Once cancelled: MERCHANT (you, before funding) or PEER_APP (the customer withdrew the listing in the Peer app, or spent the funded USDC there so the wallet no longer covers it); otherwise null. The customer checkout never cancels a payout |
funding.quoteExpiresAt | The funding deadline; see funding for how it moves |
expiresAt | Always equals funding.quoteExpiresAt and moves with it |
fundedAt | When the payout became FUNDED; null until then |
settledAt | When the payout became SETTLED; null until then |
createdAt, cancelledAt | When the payout was created or cancelled, in ISO time; cancelledAt is null until cancellation |
The view never includes the customer's email, their wallet address, their payee handle or the Peer fee.
Lifecycle
status | Meaning | Webhook when it starts |
|---|---|---|
AWAITING_FUNDING | Waiting for your funds at the deposit address | PAYOUT_ORDER_CREATED |
FUNDED | The USDC is in the customer's wallet; they have not chosen a payout method that is ready yet (none chosen, or a Venmo, Cash App or PayPal account not connected yet) | PAYOUT_ORDER_FUNDED |
READY | The customer’s payout method is ready to confirm: a connected or skipped Venmo, Cash App or PayPal account; a Zelle, Revolut or Chime handle; or a crypto destination. A confirmed crypto payout may instead be bridging with no actions | none |
LISTING | Listed for a buyer to pay what is left, or a send-to-address remainder is bridging with no actions | none, or PAYOUT_ORDER_PARTIALLY_PAID when it comes back after a partial payment |
PAYING | A buyer is paying. The customer cannot change the payout app | PAYOUT_ORDER_MATCHED |
SETTLED | The payout ended: a buyer paid what was left, the crypto payout arrived at the customer’s address, the escrow swept a small rest as dust, or the customer withdrew the rest in the Peer app or sent it to an address after a partial payment. settledAt is set and settlement says where the USDC went | PAYOUT_ORDER_SETTLED |
CANCELLED | Ended without a payout, before any buyer paid part of it; see cancelSource | PAYOUT_ORDER_CANCELLED |
EXPIRED | No USDC arrived before the funding deadline | PAYOUT_ORDER_EXPIRED |
A bridged payout stays READY (whole payout) or LISTING (remainder), with no customer actions
until delivery. A verified refund returns it to READY with no merchant webhook; see crypto payouts.
If a buyer's payment expires or is released before it completes, the payout goes back from
PAYING to LISTING with no webhook, and a later buyer sends a new PAYOUT_ORDER_MATCHED.
Peer Pay follows the listing on Base. Moves normally show within seconds, and within about a minute when the indexer is slow.
Partial payments
A buyer may pay part of a listing. Peer Pay records it in paidAmount and partialFills, sends
PAYOUT_ORDER_PARTIALLY_PAID, and lists what is left again (LISTING), even below the listing
minimum, when the deposit accepts buyers and no exit is in progress. Another buyer can pay the rest; partial payments can repeat.
When the escrow sweeps a small rest as dust and closes the deposit, the payout settles with
settlement.dustAmount. The payment that leaves only dust is included in partialFills.
After any partial payment, every way the payout ends is SETTLED, with the breakdown in
settlement, never CANCELLED: a buyer pays the rest, the customer withdraws the rest to their Peer
wallet or sends it to an address (see after a partial payment), or the
customer takes the listing back or spends the rest in the Peer app.
Funding
- You send the funding token to the deposit address.
- Relay, the conversion service behind the deposit address, converts it and delivers USDC on Base to the customer's Peer app wallet.
- Peer Pay checks Base itself for the USDC that reached the customer's wallet and updates the payout. Relay's notifications only tell Peer Pay when to look; the amount on Base is what counts.
Peer Pay checks when Relay reports activity on the deposit address, and on a schedule until the
payout leaves AWAITING_FUNDING. You do not call anything. Read the payout back, or listen for
the payout webhooks.
| What has happened | fundingStatus | status | Webhook |
|---|---|---|---|
You sent the full funding.amount and USDC arrived, or the USDC received covers the payout | FUNDED | FUNDED | PAYOUT_ORDER_FUNDED |
USDC arrived below the payout and you sent less than funding.amount | PARTIALLY_FUNDED | AWAITING_FUNDING | PAYOUT_ORDER_PARTIALLY_FUNDED, on becoming partial or when the received amount grows |
| The deadline passed with some USDC received, below the payout, and nothing still converting | FUNDED | FUNDED | PAYOUT_ORDER_FUNDED; the deposit is set from what arrived |
| As above, with a transfer still converting | PARTIALLY_FUNDED | AWAITING_FUNDING | None until it lands |
A transfer failed or was refunded to your refundAddress (on EVM chains, the sending wallet when omitted; Bitcoin funding always has one) | Unchanged | Unchanged | None. The payout keeps waiting for funds until the deadline, and each refunded Relay request is recorded as a route refund |
funding.quoteExpiresAt passed, nothing arrived and nothing is still converting | FUNDING_EXPIRED | EXPIRED | PAYOUT_ORDER_EXPIRED |
While an on-time transfer is still converting, the payout keeps waiting rather than expiring,
even if you sent the full funding.amount. Only transfers that started at or before
funding.quoteExpiresAt count towards funding. For Bitcoin, a transfer starts when Relay first sees it in the mempool, before it confirms, so a transaction broadcast before the deadline counts even if it confirms after.
The funding deadline is 24 hours after the payout is created, by default. Each time Peer Pay sees more USDC arrive on time, the deadline moves to 24 hours after that, unless it is already later, so a top-up always has a full day. The customer withdraws whatever arrived when the deadline passes; funds are never sent back to you.
Never send funds after quoteExpiresAt. Create a new payout with a new idempotency key.
Deposit size
depositAmount is set when the payout becomes FUNDED, from the USDC received before the deadline:
- If it covers the payout, the deposit is the payout. It never exceeds the payout.
- If it is at most 1 USDC above a whole payout step (100 USDC by default), the deposit is rounded down to that step.
- Otherwise the deposit is the amount received.
For a 200 USDC payout with a 100 USDC step:
| USDC received | depositAmount | Why |
|---|---|---|
| 200.30 | 200.00 | Covers the payout, so the payout |
| 199.96 | 199.96 | 99.96 above the 100 step, more than 1 USDC, so kept as received |
| 100.80 | 100.00 | 0.80 above the 100 step, so rounded down |
The last two can happen when you sent the full funding.amount but the conversion delivered
less than the payout, or when the deadline passed before the rest arrived.
depositAmount is set once at funding. If the customer withdraws the listing in the Peer app (CANCELLED),
it is replaced with the USDC the withdraw returned to their Peer wallet. A later listing
or payout holds depositAmount minus paidAmount minus any refund losses from earlier crypto
payouts, reported in settlement.refundFeeAmount when the payout settles.
Funding issues: late funds, overpayment and route refunds
Pay records money that reaches a payout outside the normal funding path as a funding issue:
| Kind | When |
|---|---|
| Overpayment | You sent more than funding.amount and more USDC than the quote arrived; the issue is the extra USDC. Relay delivering slightly over its own quote for an exact send is not an overpayment |
| Late funds | USDC arrives after the payout left AWAITING_FUNDING (any later status), or comes from a transfer that started after the funding deadline; the issue is that amount |
| Route refund | Relay could not route a payment and sent it back to your refund address (on EVM chains, the sending wallet if omitted), in the token you sent. Relay does not auto-refund exchange wallets. For Bitcoin, Relay sends BTC to your Bitcoin refund address, and the issue carries the Bitcoin txid |
funding.receivedAmount keeps growing, but the payout is never reopened and the money is never
moved onto another payout. Each issue shows on the payout's dashboard page and timeline. Peer
support contacts you and settles it outside Pay, by returning the funds or by recording that they
were claimed; Pay itself moves no money for an issue.
Pay only watches the quoted route. Funds sent in a different token or on a different chain than quoted are not detected by Pay; contact Peer support.
Get a payout
GET /api/v1/payouts/{id}
X-API-Key: <your key>
Returns the PayoutView with message Payout found. A payout that belongs to another
merchant returns 404 PAYOUT_NOT_FOUND.
List payouts
GET /api/v1/payouts?customerEmail=player@example.com&status=AWAITING_FUNDING
X-API-Key: <your key>
| Parameter | Type | Description |
|---|---|---|
customerEmail | string? | Exact match, case-insensitive |
status | string? | One status value |
merchantReference | string? | Exact match |
page | number | Default 1 |
limit | number | 1–100, default 20 |
Newest first. The response is { items: PayoutView[], page, limit, total }.
Cancel a payout
POST /api/v1/payouts/{id}/cancel
X-API-Key: <your key>
Only a payout whose status and fundingStatus are both AWAITING_FUNDING can be cancelled.
Before cancelling, Peer Pay checks Relay for any record at the deposit address, including failed
or refunded transfers. Any record blocks cancellation; if no USDC arrives, the payout expires
at the funding deadline once nothing is still converting. On success the view comes
back with status: "CANCELLED" and cancelSource: "MERCHANT", and a PAYOUT_ORDER_CANCELLED
webhook fires.
Customer checkout
Send the customer to the checkoutUrl from create. You never call the customer
API behind it; it is internal to the checkout.
The customer sees your name, logo and the amount: the payout until funding, then the deposit the customer actually receives, or what came back after a refunded bridge. The page uses your checkout theme from Settings → Checkout, like the payment checkout. It reads your theme and branding setting on each load. Peer branding removal is configured by the team: it hides "Powered by Peer" but keeps the sign-in flow and the "Privacy Policy" link.
They then:
-
Sign in to their Peer account with a one-time code. The page emails one automatic code to the payout's email per page load and asks for it; the customer can resend it. A wrong account shows "This withdrawal belongs to a different Peer account" (
403 CASHOUT_PLAYER_MISMATCH); an expired login shows "Your sign-in has expired" (401 CASHOUT_LOGIN_INVALID). Both offer Sign out. Right after sign-in, the page attaches Peer Pay's payout signer to the customer's Peer wallet, limited by a payout-only policy: it can approve and use the Peer escrow, relist and withdraw from it, add the Peer Pay merchant group to Venmo or PayPal listings, and send up to 1,000 USDC per transfer on Base. Peer Pay then sends the approve, deposit, group configuration, relist, withdraw or USDC transfer itself, with gas sponsored; the customer never sees a wallet prompt for these sends. A customer with wallet MFA confirms once when the signer is attached. The setup screen says "Getting your withdrawal ready". If setup fails or takes 30 seconds, it says "We couldn’t get your withdrawal ready" and offers "Try again", with a "Manage your withdrawal" section that sends the customer to the Peer app (see the next step). -
Choose how to get paid. On first arrival, a banner says
{merchant} sent {amount} to your own wallet.When PayPal or Revolut is offered, a Currency selector appears above "Select withdrawal method". It defaults to the saved method's currency, then the last payout's currency if offered, then USD; there is no geolocation. Each currency shows only the fiat apps that pay it, with a display-only estimate such as “≈ €89.27”. Crypto stays available. Switching currency keeps a selected PayPal account or Revolut Revtag; a fiat app that cannot pay the new currency is deselected. Under "Select withdrawal method", there is one row per fiat app and a single Crypto row for every crypto network on the payout. The customer enters a Venmo username, a Cash App $Cashtag, a PayPal username and the email on that PayPal account, a Zelle email, a Revolut Revtag or a Chime $ChimeSign. The help line says "You receive your withdrawal at this account. Check it’s yours." PayPal works only for an account already set up in Peer (422 CASHOUT_PAYEE_NOT_REGISTERED). If a crypto network is on, the customer can instead take the payout as crypto. The checkout has no cancel: the money is already in the customer's own Peer account. The last row is Peer app, "Manage your withdrawal". Choosing it replaces Continue withYour {amount} is in your Peer account. Sign in to the Peer app with {masked email} to manage it.and App Store and Google Play buttons (a phone shows only its own store). The payout staysFUNDEDorREADYuntil the customer withdraws it here or moves the money in the Peer app. -
Connect the payout account where needed:
Rail How the customer connects Continue without verifying Venmo "Verify Venmo" opens a pop-up at app.peer.xyz/connect/venmofor Gmail, Outlook or iCloudEvery device Cash App, PayPal "Open the Peer App Clip" on iPhone/iPad; a QR code elsewhere Android and desktop; not iPhone/iPad Zelle, Revolut, Chime Nothing to connect; READYat once, and each buyer proves their paymentNo connect step The connect card is titled
Verify your {Rail}and saysRequired to withdraw to {account}.The desktop QR line isScan with your iPhone camera to open the Peer App Clip and verify {Rail}.On Android it isHave an iPhone? Scan this code with its camera to open the Peer App Clip and verify {Rail}.The page moves on by itself once connected. The skip button reads "Continue without verifying". Skipping makes the payoutREADY; once confirmed, it is listed without a connection. -
Confirm ("Start withdrawal"). Peer Pay lists the unpaid USDC from the customer's Peer wallet (the whole deposit the first time). For non-USD, the review shows “You receive ≈ €89.27” and explains that the exact amount is set when someone starts paying. If the estimate is unavailable it shows the currency name. The payout is
LISTINGonce the deposit lands. -
Receive. A buyer pays:
PAYINGwhile they pay, thenSETTLEDonce the listing is paid. A buyer who pays only part leaves the rest to be relisted when no exit is in progress; see after a partial payment. While waiting, non-USD amounts remain estimates. While a buyer pays, the known bound amount is exact (no “≈”), and Done and the player email use the known fiat paid. Unknown fiat falls back to the USDC amount. The page can be closed and reopened; once settled it shows what arrived and where.
The summary panel and the review and listing rows name where the customer receives the payout: "Receiving at Venmo @alice", for example. The label becomes "Received at" once settled.
A skipped listing still offers verification while it waits. The hint is
Verify your {Rail} to get paid faster. Some payments only go to verified accounts.,
with Verify {Rail}. The same live listing becomes available to merchants that take only
connected accounts as soon as Peer sees the connection. Nothing is relisted, and Peer Pay sends
nothing. A checkout state read or the credential job clears the skip when the connection is
active. While still skipped, the listing gets no reconnect email. If a connection later lapses,
the page shows Verify {Rail} again to get paid and Verify {Rail} again; only Venmo, Cash App and
PayPal have connections that can lapse.
Before the customer confirms, Peer Pay checks that their Peer wallet still holds this payout's
USDC, with the same newest-first rule as the background wallet check (a payout funded in the last
10 minutes is left out until its USDC shows in the balance). If the customer spent it in the Peer
app, the page says "Not enough USDC for this withdrawal", with rows "This withdrawal needs" and
"Available for it", says "This withdrawal closes in a few minutes." and offers only your contact
link. Confirming returns 409 CASHOUT_WALLET_SHORT. The background check, every 5 minutes, then
cancels it with cancelSource: "PEER_APP" before any partial payment; after a partial payment it
settles with settlement.returnedAmount. The page then says "Withdrawal closed" and
You moved this money in the Peer app.
While Peer Pay is sending, every tab shows "Starting your withdrawal", "Resuming your withdrawal",
"Updating your withdrawal" or "Sending your USDC". After a partial payment the latter screens
name the rest: "Resuming your remaining $50.00", "Moving your $50.00" or "Sending your $50.00".
Each says "This takes a few seconds." and "You can leave this page. It picks up where you left off."
Nothing else is offered; confirming, saving or changing the method, relisting or
sending to an address returns 409 CASHOUT_SEND_IN_PROGRESS. Peer Pay never sends a second
listing while the first may still land, because the customer's Peer wallet also holds their other
payouts. If the deposit fails or reverts, nothing is listed: the page says "That didn’t go through.
Try again." and the customer can confirm again. The page can be closed at any point; Peer Pay
finishes the send and lists the payout without it.
Venmo and PayPal listings. After the deposit, Peer Pay sends one more transaction that lets
Peer Pay merchants take the listing without staking (the Peer Pay merchant group). If Peer Pay can't
build that group transaction, confirming answers 503 CASHOUT_LISTING_UNAVAILABLE before anything is listed. The page
says "Starting your withdrawal" until it lands. If it fails, Peer Pay withdraws the listing and the
payout returns to FUNDED, keeping what buyers have already paid: the customer sees "That didn’t go through. Try again." and chooses
the payout method again. If a buyer is already paying, the withdraw waits until their payment
ends. If a buyer starts paying just as the withdraw lands, the listing takes no new buyers.
Peer Pay withdraws again once that payment ends, accounting for every withdrawal on the attempt. A payment that completes the payout settles as usual; a partial payment is kept, and the group exit returns the unpaid rest to FUNDED instead of relisting it. A dust sweep settles with the settlement breakdown.
If the signer is missing, because it was never attached or the customer removed it in the Peer
app, the page attaches it again before offering anything that sends, and those actions return
409 CASHOUT_SIGNER_NOT_ATTACHED until it is attached. Cancelling before the payout is listed
moves no money and needs no signer.
The page header links to your customer support from Settings → Payouts. It reads
Contact {merchant}, or "Help" on phones, once the customer is signed in and the state has loaded.
The expired, out-of-limits and wallet-short screens repeat Contact {merchant}. Without a support
link the page shows no contact line. It never sends customers to Peer support, because only you can
act on their payout.
A buyer who starts paying but doesn't finish returns the payout to LISTING, waiting for a new buyer.
Each listing asks one buyer to pay its whole amount (min = max): the original deposit or the
rest after a partial payment. If the buyer pays only part, Peer Pay records it and relists the
rest when the deposit still accepts buyers and no exit is in progress. The deposit must be between
$10 and $1,000 to be listed. A non-production environment may lower the minimum to as little as
$1; the summary's rails[].minAmount shows it. Outside that range the page says "This amount is outside the withdrawal limits" and "Choose another withdrawal method.",
with "Change withdrawal method", which opens the method list without that method's rail, and your contact link. Confirming returns 422 CASHOUT_AMOUNT_OUTSIDE_RAIL_LIMITS. What is left
after a partial payment is listed at any size up to $1,000, even below that minimum.
Before any buyer has paid part of the payout, while no buyer payment is in progress, the customer can
change the payout app. Peer Pay withdraws the listing, the payout returns to FUNDED, and the page
opens the method list, with its Peer app row, again. The checkout offers no cancel. After a partial
payment, see after a partial payment.
The customer can also leave outside Pay. If they withdraw the listing in the Peer app, the payout
is CANCELLED with cancelSource: "PEER_APP". If they spend the funded USDC in the Peer app
before listing, so their wallet no longer covers the payout, Peer Pay cancels it the same way,
newest payout first. Before any partial payment, both send PAYOUT_ORDER_CANCELLED. After a
partial payment, both end SETTLED with settlement.returnedAmount and send
PAYOUT_ORDER_SETTLED instead.
A relist can also be needed without a partial payment, after a Peer-app withdraw left a range
the deposit can't fill. The page shows "Your withdrawal is paused" and
Resume {amount} so someone can pay you.
My withdrawals, in the page header, lists the signed-in customer's payouts from you only, five at a time: this payout first, then the rest newest first, with the total count. Each page is read from Peer Pay as the customer moves through the list. The same header toggle reads "Back to this withdrawal" while the list is open.
Crypto payouts
When your payout rails include a supported relay_<chainId> or near_intents_133701, the customer can choose a coin,
network and address instead of a fiat payout app. The picker shows only networks on that payout:
| Rail id | Network | Coins |
|---|---|---|
relay_1 | Ethereum | ETH, USDC, USDT, PYUSD, WBTC |
relay_10 | Optimism | ETH, USDC, USDT |
relay_56 | BNB Smart Chain | BNB, USDC, USDT, WBTC |
relay_137 | Polygon | USDC, USDT, WBTC |
relay_480 | World Chain | ETH, USDC |
relay_999 | Hyperliquid | HYPE, USDC, USDH |
relay_5042 | Arc | USDC |
relay_8453 | Base | ETH, USDC, USDT, SOL, WBTC |
relay_42161 | Arbitrum | ETH, USDC, USDT, WBTC |
relay_8253038 | Bitcoin | BTC |
relay_728126428 | Tron | USDT |
relay_792703809 | Solana | SOL, USDC, USDT, PYUSD |
near_intents_133701 | Zcash | ZEC |
There is no buyer, LISTING, PAYING or PAYOUT_ORDER_MATCHED for a whole crypto payout;
the $10–$1,000 single-buyer listing range does not apply. Base USDC (relay_8453) is a direct
transfer with gas paid by Peer. Every other supported coin/network uses Relay, including
same-chain swaps on Base, except ZEC on Zcash, which uses NEAR Intents.
- The customer enters an address for the chosen network. Pay validates EVM checksums, Solana, Tron, Bitcoin and transparent Zcash formats. It refuses zero addresses where the network has one, the customer’s Peer wallet and configured escrows on every EVM chain, Base USDC on EVM, and the selected token’s contract or mint. Smart-contract wallets are accepted. EVM recipients are checksummed, Bitcoin bech32 lower-cased, and base58 recipients retain their case. The method is READY.
- The customer reviews "You receive": the exact USDC amount on Base for a direct payout, or
≈ {amount} {SYMBOL}for a bridged coin, refreshed every 30 seconds. The note says "Network and bridge fees come out of what you receive, so the amount can change a little before it arrives. Price updates every 30 seconds." Fees come out of the payout; the USDC sent is fixed (no gross-up). The arrival row reads "About a minute" for Base USDC, "Within an hour" for Bitcoin, "Within 30 minutes" for Zcash, and "In a few minutes" for other payouts. Under "Check the network", they tickThis address can receive {SYMBOL} on {Network}. Sending to the wrong network can’t be undone.This is required for every crypto payout, including Base USDC, and resets whenever the destination changes. On confirmation, Pay checks the network against the payout's current rails again, then sends the unpaid USDC (deposit minus buyer payments and refund losses). - A direct payout settles only on the exact Base USDC Transfer from the customer's wallet to their address. A bridged payout sends that USDC to the provider's deposit address and stays READY (or LISTING for a remainder) with no actions until the provider proves delivery to the customer's destination. The Base transfer alone sends no merchant webhook.
- A whole crypto payout’s delivery emits PAYOUT_ORDER_SETTLED with a CRYPTO attempt on the network rail, for example
{ "kind": "CRYPTO", "rail": "relay_42161", "status": "SETTLED" }.payoutTransfer.amountis the delivered destination token;settlement.sentAmountis the USDC sent from Base. The customer's email links to the delivering transaction on its network.
While bridging, the page says Sending to {Network}. Its subtitle is "This usually takes a few
minutes. You can close this page; we’ll email you when it arrives." For Bitcoin the first sentence
is "This can take up to an hour."; for Zcash it is "This can take up to 30 minutes."
The progress rows read {usdc} USDC left your wallet, Sending ≈ {amount} {SYMBOL} with
Receiving at {short address}, and Arrives on {Network}.
While a direct send is in flight, customer changes return 409 CASHOUT_SEND_IN_PROGRESS.
While a bridge is QUOTED or BRIDGING, actions are empty: method changes, confirm,
relist, skip-connect and send-to-address return 409 CASHOUT_PAYOUT_BRIDGING; preview returns
409 CASHOUT_PAYOUT_QUOTE_NOT_ALLOWED. No second payout starts while the first may arrive.
A failed Base transfer closes the attempt; a bridge already funded waits for evidence without
a timeout and is never failed for missing evidence.
A verified Base refund returns the payout to READY, with a PAYOUT_REFUNDED timeline entry and
no merchant webhook. The banner reads We couldn’t send {SYMBOL} on {Network}, so {X} USDC came back to your wallet. Try again, or choose another withdrawal method.
The title is "Try again", with "Withdraw again" and "Choose another withdrawal method". The
ACTION_REQUIRED email has subject Your withdrawal didn’t reach {Network}.
USDC retained on refunds is deducted from the next payout and reported in
settlement.refundFeeAmount when the payout eventually settles. There is no automatic retry.
Peer removes a network by editing the stored rails, including on open payouts. New selections,
previews, confirms and send-to-address requests on it then return
422 CASHOUT_PAYOUT_NETWORK_UNAVAILABLE, and the checkout reloads the list. An in-flight bridge
stays governed by send, delivery or refund evidence. You can only narrow the available networks
with the create request's rails.
An unknown payout rail (including retired crypto) returns 400 validation. Relay quote
refusals are mapped by its errorCode, never a status number in its message: AMOUNT_TOO_LOW
becomes 422 CASHOUT_PAYOUT_AMOUNT_TOO_LOW; NO_SWAP_ROUTES_FOUND,
NO_INTERNAL_SWAP_ROUTES_FOUND, NO_QUOTES, INSUFFICIENT_LIQUIDITY, CHAIN_DISABLED and
INVALID_OUTPUT_CURRENCY become 422 CASHOUT_PAYOUT_ROUTE_UNAVAILABLE. INVALID_ADDRESS
becomes 422 CASHOUT_ADDRESS_NOT_ALLOWED with { reason: "INVALID" }. Other Relay quote
failures become 502 CASHOUT_PAYOUT_QUOTE_FAILED.
NEAR's amount-too-low refusal (HTTP 400 with a message containing too low and try at least
followed by a minimum) becomes 422 CASHOUT_PAYOUT_AMOUNT_TOO_LOW. When NEAR_1CLICK_JWT is
unset or blank, ZEC previews and binding quotes return 422 CASHOUT_PAYOUT_ROUTE_UNAVAILABLE
before contacting NEAR or writing an attempt. Other NEAR request failures become
502 CASHOUT_PAYOUT_QUOTE_FAILED. In-flight bridges keep reading public status and settle or
refund only on evidence.
ZEC on Zcash
ZEC payouts use NEAR Intents on near_intents_133701, inside the customer’s one Crypto
option. The destination is chain 133701, token nep141:zec.omft.near, with 8 decimals.
Only mainnet transparent addresses (t1…, t3…) are accepted, trimmed without changing
case. Shielded and unified addresses are refused: an address longer than 64 characters
(after trimming) returns 400; a non-empty invalid address within that limit returns 422
CASHOUT_ADDRESS_NOT_ALLOWED with { "reason": "INVALID" }.
The arrival estimate is Within 30 minutes. The Base USDC transfer must start before
a send-by time (internal; not in any response), 15 minutes after the binding quote request
(5 minutes before NEAR’s 20-minute refund deadline). At or after that time, a transfer that
has never started fails internally as SEND_BY_PASSED, with no USDC sent, and the bridge becomes NOT_SENT. A whole payout stays
READY so the customer can confirm again; a send-the-rest exit settles with the unsent USDC
returned to the customer’s wallet. A started send is never re-sent at or past send-by; it is
resolved from chain evidence. With none it stays open and Peer is alerted;
if no transfer was sent, the USDC stays in the wallet. It is never failed merely because time passed.
A verified refund returns USDC to the customer’s wallet, less NEAR’s refund fee. The payout
returns to READY, and the next payout deducts that loss; settlement.refundFeeAmount
reports it when the payout settles. Price movement affects the ZEC delivered, bounded by
the quote’s minAmountOut.
ZEC is a payout token only; it cannot fund a payout.
After a partial payment
If a buyer pays $150 of a $200 payout, once the rest is relisted, the page title reads "$150.00 paid, $50.00 still to come", with a Paid row showing "$150.00 of $200.00". When the deposit accepts buyers and no exit is in progress, Peer Pay lists the rest again on its own; while it sends, the page says "Resuming your remaining $50.00".
If a withdraw lands while a buyer holds the listing, it returns nothing and leaves the deposit
listed but no longer taking new buyers: EscrowV2 withdrawDeposit clears acceptingIntents.
This can be a send-to-address, change-method or group exit Pay sends, or a withdraw in the Peer app.
If that buyer then pays only part, Peer Pay does not list the rest again. The page offers the
choices below when no exit send remains in progress; sending to an address also needs a crypto network on the payout. A required group exit finishes automatically as described above; any earlier withdrawals remain accounted for. In the customer checkout state,
partialPayment.acceptingBuyers is then false. It is true while the listed deposit takes new
buyers, and false when nothing is listed.
While no buyer is paying and no send or required group exit is in progress, the customer has the choices below. Sending to an address is offered only if the payout includes a crypto network:
- Send $50.00 to a crypto address. The customer chooses a coin, network and address using
{ rail, tokenAddress, address }, with the same checks as a crypto payout. The form warnsThis address must accept {SYMBOL} on {Network}. Crypto transfers can’t be reversed.They tickThis address can receive {SYMBOL} on {Network}. Sending to the wrong network can’t be undone., review the estimate and confirm. Peer Pay withdraws the listing, then sends the remainder as direct Base USDC or through Relay or NEAR Intents. A bridged remainder stays LISTING with no actions until delivery, then settles withsettlement.sentAmount, the customer’ssettlement.sentTo, andpayoutTransfer. A verified bridge refund returns to READY with that destination saved, so the customer can try again; the next payout subtractsrefundFeeAmount. A removed network must be changed first. If the Base transfer fails after the withdraw lands, the USDC stays in the customer’s Peer wallet and the payout settles withsettlement.returnedAmount. If the withdraw fails, nothing is sent and the rest stays listed. - Change withdrawal method. Peer Pay withdraws the listing and returns the payout to
FUNDED, keepingdepositAmountandpaidAmount. Once the customer sets up the new method (connects or skips connecting Venmo, Cash App or PayPal, enters a Zelle, Revolut or Chime account, or chooses a crypto destination) and confirms, only the unpaid amount (depositAmountminuspaidAmountand bridge refund losses) is listed or sent, below the listing minimum if needed. The method list's Peer app row names the rest:Your $50.00 is in your Peer account.If the customer moves it in the Peer app, the payout settles withsettlement.returnedAmount.
While a buyer pays the rest, nothing is offered. If listing the rest again fails, the listing keeps its old range and no buyer can take it. The page says "We couldn’t resume the $50.00. Try again." and offers "Resume $50.00" alongside the two choices.
Embedding the customer checkout
Pass checkoutUrl through ensureEmbedModeUrl, which adds embed=true. Embedded mode applies
only inside a frame. It drops the outer padding, border and shadow, including on the loading
and link-error screens.
The checkout posts events to the parent window on channel zkp2p_checkout_embed_v1:
| Event | Payload | Meaning |
|---|---|---|
checkout.success | { payoutId, status: "LISTING" } | First listing: listed, not paid |
checkout.success | { payoutId, status: "SETTLED" } | The payout settled; a whole crypto payout posts only this success |
checkout.failed | { payoutId, reason } | reason is EXPIRED or LINK_INVALID |
A Peer-app close posts nothing. See the payment checkout's own events for its separate payloads. Embedded mode is in beta; use the hosted page for production.
Customer emails
The customer gets these emails, each with its own link back to the checkout:
- FUNDED:
Your {amount} from {merchant} is ready, with the available payout methods. - CANCELLED: only after the customer moved the money in the Peer app (a cancel before funding
sends no email):
Your withdrawal from {merchant} is closed, "You moved the money in the Peer app. Sign in to the Peer app with this email to see what’s in your wallet." - PARTIALLY_PAID:
You were paid part of your withdrawal from {merchant}. When the rest is relisted, it says "Someone paid $150.00 of your $200.00 withdrawal. Your remaining $50.00 is waiting for another payment." Otherwise it says "Someone paid $150.00 of your $200.00 withdrawal. $50.00 is left. Open your withdrawal to see what happens to it." - SETTLED:
You received {amount} from {merchant}, orYour withdrawal from {merchant} is completewhen there is a breakdown. It saysPeer users paid you {amount}.,{amount} went back to your own wallet., andThe last {amount} was too small to pay out.for the parts that apply. A bridged remainder saysYou received {amount} {SYMBOL} at {short address} on {Network}.; a direct remainder saysYou received {amount} USDC at {short address} on Base.A direct crypto payout names the USDC actually sent, after earlier payout refund fees, rather than the original deposit. The settled email also covers withdrawing, keeping or sending the rest after a partial payment. - ACTION_REQUIRED:
Verify {Rail} again to keep your withdrawal movingonce when a connection lapses;Your {amount} withdrawal is still waitingonce per dropped buyer; orYour withdrawal didn’t reach {Network}after a refunded crypto payout, with "Try again".
Every buyer payment sends exactly one email: a partial-payment email whether or not the rest is
relisted, and only the settled email for the final payment, including a payment that leaves only
dust. Once the payout is CANCELLED, SETTLED or EXPIRED, the merchant link and email links
only show how it ended; nothing can be changed through them.
The one exception is a payment that leaves only dust but that Peer Pay first records as a partial payment against a deposit read made before the escrow swept the dust. It gets its partial-payment email and then the settled email. Peer Pay cannot tell that payment apart without the escrow's dust threshold, which it never reads.
Customer API reference
For a funded payout whose Venmo, Cash App or PayPal method needs a connection, the customer actions include
CONNECT_RAIL and SKIP_CONNECT. POST /api/v1/cashout-checkout/:id/skip-connect needs the
link token and customer login, with no body. It returns the fresh checkout state with status
READY and method.connectSkipped: true. Venmo, Cash App and PayPal methods always report connectSkipped as a
boolean. Saving any payout method or restoring the saved crypto method after a refunded send-to-address exit clears the skip. Withdrawing a listing to change its
method returns the payout to FUNDED with no method and clears the skip too; a new Venmo,
Cash App or PayPal method must connect or skip again. A QUOTED or BRIDGING bridge returns
409 CASHOUT_PAYOUT_BRIDGING first. Otherwise, if SKIP_CONNECT is not offered, the route returns
409 CASHOUT_SKIP_NOT_ALLOWED with { status, actions }; final payout links reject POSTs with
401 CASHOUT_LINK_EXPIRED.
The fiat method route is PUT /api/v1/cashout-checkout/:id/payout-method, with the checkout
link token and Peer-app login. PayPal and Revolut require an explicit currency, including USD:
{ "rail": "revolut", "payeeHandle": "alice", "currency": "EUR" }
{ "rail": "paypal", "payeeHandle": "alice", "paypalEmail": "alice@example.com", "currency": "EUR" }
currency must be in the rail's catalog above. Both bodies are strict: omitting it gives
400; an old PayPal or Revolut checkout tab must reload. Other fiat rails retain their
existing bodies and reject a currency field. A currency outside the rail's catalog
returns 400 Invalid request.
Oracle estimate failures never block saving or confirming.
GET /api/v1/cashout-checkout/:id/state adds these informational fields:
| Field | Meaning |
|---|---|
method.currency, lastPayoutTarget.currency | The currency on a fiat method; crypto methods have no currency |
payoutCurrencies | Offered { currency, rails, rate, estimate } entries, USD first. Only rails on this payout count. rate and estimate are null for USD or a failed read; a missing unpaid amount also makes estimate null |
fiatPricing | { currency, rate, unpaid, listed } for a saved non-USD method; null for USD, crypto or no method. The rate is { currency, rate } with six decimals in fiat per USD. Amounts are { currency, amount } with two decimals; missing estimates are null, and listed is null without a live listing |
buyerPayment | Exact { currency, amount } while PAYING with a known bound rate; otherwise null |
partialPayment.paidFiat | Totals in first-paid currency order; null if any payment's fiat is unknown, [] if no buyer paid. partialPayment itself is null until a partial payment |
settlement.buyerPaidFiat | The same grouped buyer totals once SETTLED; all other settlement amounts remain USDC |
These internal crypto routes require the checkout link token and Peer-app login:
| Route | Body and result |
|---|---|
PUT /api/v1/cashout-checkout/:id/payout-method | { rail, tokenAddress, address }; saves the canonical destination and makes the payout READY |
POST /api/v1/cashout-checkout/:id/payout-quote | Same body; returns CashoutPayoutQuote without storing it |
POST /api/v1/cashout-checkout/:id/confirm | No body; rechecks the saved destination and takes a fresh binding quote if bridged |
POST /api/v1/cashout-checkout/:id/send-to-address | Same destination body; withdraws and sends the listed remainder |
For example, Base USDC uses:
{
"rail": "relay_8453",
"tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"address": "0x1234567890123456789012345678901234567890"
}
rail names the network; do not send a separate chainId. The strict schema accepts only
crypto rails and trimmed tokenAddress and address strings of 1–64 characters. All three
bodies reject unknown fields. The token must be in the payout catalog for that network.
The preview is available in READY with a crypto method and CONFIRM offered, or in LISTING
with SEND_TO_ADDRESS offered. It prices the unpaid USDC in READY and the listed remainder
in LISTING. It returns destination, usdcAmount (decimal USDC), estimatedAmount (decimal
destination token) and ISO quotedAt, inside the standard envelope with message “Payout priced”.
For Base USDC the two amounts are equal and Relay is not called. A zero output, including
in a preview, returns 422 CASHOUT_PAYOUT_AMOUNT_TOO_LOW. An unavailable action returns
409 CASHOUT_PAYOUT_QUOTE_NOT_ALLOWED with { status, actions }.
The estimate is not a promise. Confirm and send-to-address each take a new, strict, exact-input
Relay or NEAR Intents quote, with the customer’s Peer wallet as refundTo, and send it once. A refused or failed
quote creates no attempt, transaction or bridge. No delayed binding quote is refreshed or reused;
a later refund lets the customer confirm again for a fresh quote.
In CashoutCheckoutState:
methodfor crypto is{ rail, destination }, with destinationchainId,tokenAddress,symbol,decimalsand canonicaladdress.lastPayoutTargetisPayoutMethodView | null:{ rail, handle, currency }for fiat or{ rail, destination }for crypto. It only prefills an available rail and never a bridge deposit address.settleTxis{ chainId, txHash } | null: the destination transaction for a delivered bridge, or the Base transaction for other settled payouts. It is null when the newest attempt’s newest bridge was refunded; the refunded Base send is not a settling transaction.payoutBridgeis the newest attempt’s newest bridge, ornullwhen absent or NOT_SENT. It containsprovider,status(QUOTED, BRIDGING, DELIVERED or REFUNDED),destination,usdcAmount,estimatedAmount,deliveredAmount,destinationTxHash,refundedAmountandrefundTxHash. The delivered and estimated amounts use destination-token decimals; the USDC and refunded amounts use USDC decimals. Unresolved result fields arenull.
Errors
Failures use the standard envelope with success: false and a message; the merchant failures
below also carry an errorCode. Validation failures (400, Invalid request, with details in
responseObject.fieldErrors and responseObject.formErrors), authentication failures (401)
and rate limits (429) carry no errorCode. An unexpected 500 (Internal server error) also
has no errorCode.
| Status | errorCode | When |
|---|---|---|
| 400 | INVALID_IDEMPOTENCY_KEY | The Idempotency-Key header is missing or malformed |
| 400 | PAYOUT_TOKEN_UNSUPPORTED | The merchant create request’s payout is not USDC on Base (independent of the customer’s destination) |
| 400 | PAYOUT_NOT_STEP_MULTIPLE | The payout is below one payout step or not a whole multiple of it |
| 400 | PAYOUT_ABOVE_MAX | The payout is above the payout maximum |
| 400 | PAYOUT_FUNDING_TOKEN_UNSUPPORTED | The funding token is unsupported, switched off by Peer, or not in your allowed funding tokens |
| 403 | IP_NOT_ALLOWED | Your API-key IP allowlist refused the request; see IP allowlist |
| 403 | PAYOUT_SANDBOX_UNSUPPORTED | The API key belongs to a sandbox merchant |
| 403 | PAYOUTS_DISABLED | No payout methods are turned on in your payout settings |
| 403 | PAYOUT_MERCHANT_WALLET_MISSING | You charge a merchant fee but have no merchant wallet to receive it |
| 404 | PAYOUT_NOT_FOUND | No payout with this id for your merchant |
| 409 | MERCHANT_CONFIG_MISSING | Your merchant account setup isn't finished (create) |
| 409 | IDEMPOTENCY_KEY_CONFLICT | The key was already used with a different body |
| 409 | IDEMPOTENCY_REQUEST_IN_PROGRESS | A request with this key is still being processed |
| 409 | PAYOUT_NOT_CANCELLABLE | The payout is no longer unfunded: status or fundingStatus is not AWAITING_FUNDING. responseObject is { status, fundingStatus } |
| 409 | PAYOUT_FUNDING_DETECTED | Relay has a record for the deposit address, including a failed or refunded transfer. The payout can't be cancelled; if no USDC arrives, it expires at the funding deadline once nothing is still converting |
| 422 | PAYOUT_REQUESTED_RAILS_UNAVAILABLE | None of the request’s rails remains effective: “None of the requested payout methods are available right now” |
| 422 | PAYOUT_NO_RAILS_AVAILABLE | Peer has switched off every payout method you turned on, crypto networks included |
| 422 | PAYOUT_ROUTE_UNQUOTABLE | This funding route cannot be priced for the requested payout |
| 422 | PAYOUT_FUNDING_AMOUNT_TOO_LOW | Relay refused the funding route with AMOUNT_TOO_LOW: “This payout is too small to route from this funding token. Choose a larger payout or another funding token.” |
| 502 | PAYOUT_PRIVY_WALLET_FAILED | The customer's wallet could not be prepared; retry with the same key |
| 502 | PAYOUT_RELAY_QUOTE_FAILED | Any other Relay quote failure, including coded refusals a retry won't fix. Retry with the same key; if it keeps failing, use another funding token |
| 502 | PAYOUT_RELAY_STATUS_FAILED | Funding could not be checked before cancelling; retry the cancel |
A request body that fails validation returns 400 before the idempotency key is checked. For example, Bitcoin funding without refundAddress returns 400 with Bitcoin funding needs a refund address in responseObject.fieldErrors.funding. An invalid Bitcoin refund address gives Must be a Bitcoin address; a wrong Bitcoin token gives Must be the BTC token address. On other chains, a non-EVM funding.tokenAddress gives Must be an EVM address, also a 400 validation error, before the funding-token availability check.
The customer checkout behind checkoutUrl returns these codes. You never call it, but you may
see them in support conversations. The messages below are the customer page’s copy, mapped from
the API error codes; the raw API message can differ. Except for CASHOUT_WALLET_SHORT, customer 409 responses
carry { status, actions } in responseObject.
| Status | errorCode | When |
|---|---|---|
| 401 | CASHOUT_LINK_EXPIRED | “This withdrawal has ended. Open the Peer app to see what’s in your wallet.” An action on a final payout; responseObject is { status }. GETs remain readable |
| 401 | CASHOUT_LOGIN_INVALID | “Your sign-in has expired. Sign in again to continue.” The Peer login token could not be verified |
| 401 | CASHOUT_LOGIN_REQUIRED | “Sign in to continue.” Missing or empty Bearer login token |
| 401 | CASHOUT_TOKEN_INVALID | “This link doesn’t work. Ask whoever sent it for a new one.” Missing or wrong link token, or unknown payout id |
| 403 | CASHOUT_PLAYER_MISMATCH | “This withdrawal belongs to a different Peer account.” The signed-in Peer account does not match the payout |
| 404 | CASHOUT_NOT_FOUND | “We couldn’t find this withdrawal.” The payout was not found after link authentication |
| 409 | CASHOUT_ATTEMPT_NOT_LISTED | “This withdrawal already moved on. The page now shows where it is.” There is no active escrow listing to withdraw |
| 409 | CASHOUT_BUYER_PAYMENT_ACTIVE | “Someone is paying you right now, so this can’t change.” A method change cannot withdraw while a buyer pays |
| 409 | CASHOUT_CONFIRM_CONFLICT | “This withdrawal already moved on. The page now shows where it is.” A concurrent change prevented confirmation or send-to-address; the page reloads the payout |
| 409 | CASHOUT_METHOD_LOCKED | “You can’t change the withdrawal method right now.” Saving or changing the method is not offered, or the payout changed before it could be saved |
| 409 | CASHOUT_NOT_READY | “This withdrawal already moved on. The page now shows where it is.” Confirmation has no eligible method, state or unpaid amount |
| 409 | CASHOUT_PAYOUT_BRIDGING | “Your withdrawal is on its way. Wait for it to arrive.” Customer actions blocked by a QUOTED or BRIDGING bridge |
| 409 | CASHOUT_PAYOUT_QUOTE_NOT_ALLOWED | “This withdrawal can’t be priced right now.” Preview has no eligible action or its amount is zero |
| 409 | CASHOUT_RELIST_NOT_ALLOWED | “You can’t resume this right now. The page now shows where your withdrawal is.” Relist is not offered: the range already matches what is left, a buyer is paying, the listing no longer takes new buyers, or the payout changed. Also returned when the remainder is below the method’s minimum |
| 409 | CASHOUT_REMAINDER_CHANGED | “What’s left changed. Check the amount and try again.” The listed remainder changed before send-to-address could start; the page reloads the payout |
| 409 | CASHOUT_SEND_IN_PROGRESS | “Your withdrawal is being sent.” A listing (including its group configuration), relist, withdraw or USDC transfer is in progress, including a remainder exit. Confirming, saving or changing the method, relisting and sending to an address are blocked |
| 409 | CASHOUT_SEND_NOT_ALLOWED | “This can’t be sent to an address right now.” Sending needs a partial payment, a listed remainder, no buyer payment or send in progress, and a crypto network on the payout |
| 409 | CASHOUT_SIGNER_NOT_ATTACHED | “Your withdrawal is still getting ready. Try again in a few seconds.” The payout signer is not attached; the page attaches it before retrying an action that sends |
| 409 | CASHOUT_SKIP_NOT_ALLOWED | “You can’t skip connecting right now.” SKIP_CONNECT is not offered; a QUOTED or BRIDGING bridge returns CASHOUT_PAYOUT_BRIDGING first |
| 409 | CASHOUT_WALLET_SHORT | “Your wallet no longer has enough USDC for this withdrawal.” The checkout offers only your contact link until the background check closes the payout. responseObject is the checkout state, including walletShortfall with decimal USDC requiredAmount and availableAmount |
| 422 | CASHOUT_ADDRESS_NOT_ALLOWED | “That address can’t receive withdrawals”: the crypto payout or remainder address is refused; responseObject.reason is INVALID, ZERO, OWN_WALLET, ESCROW or TOKEN. For INVALID, the page says “That isn’t a wallet address” or Enter a valid {Network} address when the network is known. For OWN_WALLET, it says “That’s the wallet the money is already in. Enter a different address.” |
| 422 | CASHOUT_AMOUNT_OUTSIDE_RAIL_LIMITS | “This amount is outside the limits for that withdrawal method. Choose another.” The unpaid amount cannot be listed on this method |
| 422 | CASHOUT_CREDENTIAL_INACTIVE | “Connect your account first.” Confirmation needs an active connection and the customer has not skipped connecting |
| 422 | CASHOUT_PAYEE_HANDLE_INVALID | The account does not fit the platform rules, or Peer refused it. Messages are “Enter a valid Venmo username”, “Enter a valid $Cashtag”, “Enter a valid PayPal username”, “Enter a valid Zelle email”, “Enter a valid Revtag” or “Enter a valid $ChimeSign” |
| 422 | CASHOUT_PAYEE_NOT_FOUND | Peer found no such account. Messages begin “We couldn’t find that” and name the Venmo username, $Cashtag, PayPal username, Zelle email, Revtag or $ChimeSign; they ask the customer to check spelling and, for Cash App, public visibility, or for Revolut/Chime, discoverability |
| 422 | CASHOUT_PAYEE_NOT_REGISTERED | “Set up this PayPal account in Peer first, then try again.” The PayPal account is not registered in Peer |
| 422 | CASHOUT_PAYOUT_AMOUNT_TOO_LOW | This amount is too small to send as {SYMBOL} on {Network}. Choose another coin or network. For Zcash: This amount is too small to send as ZEC on Zcash. Choose another coin or network. Relay or NEAR minimum refusal, or zero output, in preview or binding quote |
| 422 | CASHOUT_PAYOUT_DESTINATION_UNSUPPORTED | “This coin or network isn’t available for withdrawals.” The token is not supported on the selected network |
| 422 | CASHOUT_PAYOUT_NETWORK_UNAVAILABLE | {Network} isn’t available for this withdrawal. Choose another network. For Zcash: Zcash isn’t available for this withdrawal. Choose another network. The rail is absent from current payout rails, including after ops remove it. Without a known network, the page uses “That network” |
| 422 | CASHOUT_PAYOUT_ROUTE_UNAVAILABLE | {SYMBOL} on {Network} isn’t available right now. Choose another coin or network. The provider route is unavailable; see crypto payouts |
| 422 | CASHOUT_PAYPAL_EMAIL_INVALID | “Enter a valid PayPal email”. The supplied PayPal email cannot be normalized as an account email |
| 422 | CASHOUT_PAYPAL_EMAIL_MISMATCH | “That email isn’t the one on this PayPal account.” The email does not match the registered PayPal payee |
| 422 | CASHOUT_RAIL_UNAVAILABLE | “That withdrawal method isn’t available. Choose another.” The selected fiat rail is absent from the payout |
| 502 | CASHOUT_PAYOUT_QUOTE_FAILED | “We couldn’t price this withdrawal right now. Try again.” Any other quote failure |
| 503 | CASHOUT_LISTING_UNAVAILABLE | “Withdrawals to that app aren’t available right now. Try again later.” Peer Pay cannot prepare the Venmo or PayPal group transaction; nothing was sent |
| 503 | CASHOUT_PAYEE_UNAVAILABLE | “Something on our side isn’t responding. Try again shortly.” Peer could not check or register the payout account |
See crypto payouts for provider refusals and network availability.