Payment intents
A payment intent asks one terminal to take one payment. How to create, follow and cancel it.
A payment intent (pi_…) is one attempt to take one amount on one terminal. Use it for server-driven checkout: your ordering or POS system decides the amount, and the customer taps at the counter.
Create
const intent = await zp.paymentIntents.create(
{
amount: 2500000, // 2.50 USDC, in atoms
terminal: 'tml_ZP-4821-K',
order_id: 'A7K2Q9XZ', // optional, 1-8 characters, written on-chain
description: 'Table 12', // shown on the terminal
metadata: { pos_order: '48213' },
expires_in: 120, // seconds, 30-600
},
{ idempotencyKey: `intent-${posOrderId}` },
);| Field | Rules |
|---|---|
amount | Integer USDC atoms, at least 1. The customer pays the network fees on top. |
terminal | An active terminal of the same mode (tml_… from GET /v1/terminals). A deactivated terminal answers 409 terminal_inactive. |
order_id | Optional. 1-8 characters of A-Z a-z 0-9 - _. It is written on-chain with the settlement, so it must be unique per account and mode and must not have been paid before. Generated when omitted. |
description | Optional, up to 500 characters. Shown on the terminal while it waits. |
metadata | Optional. Up to 20 string pairs, returned unchanged. Never put secrets or personal data here. |
expires_in | Optional, 30-600 seconds, default 120. |
Idempotency-Key is required. Derive it from your own order so a crash-and-retry of your process finds the same intent instead of creating a second one. See Idempotency.
Live mode also checks that your business verification is approved (403 kyb_required) and that your account can take payments (403 account_restricted or account_disabled).
How the terminal takes it
A terminal collects its API-created intents by polling ZyloPay with its own device key, oldest first. It shows the amount and description, reads the customer's pass over NFC, and settles through the same path as a payment the cashier starts: the same checks, limits, confirmation rules and receipts. The pass credential never reaches your servers.
ZyloPay POS app support
The terminal protocol is live. The ZyloPay POS app picks up intents from the release that adds intent pickup, which is rolling out now. Before you rely on server-created intents, confirm with support that your terminals run it; until then, use terminal-started payments. See Terminals.
Follow
Listen to the payment_intent.* events, or retrieve the intent. Reads never change its state.
const current = await zp.paymentIntents.retrieve(intent.id);
if (current.status === 'succeeded') await fulfil(current.order_id, current.payment);payment is filled once the settlement is indexed, usually together with succeeded. If you need the payment object (fees, payer, block), use the payment.succeeded event or GET /v1/payments/{id}.
Cancel
await zp.paymentIntents.cancel(intent.id, { idempotencyKey: `cancel-${intent.id}` });- You can cancel while the intent is
requires_payment_methodorrequires_action. A pending payer confirmation is declined first. - If the settlement was already sent, or the customer already confirmed, the cancel answers
409 payment_intent_unexpected_state. Refund the payment instead. - Canceling a canceled intent returns it unchanged.
Expiry
An intent that nobody pays is canceled at expires_at with cancellation_reason: "expired", and payment_intent.canceled is sent. Reading an expired intent already shows canceled.
Intents the terminal starts
When a cashier enters an amount on the terminal, ZyloPay creates an intent too, with source: "terminal". It follows the same statuses and sends the same events, except payment_intent.created, which is sent only for intents your server creates. Filter with GET /v1/payment_intents?source=api or ?source=terminal.