Payments
A payment is one customer attempt to pay an order: they picked a rail, got a quote, and had a window to send the money (one hour on fiat rails, about six hours on crypto rails). An order can have several, because an attempt can expire or be cancelled and the customer can start again. This endpoint lists them across all your orders, newest first.
Payment status
What a row on this endpoint is telling you:
| Status | Meaning |
|---|---|
CREATED | In progress. The customer is inside their payment window (quoteExpiresAt) and the quote in quote is reserved for them |
SETTLED | The payment was verified (fiat) or the transfer filled (crypto) and USDC was delivered onchain. fulfillTransaction holds the transaction hash and netSettledUsdcAmount what you received after fees |
EXPIRED | The window closed without a verified payment. The order stays open |
CANCELLED | The customer switched payment method or backed out before paying |
FAILED | The attempt could not be started onchain, or a live crypto transfer failed or never arrived. A rejected verification leaves the payment CREATED so the customer can retry. errorMessage says why when known. errorCode is null on list rows; only the hosted order read sets it |
SETTLED is the only one that is always terminal: a payment can move out of the other four,
and a late settlement can still land on an attempt that expired. On Relay crypto payments the
fee and deposit fields of a SETTLED payment can still be refined for a few minutes after
settlement. The transitions and the webhook that fires on each are in
payment status.
SETTLED is the state your accounting cares about. A chargeback on a settled payment is
additive: it lands in chargeback and chargebackStatus and never changes status. See
chargeback status.
List payments
GET /api/v1/merchants/me/payments
X-API-Key: <your key>
Query parameters
| Parameter | Type | Description |
|---|---|---|
status | CheckoutPaymentStatus | CREATED, SETTLED, EXPIRED, CANCELLED, or FAILED |
chargebackStatus | PaymentChargebackStatus | NONE for payments with no chargeback, CHARGEBACKED for payments with one |
page and limit follow the shared pagination rules.
There is no order filter on this endpoint. For every attempt on one order, use
list payments for an order. For the attempt a customer is on right
now, currentPayment on the hosted read is enough. A
filtered pull by status=SETTLED is small enough to walk for reconciliation.
Response
responseObject is a page of CheckoutPayment rows,
with a nullable quote in this list only:
For an older settled payment whose stored quote is empty, the list returns quote: null
so the payment remains in merchant history. Other payment reads still require a valid quote.
type MerchantPaymentList = {
payments: Array<Omit<CheckoutPayment, 'quote'> & { quote: CheckoutPayment['quote'] | null }>;
total: number;
page: number;
limit: number;
};
The fields that matter for reconciliation:
| Field | Description |
|---|---|
orderId | The order this attempt belongs to |
rail | Payment platform the customer used, for example venmo or zelle. See payment platforms |
paymentAmount, currency | What the customer actually paid. paymentAmount is "0" until the attempt settles, then it is the amount that arrived, denominated in currency. currency is a fiat code on fiat rails and a token symbol (USDC, ETH, ZEC) on crypto rails, where settlement can rewrite it to the token that actually arrived. What the customer is asked to send lives in quote instead: signalIntent.amount at conversionRate. On Relay crypto payments both can be refined for a few minutes after settlement |
currencyPerUsdRate | Fiat rails snapshot the FX rate for currency at quote time. Crypto rails carry "1" until settlement writes the effective token per USD rate. It can be refined for a few minutes after settlement |
penalties | Always-present PaymentPenalty[] of attestation-applied reductions (Venmo/PayPal purchase protection, cross-currency), in application order. Empty when none apply; a non-empty array explains a netSettledUsdcAmount lower than expected |
netSettledUsdcAmount | USDC you received after all fees. null until SETTLED |
totalUsdcFeeAmount | Total fees taken from the settlement in USDC. On Relay crypto payments it starts as an estimate from the configured fee rate and is replaced with Relay's actual fee within minutes |
referralFees | Per-recipient referral fee breakdown, when any applied. On Relay crypto payments it follows the same estimate-then-actual refinement as totalUsdcFeeAmount |
railIdentifier | Opaque reference for the attempt: the onchain identifier on fiat rails, the transfer request id on crypto rails. It is assigned when the attempt is registered, before any funds move, and is null until then. On fiat rails it changes only when a recreate reopens the attempt. On crypto rails settlement rewrites it to the latest successful Relay request id, which is not always the id the quote was issued under. id never changes, so key external references on id |
fulfillTransaction | Settlement transaction hash. null until SETTLED |
quoteExpiresAt | When the customer's window closes |
completedAt | When the payment settled or the customer cancelled it. It is not a general terminal-state timestamp: it stays null on EXPIRED and on FAILED |
Example
Every settled payment, most recent first:
curl "https://api.pay.peer.xyz/api/v1/merchants/me/payments?status=SETTLED&limit=100" \
-H "X-API-Key: $ZKPAY_API_KEY"
Walking all pages to total what settled:
Envelope<T> is the shared response envelope.
type PaymentPage = MerchantPaymentList;
// "12.5" -> 12500000n. Scale to integers instead of parseFloat.
const toMicroUsdc = (amount: string): bigint => {
const [whole, fraction = ''] = amount.split('.');
return BigInt(whole) * 1_000_000n + BigInt(fraction.padEnd(6, '0').slice(0, 6));
};
async function sumSettledMicroUsdc(): Promise<bigint> {
let page = 1;
let settled = 0n;
for (;;) {
const url = new URL('https://api.pay.peer.xyz/api/v1/merchants/me/payments');
url.searchParams.set('status', 'SETTLED');
url.searchParams.set('page', String(page));
url.searchParams.set('limit', '100');
const response = await fetch(url, {
headers: { 'X-API-Key': process.env.ZKPAY_API_KEY! },
signal: AbortSignal.timeout(8_000),
});
const body = (await response.json()) as Envelope<PaymentPage>;
if (!body.success || body.responseObject === null) {
throw new Error(`${body.statusCode}: ${body.message}`);
}
for (const payment of body.responseObject.payments) {
settled += toMicroUsdc(payment.netSettledUsdcAmount ?? '0');
}
if (page * body.responseObject.limit >= body.responseObject.total) break;
page += 1;
}
return settled;
}
A chargeback pull for the finance team:
curl "https://api.pay.peer.xyz/api/v1/merchants/me/payments?chargebackStatus=CHARGEBACKED" \
-H "X-API-Key: $ZKPAY_API_KEY"
Errors
The common errors are the only ones this endpoint returns.
List payments for an order
GET /api/v1/merchants/me/orders/{orderId}/payments
X-API-Key: <your key>
Every attempt on one of your orders, newest first, as full
CheckoutPayment rows. There is no pagination; an order rarely
has more than a handful of attempts. When a customer says they paid, railIdentifier
identifies the attempt and quoteExpiresAt is its window. payTo is an opaque recipient
identifier, not a readable handle. Remediation acts on these rows.
Response
type OrderPayments = { payments: CheckoutPayment[] };
Example
curl "https://api.pay.peer.xyz/api/v1/merchants/me/orders/cmf5k2x9d0001abcd1234efgh/payments" \
-H "X-API-Key: $ZKPAY_API_KEY" \
| jq '.responseObject.payments[] | {id, status, rail, payTo, railIdentifier, quoteExpiresAt}'
Errors
| Status | Cause |
|---|---|
404 | The order does not exist, belongs to another merchant, or lives in the other environment (sandbox vs live) |
The common errors also apply. This read counts toward the per-merchant
remediation read limit as well as the global one, so its
429 can arrive earlier than on the other endpoints.
Notes
- Payment rows are the same objects that arrive as
data.paymentin webhook events. Reconcile with the same code. - A sandbox key lists sandbox payments only. Sandbox settlements are simulated: no money
moves and nothing settles onchain, so do not look up a sandbox
fulfillTransactionon a block explorer. - Amounts are decimal strings. Parse them with a decimal library or scale to integers, as
the example does, rather than with
parseFloat.
Platform-wide payment creation pause
New customer payment creation can temporarily return HTTP 503:
{
"success": false,
"message": "Payments are temporarily paused. Please try again shortly.",
"responseObject": null,
"statusCode": 503,
"errorCode": "PAYMENTS_PAUSED"
}
This applies to POST /api/v1/payments,
POST /api/v1/orders/:orderId/create-payment (including replacements and
crypto/Relay/Apple Pay), and POST /api/v1/sandbox/test-order.
Read responseObject.paymentCreationPaused from the existing
GET /api/v1/orders/:orderId response to show the state before payment creation.
The server remains authoritative; status can change after the read. The shared
constant is OrderErrorCode.PAYMENTS_PAUSED from @zkp2p/pay-shared.
Merchant order creation and quotes remain available. Continue processing existing payments, proofs, webhooks, settlement, support recovery, bridges and refunds. Pause-state reads may lag by up to five seconds and fail open if Redis is unavailable.