Sessions
A session represents a single checkout: a fixed ZKZ amount, a dedicated receiving wallet, and a 5-minute window for the customer to pay. Your backend creates a session server-side with your site key; everything customer-facing (the checkout page, the embedded widget) is driven from the token the session response returns.
All amounts in this document are decimal strings, never numbers — see Getting started. Never use parseFloat/Number on an amount field.
Session lifecycle
A session's status is always one of exactly four values:
| Status | Meaning |
|---|---|
open |
Created, not yet paid. This includes sessions waiting in a queue (see Queue-at-cap behavior below) — queued sessions are still open, there is no separate queued status. |
paid |
Settlement confirmed. The payment object is present on the session (see Retrieve a session). |
expired |
The 5-minute window elapsed with no valid payment. |
cancelled |
Cancelled via the API before payment. |
Transitions: open → paid, open → expired, open → cancelled. There is no transition back out of paid, expired, or cancelled — once a session leaves open it is terminal. If a customer wants to retry after expiry or cancellation, the checkout page/widget handles minting a new session automatically; you don't need to call the Session API again yourself.
Expiry semantics
Every session expires 5 minutes after creation (expires_at / expires_unix in the response). This is a hard deadline enforced by the underlying SplitChain gateway — an expired transfer code is rejected outright, with no grace period. Design your order flow so that "payment expired" is a normal, expected outcome your UI handles gracefully, not an error state.
Create a session
POST /v1/sessions
Auth: site key. Requires an Idempotency-Key header — a repeat request with the same key and the same body returns the original response; the same key with a different body returns 409 idempotency_conflict. This makes it safe to retry a create call after a network timeout without risking a duplicate session.
curl
curl -X POST https://<your-node-host>/v1/sessions \
-H "Authorization: Bearer sk_9Qh2mVaZ1bxLc4qWyoFhslmyxSuHc" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_8842-create-1" \
-d '{
"amount": "125",
"order_id": "order_8842",
"mode": "redirect",
"success_url": "https://shop.acme.example/thanks?order=8842",
"cancel_url": "https://shop.acme.example/cart",
"metadata": { "cart_id": "c_991" },
"currency_display": "AUD"
}'
JS (fetch)
const response = await fetch("https://<your-node-host>/v1/sessions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.ZUPAYMENTS_SITE_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `order_${orderId}-create-1`,
},
body: JSON.stringify({
amount: "125", // decimal STRING — never a number
order_id: "order_8842",
mode: "redirect", // "redirect" | "embed"
success_url: "https://shop.acme.example/thanks?order=8842",
cancel_url: "https://shop.acme.example/cart",
metadata: { cart_id: "c_991" },
currency_display: "AUD",
}),
});
const session = await response.json();
Request fields
| Field | Required | Notes |
|---|---|---|
amount |
yes | Decimal string, 0 < amount <= 100000000, up to 32 decimal places. |
order_id |
no | ≤128 chars. Not required to be unique, but echoed back on the session and in webhooks. |
mode |
no | "redirect" or "embed". Defaults to your node's configured default. |
success_url / cancel_url |
required when mode: "redirect" |
Must be HTTPS in production. |
metadata |
no | Object, ≤20 keys, string values only, ≤2 KB serialized. |
currency_display |
no | ISO-4217 code (e.g. "AUD") for an optional fiat estimate on the checkout page. Defaults to your node's configured currency. |
Response — 201
{
"session_id": "sess_01JZC8XR4M2QF7N3K5B6D8A0PQ",
"checkout_url": "https://pay.acme-merchant.example/checkout/cs_9Qh2mV...",
"token": "cs_9Qh2mV...",
"status": "open",
"amount": "125",
"receiver_pubkey": "n3Kx...Ug=",
"mode": "redirect",
"expires_at": "2026-07-14T04:35:00Z",
"expires_unix": 1768365300,
"created_at": "2026-07-14T04:30:00Z"
}
session_idis asess_-prefixed ULID. It's your durable reference for this order — the same string is echoed in webhooks and is what you'll pass toGET/cancel.tokenis the checkout-scoped credential (cs_...) — it's also embedded incheckout_url. Formode: "redirect", send the customer tocheckout_url. Formode: "embed", passtokento the widget (see Widget embed).statusis always"open"on creation.
Queue-at-cap behavior
If your node's wallet pool is at capacity and queueing is enabled (the default), the session is still created with status: "open" — it does not error — but the response carries a queue object instead of a receiver_pubkey:
{
"session_id": "sess_01JZC8XR4M2QF7N3K5B6D8A0PQ",
"status": "open",
"queue": { "position": 3, "eta_seconds": 180 },
"amount": "125",
"mode": "redirect",
"created_at": "2026-07-14T04:30:00Z"
}
position— the customer's place in line.eta_seconds— a live estimate, recalculated from the average of the last 20 completed sessions on your node.
The receiving wallet and transfer code are withheld until a wallet frees up; the checkout page/widget shows a queue screen and transitions automatically once a wallet is allocated. You don't need to poll or handle this specially from your backend — it's a UI concern the hosted checkout page and widget already handle.
If the pool is at capacity and queueing is disabled, session creation fails instead with 402 pool_exhausted_hard.
Retrieve a session
GET /v1/sessions/:session_id
curl
curl https://<your-node-host>/v1/sessions/sess_01JZC8XR4M2QF7N3K5B6D8A0PQ \
-H "Authorization: Bearer sk_9Qh2mVaZ1bxLc4qWyoFhslmyxSuHc"
JS (fetch)
const res = await fetch(
`https://<your-node-host>/v1/sessions/${sessionId}`,
{ headers: { "Authorization": `Bearer ${process.env.ZUPAYMENTS_SITE_KEY}` } },
);
const session = await res.json();
Response — 200
{
"session_id": "sess_01JZC8XR4M2QF7N3K5B6D8A0PQ",
"status": "paid",
"amount": "125",
"amount_received": "125",
"order_id": "order_8842",
"mode": "redirect",
"receiver_pubkey": "n3Kx...Ug=",
"payment": {
"step2_signature": "b3J...",
"payer_pubkey": "aa91...Q=",
"settled_at": "2026-07-14T04:31:12Z",
"tx_ref": "txn_5c1d..."
},
"metadata": { "cart_id": "c_991" },
"created_at": "2026-07-14T04:30:00Z",
"expires_at": "2026-07-14T04:35:00Z",
"paid_at": "2026-07-14T04:31:12Z"
}
payment is present only when status is "paid". If you're building order fulfillment logic, prefer the payment.succeeded webhook over polling this endpoint — webhooks are the reliable trigger; this endpoint is best used for on-demand status checks (e.g. a customer support tool).
List sessions
GET /v1/sessions
Cursor-paginated, newest first.
| Query param | Notes |
|---|---|
status |
filter by open / paid / expired / cancelled |
order_id |
filter by your order id |
created_after / created_before |
RFC3339 or Unix seconds |
limit |
1–100, default 20 |
starting_after |
cursor — pass the previous page's next_cursor |
curl
curl "https://<your-node-host>/v1/sessions?status=paid&limit=20" \
-H "Authorization: Bearer sk_9Qh2mVaZ1bxLc4qWyoFhslmyxSuHc"
Response — 200
{
"object": "list",
"data": [ /* session objects, same shape as GET /v1/sessions/:id */ ],
"has_more": true,
"next_cursor": "sess_01JZC8W..."
}
Pass next_cursor back in as starting_after to fetch the next page.
Cancel a session
POST /v1/sessions/:session_id/cancel
Cancels an open session and releases its wallet. Safe to call more than once — cancelling an already-cancelled session is a no-op. Fires the session.cancelled webhook.
curl
curl -X POST https://<your-node-host>/v1/sessions/sess_01JZC8XR4M2QF7N3K5B6D8A0PQ/cancel \
-H "Authorization: Bearer sk_9Qh2mVaZ1bxLc4qWyoFhslmyxSuHc"
Response — 200
The session object with status: "cancelled". If the session has already settled (paid) or already expired, this returns 409 session_not_cancellable instead.
Errors
All errors share one envelope:
{
"error": {
"code": "amount_out_of_bounds",
"message": "amount exceeds max 100000000",
"param": "amount",
"request_id": "req_9f2c...",
"doc_url": "https://docs.zupayments.com/errors#amount_out_of_bounds"
}
}
code is a stable machine-readable string — build your error handling against it, not message (which may change). The most relevant codes for session creation:
| HTTP | code | Meaning |
|---|---|---|
| 400 | validation_error |
Request failed schema validation; see param. |
| 401 | invalid_api_key |
Missing or unknown site key. |
| 402 | pool_exhausted_hard |
At capacity, queueing disabled. |
| 409 | idempotency_conflict |
Same Idempotency-Key, different request body. |
| 409 | session_not_cancellable |
Cancel attempted on a paid or expired session. |
| 422 | amount_out_of_bounds |
Amount is <= 0, > 100000000, or has more than 32 decimal places. |
| 429 | rate_limited |
See Retry-After. |
5xx responses are intentionally opaque (internal_error) — no internal detail is leaked in the body.