Stable
Sourced from: docs/07-API-REFERENCE.md §0, §1, §8.1-8.4; docs/DECISIONS.md D2.1, D2.2, D2.6, D8.1, D8.5, D8.8, D8.9; packages/shared/src/contracts/session.ts

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_id is a sess_-prefixed ULID. It's your durable reference for this order — the same string is echoed in webhooks and is what you'll pass to GET/cancel.
  • token is the checkout-scoped credential (cs_...) — it's also embedded in checkout_url. For mode: "redirect", send the customer to checkout_url. For mode: "embed", pass token to the widget (see Widget embed).
  • status is 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.