createCheckout
Creates an order and returns an authenticated checkout URL.
Signature
function createCheckout(
params: CreateOrderRequest,
opts: CheckoutClientOptions
): Promise<CreateCheckoutResult>
Parameters
params: CreateOrderRequest
| Property | Type | Required | Description |
|---|---|---|---|
requestedUsdcAmount | string | Conditional | Use this for direct USDC mode. Mutually exclusive with fiat-mode fields. |
requestedFiatAmount | string | Conditional | Fiat-mode input amount. Requires requestedFiatCurrency. |
requestedFiatCurrency | string | Conditional | Fiat-mode ISO currency code (for example EUR). Requires requestedFiatAmount. |
openAmount | OpenAmountInput | Conditional | Open-amount mode: the customer types the amount in checkout. { currency, minAmount?, maxAmount?, presets? }. See Open-Amount Orders. |
idempotencyKey | string | No | Stable purchase key for retry deduplication, 8–128 letters, digits, underscores or hyphens. See below. |
destinationAddress | string | No | Override destination wallet (falls back to your merchant payout wallet for the destination chain when omitted) |
destinationToken | string | No | Override destination token alias/address |
destinationChainId | number | No | Override destination chain |
feePayer | FeePayerType | No | MERCHANT, PAYEE, or SPLIT fee mode override |
buyerFeeShareBps | number | No | Required with explicit SPLIT: buyer share of total fees in basis points (5000 = 50%, 0–10000 in steps of 1000) |
dynamicOrdersEnabled | boolean | No | Per-order Dynamic Orders opt-out. false disables the feature for this order; omitted inherits the merchant default. Passing true when the merchant setting is disabled rejects order creation with 400 DYNAMIC_ORDERS_DISABLED. The value is snapshotted at order creation. Open-amount orders cannot pass true. |
enabledRails | string[] | No | Restrict available rails. Non-empty; each value must be a supported rail identifier |
successUrl | string | null | No | Absolute URL for the Return to merchant link shown after a successful payment (defaults to null). See what the customer actually sees |
cancelUrl | string | null | No | Absolute URL stored on the order and echoed back to you (defaults to null). Checkout never navigates to it. See what the customer actually sees |
notes | Record<string, unknown> | null | No | Merchant notes payload (defaults to null) |
Provide exactly one amount mode:
- USDC mode:
requestedUsdcAmount - Fiat mode:
requestedFiatAmount+requestedFiatCurrency - Open-amount mode:
openAmount
A request with none of them fails with 400. Leaving the amount out never creates an open-amount order.
In fiat mode the amount is converted to USDC at the current rate and rounded to 2 decimals. The order's USDC amount fields carry the result. In open-amount mode the order has no amount until the customer starts a payment: requestedUsdcAmount and remainingUsdcAmount are null and amountMode is "OPEN". See Open-Amount Orders.
Order amount limits
Orders are limited to 10,000 USDC, the most the protocol can settle in a single request. Every order must also be at least the platform minimum (10 USDC by default). Both limits apply to sandbox merchants too.
- A fixed order above 10,000 USDC fails with
400 AMOUNT_ABOVE_MAX. Exactly 10,000 USDC is allowed. In fiat mode the converted USDC amount is checked, so 9,500 EUR at 0.92 EUR per USD (10,326.09 USDC) is refused. - Dynamic Orders never suggest a nearby amount above 10,000 USDC, or one whose payment would settle above the per-payment limit below. A resize above 10,000 USDC fails the same way.
- An open-amount order's allowed range never goes above 10,000 USDC. See Open-Amount Orders.
The 10,000 USDC limit applies to each payment too, and it includes fees. When the customer pays by bank or app (Venmo, Cash App, Zelle and the other fiat methods), the amount the payment settles on chain must be at most 10,000 USDC.
- With
feePayer: "MERCHANT"and a Base USDC payout, that settled amount is normally the order amount or a little less, so a 10,000 USDC order fits. If the best quote would still settle above the limit, the payment start fails and is not retried with the next seller's quote. - When the customer pays the fees (
PAYEE, or the buyer's share underSPLIT), the fees are added first. A 10,000 USDCPAYEEorder with 1.5% fees settles about 10,152 USDC, so bank and app methods cannot take it. At 1.5% the largestPAYEEorder they can take is about 9,850 USDC. - A payout outside Base USDC adds the bridge cost the same way, whoever pays the fees.
Over the limit, checkout lists the affected bank and app methods as unavailable for that amount. A payment started on one anyway fails with 400 INTENT_ABOVE_MAX. Crypto payments are not affected, because they do not settle through the per-payment escrow. Apple Pay has its own lower limit.
For a larger purchase, create several orders.
Idempotent order creation
The request body of POST /api/v1/orders has the same shape. Pass a stable
idempotencyKey per purchase and reuse it on retries, including timeouts. A replay
returns the original order; a different amount or mode returns
409 IDEMPOTENCY_KEY_CONFLICT. Other fields do not update the original order.
A replay returns idempotentReplay: true, orderToken: null and
checkoutUrl: null. Save the first checkout URL on your backend and check for a
null URL before redirecting. The API does not recover a lost token on replay.
createCheckoutAndRedirect returns the replay without navigating.
See integration facts at a glance.
opts: CheckoutClientOptions
apiBaseUrl and apiKey are required, and you should always set checkoutBaseUrl so the
returned checkoutUrl points at the checkout host rather than the API host. Every field is
documented in Configuration.
Returns
type CreateCheckoutResult = {
order: CheckoutOrder;
orderToken: string | null;
checkoutUrl: string | null;
idempotentReplay?: true;
};
For a newly created order, checkoutUrl includes the order token. A replay has no checkout URL.
Example
import { createCheckout } from '@zkp2p/pay-sdk';
const checkout = await createCheckout(
{
requestedFiatAmount: '75.00',
requestedFiatCurrency: 'EUR',
destinationChainId: 8453,
destinationToken: 'USDC',
destinationAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE71',
successUrl: 'https://example.com/success',
cancelUrl: 'https://example.com/cancel',
notes: {
orderId: 'order_12345',
customerId: 'cust_67890',
},
},
{
apiBaseUrl: 'https://api.pay.peer.xyz',
checkoutBaseUrl: 'https://pay.peer.xyz',
apiKey: process.env.ZKPAY_API_KEY!,
}
);
console.log(checkout.order.id); // cmf5k2x9d0001abcd1234efgh
console.log(checkout.checkoutUrl);
What successUrl and cancelUrl do
Neither URL moves the customer on its own.
successUrl: after a successful payment, checkout shows a Return to merchant link the customer clicks. It is not an automatic redirect, and it is not shown in embedded mode. The link carriesorder_id,payment_idorrequest_id,intent_hash,tx_hash, andstatus=successas query parameters.cancelUrl: stored on the order and echoed back to you. Checkout never navigates to it.
Treat both as convenience navigation only. Never fulfill an order on a redirect or on a
query parameter. Fulfill on the ORDER_FULFILLED webhook.
Errors
A rejected request throws PayApiError. Branch on errorCode; the message is written for a log line, not for matching.
| Status | errorCode | Cause |
|---|---|---|
400 | (none) | A field failed validation. Detail on fieldErrors and formErrors |
400 | AMOUNT_BELOW_MIN | The amount is below the platform minimum (10 USDC by default). Applies to sandbox too |
400 | AMOUNT_ABOVE_MAX | The amount is above 10,000 USDC, the protocol's per-request limit. Fiat mode checks the converted USDC amount. Applies to sandbox too. See order amount limits |
400 | AMOUNT_NON_POSITIVE | Fiat mode converted to zero or less |
400 | AMOUNT_BELOW_MERCHANT_MIN, AMOUNT_ABOVE_MERCHANT_MAX | The amount is outside the order-size range configured for your account. Contact Peer to change it. Not enforced for sandbox merchants |
400 | DESTINATION_INVALID | No payout address is configured for the destination chain, or the supplied destinationAddress does not match that chain's address format |
400 | PAYOUT_CONFIG_INVALID | Unsupported destination chain or token, or a non-Base payout on an account that has not enabled receiving outside Base USDC |
400 | NO_ELIGIBLE_PAYMENT_RAILS | None of the rails the order allows can be offered. Each is disabled, or excluded by your account's verification settings |
400 | MERCHANT_CONFIG_MISSING | The account has no checkout configuration yet. Finish onboarding in the dashboard |
400 | DYNAMIC_ORDERS_DISABLED | dynamicOrdersEnabled: true on a merchant with the setting off |
400 | OPEN_AMOUNT_INVALID | openAmount is invalid, its currency has no exchange rate, its maxAmount converts to more than 10,000 USDC, its allowed range is empty, a preset is outside it, or it is combined with another amount mode, dynamicOrdersEnabled: true or inPersonCheckout: true. The message names the field |
400 | OPEN_AMOUNT_DISABLED | Open-amount orders are switched off for this environment |
401 | (none) | Missing or invalid API key |
403 | MERCHANT_TIER_FORBIDDEN | A live key with no plan selected. Sandbox keys never hit this. Select Base or Pro under Settings, Billing |
403 | MERCHANT_MONTHLY_ORDER_LIMIT_EXCEEDED | The plan's monthly fiat order count is reached (Base 50, Pro 100, per UTC month) |
403 | MERCHANT_MONTHLY_VOLUME_LIMIT_EXCEEDED | The order would take fiat-paid volume past the plan's monthly cap (Base $10K, Pro $30K). An open-amount order has no amount yet, so its volume is checked when the customer starts a payment, not at creation |
409 | IDEMPOTENCY_KEY_CONFLICT | The idempotencyKey was already used with a different amount or mode, including a fixed request against an open-amount order and the reverse |
409 | MERCHANT_OWNERSHIP_CHANGED | The merchant account changed owners while the order was being created. Nothing was saved; retry with the same idempotencyKey to create it for the new owner |
502 | EXCHANGE_RATE_LOOKUP_FAILED | The rate lookup failed for fiat mode with a non-USD currency, or for an open-amount order whose openAmount.currency is not USD. Retry |
Starting a payment can fail with 400 INTENT_ABOVE_MAX, which createCheckout never returns. It means a bank or app payment on the order would settle more than 10,000 USDC once fees are added, for example This payment would be 10152.29 USDC with fees, above the 10000.00 USDC limit per payment. It applies to sandbox too. See order amount limits.
Monthly caps count fiat-paid orders created in the current UTC month, at their full requested amount: an order counts once any fiat payment on it settles, so an abandoned checkout does not consume them and orders paid only with crypto never do. The checks run at creation, before the customer picks how to pay. For an open-amount order, the volume check runs when the customer starts a payment with a new amount instead. Concierge and sandbox merchants are never capped. See plans and support.