Payouts
A payout pays a customer out: you fund it with crypto, and the customer takes it as Venmo, Cash App, PayPal, Zelle, Revolut, Chime or crypto through a checkout link. The Payouts API explains the lifecycle, the money rules and funding; this page shows the SDK calls for it.
Players can be paid in catalog currencies on PayPal and Revolut by default; you still fund and see USD. Currencies come from the rail catalog. Merchant requests, fees and accounting remain USDC; the fiat fields are information only.
Every call sends your merchant API key in the X-API-Key header. Call these only from a trusted
backend, never in a browser. The only part the customer sees is the checkoutUrl link you hand
them.
import type { PayoutClientOptions } from '@zkp2p/pay-sdk';
const options: PayoutClientOptions = {
apiBaseUrl: 'https://api.pay.peer.xyz',
apiKey: process.env.ZKPAY_API_KEY!,
// Optional: fetcher (a custom fetch) and signal (an AbortSignal).
};
Create a payout
funding.refundAddress is optional for EVM funding. Omit it to refund the wallet that sent the funding
if Relay cannot convert it. Set it when funding from an exchange withdrawal: Relay
does not automatically refund exchange wallets. Bitcoin funding requires it: a Bitcoin address you control.
import { createPayout } from '@zkp2p/pay-sdk';
const payout = await createPayout(
{
customerEmail: withdrawal.playerEmail,
merchantReference: withdrawal.id,
payout: {
amount: '100',
chainId: 8453,
tokenAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base
},
funding: {
chainId: 8453,
tokenAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
refundAddress: '0xYourTreasury…',
},
returnUrl: 'https://casino.example.com/cashier',
// rails: ['venmo', 'relay_8453', 'near_intents_133701'], // limits the payout to these rails; never adds a rail you turned off
},
{ idempotencyKey: `withdrawal-${withdrawal.id}` },
options,
);
// Fund it: send payout.funding.amount to payout.funding.depositAddress
// before payout.funding.quoteExpiresAt, then give the customer payout.checkoutUrl.
payout is always USDC on Base (chain 8453): it sets the amount. The customer picks the coin
and network they receive at checkout.
To fund with BTC on Bitcoin, use chain 8253038, the BTC token address and your own Bitcoin refund address:
const btcPayout = await createPayout(
{
customerEmail: withdrawal.playerEmail,
merchantReference: withdrawal.id,
payout: { amount: '100', chainId: 8453, tokenAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' },
funding: {
chainId: 8253038, // Bitcoin
tokenAddress: 'bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmql8k8', // BTC
refundAddress: 'bc1q…', // a Bitcoin address you control
},
},
{ idempotencyKey: `withdrawal-${withdrawal.id}` },
options,
);
idempotencyKey is required, and the SDK never makes one up. Use one key per withdrawal and keep
it the same across retries: 8–128 letters, digits, _ or - (the SDK checks this before
sending). A retry with the same key and the same body returns the same payout, with
idempotentReplay: true and its current checkoutUrl, so a timeout never creates a second payout.
The same key with a different body is refused with a 409 PayApiError
(IDEMPOTENCY_KEY_CONFLICT).
rails is optional: it limits the merchant’s effective rails, never widens them. Requested
rails you or Peer turned off are dropped. If none of the requested rails remain in the
effective set, the call fails with 422 PAYOUT_REQUESTED_RAILS_UNAVAILABLE. An empty or
duplicate list or an unknown id is a 400. Reordering a list replays the same request; changing
its members with the same key conflicts. A request without rails replays exactly as before (same key, same body).
Get, list and cancel
import { cancelPayout, getPayout, listPayouts } from '@zkp2p/pay-sdk';
const view = await getPayout(payoutId, options);
const page = await listPayouts(
{ merchantReference: withdrawal.id, status: 'FUNDED', page: 1, limit: 20 },
options,
);
// page.items (newest first), page.page, page.limit, page.total
const cancelled = await cancelPayout(payoutId, options);
listPayouts takes customerEmail (case-insensitive), merchantReference (exact),
status, page and limit; filters you leave out aren't sent. page defaults to 1, and
limit defaults to 20 with a maximum of 100.
cancelPayout works only while the payout is awaiting funding and nothing has arrived.
If Relay has any record for the deposit address, cancel fails with 409 PAYOUT_FUNDING_DETECTED.
If Pay cannot check with Relay, it fails with 502 PAYOUT_RELAY_STATUS_FAILED; retry.
See cancel a payout.
All four return a PayoutView (inside items for a list),
and createPayout adds checkoutUrl and idempotentReplay.
Fiat payout fields in 7.0.0
PayoutView.payoutCurrency is the saved fiat method's catalog currency, or null for
crypto or no method. partialFills[].fiat and partialPayment.fill.fiat on a partial-payment
webhook are PayoutFiatValue | null: { currency, amount }, with a two-decimal fiat amount
at that buyer's bound rate. A missing currency or bound rate produces null, never an estimate.
settlement.buyerPaidFiat contains one total per currency, in first-paid order; null means
some payment's fiat is unknown, and [] means no buyer paid. A currency change can produce
more than one entry. USD/USDC amounts remain authoritative for funding and reconciliation.
The SDK exports PayoutCurrency, PayoutCurrencyType and PayoutFiatValue. Checkout-only
pricing and selector types live in @zkp2p/pay-shared; see types.
These fiat fields ship in payout webhook version 2 (PAYOUT_WEBHOOK_VERSION is 2).
Errors
An API refusal throws a PayApiError with the API's message, statusCode and errorCode.
A 400 body-validation failure has no errorCode; it carries fieldErrors and formErrors
instead. responseObject holds any detail the API sent, such as status and fundingStatus
for PAYOUT_NOT_CANCELLABLE.
import { PayApiError, createPayout } from '@zkp2p/pay-sdk';
try {
await createPayout(params, { idempotencyKey }, options);
} catch (error) {
if (error instanceof PayApiError && error.errorCode === 'PAYOUT_NOT_STEP_MULTIPLE') {
// Round the payout to a multiple of your payout step and retry with a new key.
}
throw error;
}
Common codes:
errorCode | Status | Meaning |
|---|---|---|
IDEMPOTENCY_KEY_CONFLICT | 409 | The key was already used with a different body |
PAYOUT_ABOVE_MAX | 400 | The payout amount is above the payout maximum |
PAYOUT_TOKEN_UNSUPPORTED | 400 | The payout amount must be denominated in USDC on Base |
PAYOUT_NOT_STEP_MULTIPLE | 400 | The payout is not a multiple of your payout step |
PAYOUT_FUNDING_TOKEN_UNSUPPORTED | 400 | The funding token isn't supported, Peer switched it off, or it isn't in your allowed funding tokens |
PAYOUT_FUNDING_AMOUNT_TOO_LOW | 422 | Relay can't route a payout this small from this funding token |
MERCHANT_CONFIG_MISSING | 409 | Your merchant account setup isn't finished |
PAYOUTS_DISABLED | 403 | You have no payout methods turned on in your payout settings |
PAYOUT_NO_RAILS_AVAILABLE | 422 | Every payout method you turned on, crypto networks included, is switched off by Peer |
PAYOUT_REQUESTED_RAILS_UNAVAILABLE | 422 | None of the requested payout methods are available right now |
PAYOUT_FUNDING_DETECTED | 409 | Relay has a record for the deposit address |
PAYOUT_RELAY_STATUS_FAILED | 502 | Pay could not confirm with Relay that the payout is unfunded; retry |
PAYOUT_NOT_CANCELLABLE | 409 | Some funding already arrived, or the payout is past awaiting funding |
The full list is on the API page. An invalid or missing idempotencyKey
or an empty payoutId throws a TypeError. A missing apiKey throws an Error. These checks
fail before any request is sent and do not throw PayApiError. The API rejects an invalid
Idempotency-Key header with 400 INVALID_IDEMPOTENCY_KEY; the SDK checks it locally first.
Webhooks
Verify the signature first, as shown in verification. Then parse the
body and narrow it: payout events carry a PayoutView, not the order envelope.
import {
isPayoutWebhook,
type PayoutPartialFillView,
type PayoutPartialPayment,
type PayoutSettlementBreakdown,
type WebhookPayload,
} from '@zkp2p/pay-sdk';
const payload = JSON.parse(rawBody) as WebhookPayload; // after verifying the signature
if (isPayoutWebhook(payload)) {
// payload.version === 2; payload.data is a PayoutView
if (payload.type === 'PAYOUT_ORDER_PARTIALLY_PAID') {
const partial: PayoutPartialPayment = payload.partialPayment;
recordPartialPayment(payload.data.merchantReference, partial.fill, partial.remainingAmount);
}
if (payload.type === 'PAYOUT_ORDER_SETTLED') {
const settlement: PayoutSettlementBreakdown | null = payload.data.settlement; // set on every SETTLED payout
const fills: PayoutPartialFillView[] = payload.data.partialFills;
markPaid(payload.data.merchantReference, payload.data.paidAmount, settlement, fills);
}
} else {
// An order or payment event: payload.data.order, payload.data.payment, …
}
attempt.railis typedstring | null; handlenullwhen an attempt has no rail.versionis2today and changes only with a breaking change to the payout payload.PAYOUT_ORDER_PARTIALLY_PAIDfires once per buyer payment that pays only part of the listing. It carriespartialPayment(aPayoutPartialPayment) besidedata:expectedAmount,paidAmount(this payment included),remainingAmountand thefill.paidAmountis what 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, so a plain crypto payout keeps it at"0.00";payoutTransferreports the delivered destination token, network and recipient, withdecimals, deliveringtxHash, BaseusdcTxHashandprovider(RELAY,NEAR_INTENTS, ornullfor direct Base USDC). ZEC on Zcash usesNEAR_INTENTSand a transparentt1…ort3…recipient;txHashis the Zcash txid (64 hex, no0x). It also covers a sent remainder after a partial payment.settlement(aPayoutSettlementBreakdown) is set on everySETTLEDpayout withbuyerPaidAmount,sentAmount,sentTo,returnedAmount,dustAmountandrefundFeeAmount,"0.00"for a part that did not happen.refundFeeAmountis USDC retained on bridge refunds;sentTois the customer’s recipient (EVM lower-case, other families canonical), never a bridge deposit address. Delivery emits the settled webhook; a refund returns to READY without a merchant webhook.partialFillslists each partial payment (PayoutPartialFillView).- Deduplicate on
payload.id, which is the same for every endpoint. See payout events for the ids and every payload.