Skip to main content

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:

StatusMeaning
CREATEDIn progress. The customer is inside their payment window (quoteExpiresAt) and the quote in quote is reserved for them
SETTLEDThe 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
EXPIREDThe window closed without a verified payment. The order stays open
CANCELLEDThe customer switched payment method or backed out before paying
FAILEDThe 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​

ParameterTypeDescription
statusCheckoutPaymentStatusCREATED, SETTLED, EXPIRED, CANCELLED, or FAILED
chargebackStatusPaymentChargebackStatusNONE 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:

FieldDescription
orderIdThe order this attempt belongs to
railPayment platform the customer used, for example venmo or zelle. See payment platforms
paymentAmount, currencyWhat 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
currencyPerUsdRateFiat 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
penaltiesAlways-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
netSettledUsdcAmountUSDC you received after all fees. null until SETTLED
totalUsdcFeeAmountTotal 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
referralFeesPer-recipient referral fee breakdown, when any applied. On Relay crypto payments it follows the same estimate-then-actual refinement as totalUsdcFeeAmount
railIdentifierOpaque 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
fulfillTransactionSettlement transaction hash. null until SETTLED
quoteExpiresAtWhen the customer's window closes
completedAtWhen 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​

StatusCause
404The 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.payment in 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 fulfillTransaction on 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.