Open-Amount Orders
An open-amount order has no amount when you create it. The customer types the amount in checkout, sees live prices for each payment method, and can change the amount until a payment that could still complete exists. Use it for deposits, top-ups and donations, where the customer decides how much to pay.
Open-amount orders are enabled on the hosted API. If they are switched off, creating one returns 400 OPEN_AMOUNT_DISABLED, and open-amount orders you already created keep working. You can test the whole flow locally with the Peer Pay CLI.
Create an order
Pass openAmount instead of an amount when calling createCheckout:
import { createCheckout } from '@zkp2p/pay-sdk';
const checkout = await createCheckout(
{
openAmount: {
currency: 'USD',
minAmount: '10',
maxAmount: '2000',
presets: ['25', '50', '100', '250'],
},
destinationChainId: 8453,
destinationToken: 'USDC',
},
{
apiBaseUrl: 'https://api.pay.peer.xyz',
checkoutBaseUrl: 'https://pay.peer.xyz',
apiKey: process.env.ZKPAY_API_KEY!,
},
);
Or call the API directly:
curl -X POST https://api.pay.peer.xyz/api/v1/orders \
-H "Content-Type: application/json" \
-H "X-API-Key: your_api_key" \
-d '{
"openAmount": {
"currency": "USD",
"minAmount": "10",
"maxAmount": "2000",
"presets": ["25", "50", "100", "250"]
},
"destinationChainId": 8453,
"destinationToken": "USDC"
}'
| Field | Type | Required | Description |
|---|---|---|---|
openAmount.currency | string | Yes | ISO currency code of your limits and presets, for example USD or EUR. Must be a currency Peer Pay can price. The customer types in checkout's selected currency; see Customer experience. |
openAmount.minAmount | string | No | Smallest amount the customer can pay, in currency, with at most 2 decimals. |
openAmount.maxAmount | string | No | Largest amount the customer can pay, in currency, with at most 2 decimals. Must convert to at most 10,000 USDC at the current rate. |
openAmount.presets | string[] | No | 1 to 6 suggested amounts shown under the input, in currency, with at most 2 decimals. |
The response is a normal order with amountMode: "OPEN". requestedUsdcAmount and remainingUsdcAmount are null and amountVersion is 0. The stored openAmount is normalized: upper-case currency, 2-decimal amounts, presets sorted ascending.
Rules
openAmountis the third amount mode. It cannot be combined withrequestedUsdcAmount,requestedFiatAmountorrequestedFiatCurrency. A request with no amount mode still fails: leaving the amount out never creates an open-amount order.minAmountmust not be greater thanmaxAmount, and presets must be unique ("10"and"10.00"are the same preset).- The allowed range is the tightest of your
minAmountandmaxAmount, the Peer Pay minimum order amount, the 10,000 USDC order maximum, and your merchant order-size limits. Sandbox merchants skip the order-size limits, as for any order, but not the Peer Pay minimum or maximum. The range must not be empty, and every preset must fall inside it. - Without a
maxAmount, the customer can pay up to 10,000 USDC worth ofcurrencyat the current rate, for example 9,200.00 EUR at 0.92 EUR per USD. AmaxAmountthat converts to more than 10,000 USDC is refused at creation with400 OPEN_AMOUNT_INVALID, and so is a preset above the range. - The 10,000 USDC limit also applies to each payment, fees included. When the customer pays the fees (
feePayer: "PAYEE", or the buyer's share underSPLIT), a bank or app payment settles the amount plus fees, so it can go over the limit while the amount itself is inside the range. At 1.5% buyer-paid fees, 9,900 USD settles about 10,051 USDC. Checkout then lists bank and app methods as unavailable for that amount, and a payment started on one returns400 INTENT_ABOVE_MAXwithout saving the amount. Crypto payments still work. See order amount limits. - Open-amount orders cannot use
inPersonCheckout: trueordynamicOrdersEnabled: true. They are never resized, soPOST /orders/{orderId}/resizereturns400 RESIZE_NOT_SUPPORTED. - Idempotent retries must repeat the same
openAmount. Reusing theidempotencyKeyof an open-amount order for a fixed-amount request, or the reverse, returns409 IDEMPOTENCY_KEY_CONFLICT. - An open-amount order still takes one completed payment. Create a new order for each deposit.
Customer experience
The order summary in checkout shows an amount input with your presets under it. The customer types the amount in the checkout's selected payment currency and can switch currency at any time, except while a payment is starting. In your openAmount.currency they see your minAmount, maxAmount and presets exactly as you set them, within the USDC limits below: a tighter limit replaces your bound, and a preset outside the range is left out. In any other currency these convert at the live exchange rate and round to whole units: the minimum up, the maximum down, and each preset to the nearest unit inside the range. When the limits leave no amount in the range, for example after you lower your order-size limits, checkout shows no presets. Switching currency converts the amount already typed at the same rate, rounded to a whole unit and kept inside the new currency's range; if a rate is unavailable, the amount is cleared and the customer types it again. The 10,000 USDC limits convert too: on a USD order with no maxAmount, a customer paying in EUR at 0.92 EUR per USD can type up to 9,200 EUR.
Whatever the currency, the amount is converted to USDC and checked against the Peer Pay minimum, the 10,000 USDC order maximum, your order-size limits and your monthly volume cap. On bank and app methods, the 10,000 USDC per-payment limit applies to that USDC amount plus any buyer-paid fees.
As the customer types, the payment methods are priced for that amount; nothing is saved yet. When the customer starts a payment, Peer Pay saves the amount on the order, in the currency the customer typed it in, and sends you ORDER_AMOUNT_SET.
When the amount can change
The customer can change the amount as long as no payment on the order can still complete. A payment that is in progress, has expired, or has settled locks the amount. A cancelled Zcash payment also locks it, because the deposit can still arrive late. A failed crypto payment can also still complete if Relay delivers it later. That covers a payment that failed while the bridge was delayed and a payment Relay itself reported as failed, so both lock the amount too. When the amount is locked, checkout tells the customer why:
amountLock | Meaning |
|---|---|
PAYMENT_IN_PROGRESS | A payment is open. Cancelling it unlocks the amount. |
PAYMENT_MAY_SETTLE | An expired payment, a Zcash payment, or a failed crypto payment that Relay can still deliver (it failed while the bridge was delayed, or Relay reported it as failed) can still settle. Cancelling does not unlock the amount. |
PAID | A payment settled. |
The order read returns amountLock and the allowed range as openAmount.effectiveMin and openAmount.effectiveMax, in openAmount.currency at the current exchange rate. Both are null while the amount is locked, once the order is fulfilled or cancelled, and while the exchange rate is unavailable. The read never fails because of the exchange rate.
openAmount.savedInput is the saved amount as { amount, currency }, in the currency the customer typed it in. That can differ from openAmount.currency: a customer can pay a USD order in EUR.
Starting a new payment with the saved amount in the same currency, for example to pay the rest after a partial payment, does not change the order and sends no event.
Subscribe to ORDER_AMOUNT_SET
Webhooks receive only the events they subscribe to. Existing webhooks do not receive ORDER_AMOUNT_SET until you add it. In the Merchant Dashboard, open Settings → Developer, edit the webhook and select Order amount set, or update it through the API with the full event list:
curl -X PATCH https://api.pay.peer.xyz/api/v1/webhooks/{webhookId} \
-H "Content-Type: application/json" \
-H "X-API-Key: your_api_key" \
-d '{"events": ["ORDER_CREATED", "ORDER_AMOUNT_SET", "PAYMENT_CREATED", "ORDER_FULFILLED"]}'
ORDER_AMOUNT_SET carries the saved order and an amountChange block:
{
"type": "ORDER_AMOUNT_SET",
"data": {
"order": {
"id": "cmf5k2x9d0001abcd1234efgh",
"status": "CREATED",
"amountMode": "OPEN",
"requestedUsdcAmount": "100",
"remainingUsdcAmount": "100",
"amountVersion": 1
},
"payment": null,
"amountChange": {
"previousAmountUsdc": null,
"newAmountUsdc": "100.000000",
"fiat": { "amount": "100.00", "currency": "USD" },
"amountVersion": 1
}
}
}
amountChange.fiat is the amount the customer typed and the currency they typed it in, which can differ from openAmount.currency. The order's metadata.requestedFiatAmount and metadata.requestedFiatCurrency record the same amount. Use the USDC amounts (newAmountUsdc and data.order.requestedUsdcAmount) for accounting. amountVersion goes up by one each time the amount is saved. Deliveries can arrive out of order, so keep the highest version you have seen for the order. The complete payload is under ORDER_AMOUNT_SET.
Credit on ORDER_FULFILLED
ORDER_AMOUNT_SET is informational, for example to show a pending deposit. Credit the customer only on ORDER_FULFILLED, using the amounts in its data.order. A customer who cancels a payment can choose a different amount, so the first amount you see may not be the one they pay:
ORDER_CREATED: no amount,amountVersion0.- The customer tries $100, then $250. No webhook is sent while they type.
- They start a $250 payment:
ORDER_AMOUNT_SET(none to 250, version 1),PAYMENT_CREATED. - They cancel it:
PAYMENT_CANCELLED. - They start a $200 payment:
ORDER_AMOUNT_SET(250 to 200, version 2),PAYMENT_CREATED. - The payment settles:
PAYMENT_SETTLED, thenORDER_FULFILLEDfor 200.
Read the order in TypeScript
CheckoutOrder is a union on amountMode. Narrow it before reading the amounts:
import { OrderAmountMode, type CheckoutOrder } from '@zkp2p/pay-shared';
function describeAmount(order: CheckoutOrder): string {
if (order.amountMode === OrderAmountMode.OPEN && order.requestedUsdcAmount === null) {
return `Awaiting amount (${order.openAmount.currency})`;
}
return `${order.requestedUsdcAmount} USDC`;
}
Errors
| Status | Code | When |
|---|---|---|
400 | OPEN_AMOUNT_INVALID | Create: 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 the range, or it is combined with another amount mode, inPersonCheckout: true or dynamicOrdersEnabled: true. The message names the field. |
400 | OPEN_AMOUNT_DISABLED | Create: open-amount orders are switched off for this environment. |
400 | AMOUNT_REQUIRED | Payment start: the order has no saved amount and the request sends none. |
400 | AMOUNT_NOT_ALLOWED | Quotes or payment start: an amount was sent for a fixed-amount order. |
400 | AMOUNT_OUT_OF_RANGE | Quotes or payment start: the amount is outside the allowed range, including an amount above 10,000 USDC on an order without a maxAmount. The response carries effectiveMin, effectiveMax and currency, in the currency the amount was typed in. |
400 | INTENT_ABOVE_MAX | Payment start: on a bank or app method, the amount plus buyer-paid fees would settle more than 10,000 USDC. The amount is not saved. Quotes list such methods as unavailable instead of returning this code. |
409 | AMOUNT_LOCKED | Quotes or payment start: a payment that can still complete locks the amount. The response carries reason. |
409 | AMOUNT_CONFLICT | Payment start: the amount was saved from another tab after this checkout loaded. |
403 | MERCHANT_MONTHLY_VOLUME_LIMIT_EXCEEDED | Payment start: the new amount would take the order's creation month past your plan's volume cap. See monthly caps. |
400 | RESIZE_NOT_SUPPORTED | Resize: open-amount orders are never resized. |
502 | EXCHANGE_RATE_LOOKUP_FAILED | Create, quotes or payment start: the exchange rate for the order's currency, or for the currency the amount was typed in, is unavailable. Retry. |
429 | Quotes: more than 30 quotes with an amount for one order within a minute. |
Test locally with the CLI
The Peer Pay CLI local simulator always accepts open-amount orders:
peer-pay-cli --local orders create --open-amount --currency USD --min 10 --max 2000 --presets 25,50,100
peer-pay-cli --local simulate ORDER_ID --event ORDER_AMOUNT_SET --amount 100 --amount-currency USD --rail venmo
The simulation starts a payment with the amount in --amount-currency, exactly as checkout does with its selected currency, so your webhook endpoint receives ORDER_AMOUNT_SET and then PAYMENT_CREATED. See Orders for the full set of local rules.