# payzum — full integration reference for AI agents payzum is a multi-chain crypto payment processor for merchants. This file is the complete reference: an agent can integrate payzum end to end from this document without fetching anything else. **If an official SDK exists for your language, use it (§0).** The raw REST and webhook sections that follow are the fallback for every other language, and the source of truth for what the SDKs do. --- ## §0 Official SDKs — the preferred integration path | Language | Install | Repository | |----------|---------|------------| | PHP ≥ 8.1 | `composer require payzum/payzum-php` | https://github.com/payzum-dev/payzum-php | | Node.js ≥ 18.17 (TypeScript-first) | `npm install payzum` | https://github.com/payzum-dev/payzum-node | | Python | *(name reserved on PyPI; SDK not released yet — use the REST API)* | https://github.com/payzum-dev/payzum-python | Why prefer the SDK over the raw API you will read below: - **The webhook signature header names are fixed inside the SDK** and are not configurable. Choosing the wrong header (§4's warning) is the single most common integration bug with this API; the SDK makes it unrepresentable. - **Amounts never touch a float.** The merchant surface emits JSON numbers, and every standard JSON parser silently rounds 18-decimal token amounts. The SDKs parse losslessly and hand you exact decimal strings. - **Retries are safe by construction.** Only the three retryable error codes are retried, `Retry-After` is honoured, and `POST /v1/payment` is never auto-retried without an `Idempotency-Key` (and with one, only after the ~60s idempotency consistency window). - Typed errors for all 16 API codes, canonical payment-status mapping across the merchant and buyer vocabularies, and constant-time signature checks. ### §0.1 PHP quickstart ```php // composer require payzum/payzum-php use Payzum\Payzum; $payzum = new Payzum(getenv('PAYZUM_API_KEY')); // Sandbox: Payzum::sandbox(getenv('PAYZUM_API_KEY')) $invoice = $payzum->payments->create( priceAmount: '49.99', // a string — nothing gets rounded priceCurrency: 'usd', payCurrency: 'all', // let the buyer choose the asset orderId: 'ORDER-12345', ipnCallbackUrl: 'https://merchant.example.com/payzum/ipn', ); header('Location: ' . $invoice['invoice_url']); ``` Webhook handler — the SDK reads the correct header itself, verifies against the raw bytes in constant time, enforces the 10-minute replay window, and throws `SignatureException` on anything wrong: ```php $payload = $payzum->webhooks(getenv('PAYZUM_WEBHOOK_SECRET'))->verifyPaymentIpn( file_get_contents('php://input'), getallheaders(), ); if ($payload['payment_status'] === 'finished') { // fulfil the order identified by $payload['order_id'] } ``` ### §0.2 Node.js / TypeScript quickstart ```ts // npm install payzum import { Payzum } from 'payzum' const payzum = new Payzum(process.env.PAYZUM_API_KEY!) // Sandbox: Payzum.sandbox(apiKey) const invoice = await payzum.payments.create({ priceAmount: '49.99', // a string — nothing gets rounded priceCurrency: 'usd', payCurrency: 'all', orderId: 'ORDER-12345', ipnCallbackUrl: 'https://merchant.example.com/payzum/ipn', }) redirect(String(invoice.invoice_url)) ``` Webhook handler (Express — note `express.raw()`, never `express.json()`, because verification must see the raw bytes): ```ts import { SignatureError } from 'payzum' app.post('/payzum/ipn', express.raw({ type: '*/*' }), (req, res) => { try { const payload = payzum .webhooks(process.env.PAYZUM_WEBHOOK_SECRET!) .verifyPaymentIpn(req.body, req.headers) if (payload.payment_status === 'finished') { // fulfil the order identified by payload.order_id } res.status(200).end() } catch (e) { if (e instanceof SignatureError) return res.status(401).end() throw e } }) ``` Both SDKs also expose `payments.get` / `payments.list`, the buyer status endpoint (`invoices.status` — exact decimal amounts), `currencies` (cached detailed catalogue), `rates.estimate` / `rates.minAmount`, `health`, and the mass-payout webhook verifier. Their signature implementations are validated against a shared, versioned vector corpus, so all SDKs accept and reject exactly the same deliveries. --- ## §1 Base URLs & environments | Environment | Host | |-------------|------| | Production | `https://merchant.payzum.com` | | Staging / sandbox | `https://staging.payzum.com` | **Host note:** the API, hosted checkout, widget, and docs all live under `merchant.payzum.com` (production) or `staging.payzum.com` (sandbox/testing). Staging route patterns are verified. Merchant-facing API paths are on the same host under `/v1/*`, `/legacy/*`, and `/health`. (`/webhooks/*` exists on the same host but is reserved for internal gateway callbacks — you do not call it; payzum delivers webhooks TO your own `ipn_callback_url`/webhook URL.) --- ## §2 Authentication All merchant endpoints require an API key in the `x-api-key` request header (case-insensitive). The key must be at least 32 characters. ``` Header: x-api-key: ``` Keys are SHA-256-hashed server-side before storage; the plaintext is shown once at creation and is not retrievable afterwards. The last 4 characters are retained for identification. ### Authentication errors | HTTP | Error kind | Cause | |------|------------|-------| | 401 | `API_KEY_MISSING` | Header absent | | 401 | `API_KEY_MALFORMED` | Key shorter than 32 chars | | 401 | `API_KEY_NOT_FOUND` | Key hash not in DB | | 403 | `MERCHANT_SUSPENDED` | Merchant account suspended | ### Rate limiting A per-merchant rate limit is enforced after auth. When exceeded the server returns `429` with header `Retry-After: `. --- ## §3 Accept payments ### §3.1 Create an invoice — `POST /v1/payment` ```bash curl -X POST https://merchant.payzum.com/v1/payment \ -H "x-api-key: $PAYZUM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "price_amount": 49.99, "price_currency": "usd", "pay_currency": "usdttrc20", "order_id": "ORDER-12345", "ipn_callback_url": "https://merchant.example.com/payzum/ipn", "order_description": "Widget purchase", "success_url": "https://merchant.example.com/checkout/success", "cancel_url": "https://merchant.example.com/checkout/cancel" }' ``` #### Request fields | Field | Type | Required | Notes | |-------|------|----------|-------| | `price_amount` | number | yes | Must be > 0 | | `price_currency` | string | yes | 2–8 chars (e.g. `usd`) | | `pay_currency` | string | yes | 2–32 chars; suffix style (`usdttrc20`) OR symbol + `network` | | `network` | string | no | Use with symbol-style `pay_currency` (e.g. `pay_currency=usdt` + `network=tron`) | | `order_id` | string | no | ≤255 chars | | `order_description` | string | no | ≤2000 chars | | `ipn_callback_url` | string (url) | no | URL for signed IPN delivery | | `purchase_id` | string | no | External purchase reference | | `success_url` | string | no | http(s), ≤2048 chars; buyer redirect on payment | | `cancel_url` | string | no | http(s), ≤2048 chars; buyer redirect on cancellation | | `pricing_mode` | string | no | `fiat` (default) — convert via rate provider; `direct` — price already in `pay_currency` (then `price_currency` must equal the pay symbol) | #### Response `201` — payment object fields | Field | Description | |-------|-------------| | `payment_id` | payzum invoice ID | | `payment_status` | Current status | | `pay_address` | Deposit address for the buyer | | `price_amount` | Original fiat or crypto amount | | `price_currency` | Original currency | | `pay_amount` | Amount the buyer must send | | `pay_currency` | Crypto currency code | | `amount_received` | Amount received so far | | `actually_paid` | Amount actually paid by the buyer (mirrors `amount_received`) | | `order_id` | Merchant's order ID (nullable) | | `purchase_id` | Purchase reference | | `network` | Chain identifier | | `network_precision` | Decimal precision for this chain | | `created_at` | ISO 8601 creation timestamp | | `invoice_url` | Hosted checkout URL (see §3.4) | #### Errors `INVALID_REQUEST`, `CURRENCY_NOT_SUPPORTED`, `QUOTA_EXCEEDED`, `RATE_PROVIDER_DOWN`, `INTERNAL_ERROR`. --- ### §3.2 Read invoices - **`GET /v1/payment/:id`** — fetch a single invoice. The `:id` path parameter accepts either the payzum `payment_id` or the merchant's `order_id`. - **`GET /v1/payment`** — paginated list. Query params: `limit` (1–100), `page` (**zero-based** — not an offset), `sortBy` (`created_at` | `updated_at`; any other value silently falls back to `created_at`), and `orderBy` (`asc` | `desc`). --- ### §3.3 Helpers (no auth required) | Endpoint | Description | |----------|-------------| | `GET /v1/status` | Liveness probe — returns `{ "message": "OK" }` | | `GET /v1/currencies` | Authoritative list of supported (chain, symbol) tuples in suffix-style codes (e.g. `usdttrc20`). **Source of truth for supported chains/tokens.** | | `GET /v1/estimate?amount=¤cy_from=¤cy_to=` | Quote a fiat amount in a target crypto currency; also returns `min_amount_usd` and `below_minimum` | | `GET /v1/min-amount?currency_from=¤cy_to=` | Per-currency minimum payment amount in USD; below-min invoices are rejected with `AMOUNT_BELOW_MINIMUM` | --- ### §3.4 Hosted checkout Every invoice response includes an `invoice_url` field of the form `https://merchant.payzum.com/pay/:invoiceId`. Redirect the buyer to this URL to show the payzum-hosted checkout page. The page displays: - The chain and pay currency - The exact pay amount and deposit address (with copy button) - A QR code targeting the deposit address - A countdown to invoice expiration - A live status indicator that updates as confirmations arrive On completion the buyer is redirected to `success_url`; on cancellation to `cancel_url`. If you prefer to render your own checkout UI, poll the buyer-facing status endpoint `GET /v1/invoices/:invoiceId/status` directly (authenticated by the invoice id itself, not the merchant API key). --- ### §3.5 Embeddable widget Load the widget script once per page, then annotate buttons with `data-payzum-link`: ```html ``` The script auto-scans `[data-payzum-link]` elements on load. Call `window.Payzum.rescan()` after adding elements dynamically. #### `window.Payzum` API | Method | Description | |--------|-------------| | `open(invoiceId, options)` | Open an existing invoice in a modal overlay | | `openInline(invoiceId, containerEl, options)` | Embed into a DOM element | | `openLink(linkId, body, options)` | Create and open an invoice from a payment link | | `close()` | Close the active overlay | | `rescan()` | Re-scan the DOM for `[data-payzum-link]` attributes | #### `options` callbacks | Callback | Triggered when | |----------|----------------| | `onSuccess(invoiceId)` | Invoice reaches `paid` or `overpaid` | | `onCancel(invoiceId)` | Invoice is cancelled | | `onExpired(invoiceId)` | Invoice expires | | `onPartial(invoiceId)` | Invoice is `partial` | | `onClose()` | Overlay is dismissed | #### Status values emitted `pending | partial | paid | overpaid | expired | cancelled` --- ### §3.6 Legacy form-encoded adapter A drop-in endpoint for older shopping-cart payment plugins that speak a form-encoded, HMAC-signed protocol. New integrations should prefer `POST /v1/payment`. **Endpoint:** `POST /legacy/api.php` Form-encoded body (`application/x-www-form-urlencoded`). Sign each request by putting the HMAC in the `HMAC` request header: ``` HMAC: hex(HMAC-SHA-512(privateKey, rawBody)) ``` The raw body bytes you send must match the bytes you signed — do not re-encode the form between signing and transmitting. **Supported commands** (pass as `cmd` field): | Command | Equivalent REST endpoint | |---------|--------------------------| | `create_transaction` | `POST /v1/payment` | | `get_tx_info` | `GET /v1/payment/:id` | | `rates` | `GET /v1/currencies` | Each network has a minimum payment amount. `create_transaction` rejects a below-minimum `amount` with `{ "error": "amount below minimum" }` and HTTP `400`. `rates` returns, per currency, `min_amount_usd` (the USD floor) and `min_amount` (the same floor in that currency) — read them before charging. See §3.3 (`GET /v1/min-amount`) for the equivalent REST surface. IPN payloads for legacy-adapter transactions are delivered using the same signed-webhook format as the REST API (see §4.3). For new integrations, prefer `POST /v1/payment`. --- ## §4 Webhooks / IPN verification **CRITICAL:** There are THREE distinct webhook shapes with DIFFERENT signature schemes. Use the correct algorithm for each. **In PHP or Node, do not implement this section by hand** — the official SDKs (§0) ship all three verifiers with the header names fixed internally: `verifyPaymentIpn`, `verifyCoinPaymentsIpn`, `verifyMassPayout`. The material below is the wire-level contract, and the fallback for other languages. --- ### §4.1 Payment IPN **Algorithm:** HMAC-SHA-512, lowercase hex **Signature header:** `x-nowpayments-sig` — **fixed, not configurable**. The name comes from payzum's NowPayments-compatible dialect, which lets an existing NowPayments integration point at payzum without code changes. Look it up case-insensitively (RFC 9110); do not build a setting for it. > **If your merchant was issued CoinPayments keys**, deliveries use the > CoinPayments shape instead: header `HMAC`, HMAC-SHA-512 over the > **form-encoded** body, dedup on `ipn_id`. See §4.3. A merchant is in exactly > one mode; the default is the NowPayments shape described here. **Body format:** payzum serializes the payment object as sorted-key canonical JSON and signs *that exact string*, then transmits it as the request body. So the bytes you receive over the wire ARE the signed bytes. **Replay protection:** the signed payload carries `event_id` and `event_at` (epoch seconds), and the delivery also sets the `x-payzum-event-id` header. Reject deliveries whose `event_at` is more than 10 minutes old, and deduplicate on `event_id` — retries reuse the same id. Verify the HMAC against the RAW request bytes exactly as received — do not re-parse and re-serialize the JSON before comparing, or key reordering will break the signature. ```js import crypto from "node:crypto"; // Verify against the RAW request bytes, before JSON.parse. function verifyPaymentIpn(rawBody, sigHeader, secret) { const expected = crypto.createHmac("sha512", secret).update(rawBody).digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(sigHeader, "hex"); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` #### Express handler example ```js import express from "express"; import { verifyPaymentIpn } from "./verify.js"; // Fixed header — not a setting. express's req.header() is case-insensitive. const PAYZUM_SIG_HEADER = "x-nowpayments-sig"; const app = express(); app.post( "/payzum/ipn", express.raw({ type: "application/json" }), (req, res) => { const sig = req.header(PAYZUM_SIG_HEADER); if (!sig || !verifyPaymentIpn(req.body, sig, process.env.PAYZUM_WEBHOOK_SECRET)) { return res.status(401).send("invalid signature"); } const payload = JSON.parse(req.body.toString("utf8")); // handle payload.payment_status, payload.payment_id, etc. res.sendStatus(200); }, ); ``` #### Payment status values `payment_status` emits exactly FIVE values — no others ever appear: `waiting` → `partially_paid` → `finished` | `expired` | `failed` - There is **no `unconfirmed`** status. Older documentation listed one; it does not exist. - **Overpayment surfaces as `finished`.** Overpaid is handled internally and the merchant receives a normal paid signal. Do not build reconciliation logic that expects an `overpaid` value here. - **Cancellation surfaces as `failed`.** There is no `cancelled` value on this surface. The buyer-facing endpoint `GET /v1/invoices/{id}/status` uses a DIFFERENT vocabulary — `pending | partial | paid | overpaid | expired | cancelled` — with camelCase field names, decimal-string amounts and epoch-ms timestamps. The two surfaces share no field names. Do not mix them. #### What triggers an IPN, and what the payload tells you Five distinct internal conditions produce a delivery: the invoice was paid, it expired, a late deposit arrived, a wrong token was received, or a suspicious token was received. **The NowPayments-shaped payload does NOT carry an event-type field.** The body is the payment object plus `event_id` and `event_at`. Branch on `payment_status` and on the amount fields — never on an event name, because there isn't one. **There is no IPN for a partial payment and none for a cancellation.** Poll `GET /v1/payment/{id}` if you need those transitions. Older documentation claimed IPNs fired on `partially_paid`; that was wrong. Non-2xx responses and network errors are retried up to 5 times with a 30-second backoff. Failed deliveries are dead-lettered and surface in the admin DLQ. #### IPN invoice_type discriminator Every IPN payload carries an `invoice_type` field: | Type | Notes | |------|-------| | `payment` | One-shot payment from a reusable button | | `donation` | Buyer-driven amount; optional metadata in `_payzum.donation` | | `subscription` | Recurring cycle; adds `subscriber_email`, `subscription_cycle`, `next_renewal_at` | | `pos` | Cashier-driven single-use terminal invoice | --- ### §4.2 Mass-payout webhooks **Header:** `X-Payzum-Signature` **Algorithm:** HMAC-SHA-256 over the raw body **Dedup key:** `X-Payzum-Event-Id` — stable across retries; use it to deduplicate delivery. ```js import crypto from "node:crypto"; function verifyMassPayoutWebhook(rawBody, sigHeader, secret) { const expected = crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(sigHeader, "hex"); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` #### Common envelope fields Every mass-payout event payload contains: | Field | Type | Description | |-------|------|-------------| | `eventType` | string | Event type (see below) | | `eventId` | string | Stable ID (`pzwe_` prefix) for deduplication | | `eventAt` | number | Unix timestamp (seconds) when emitted | | `order` | object | Order snapshot at time of event | #### Event types | Event type | When it fires | |------------|---------------| | `mass_payout.created` | After `POST /:id/confirm` — order moves to `pending_deposit` | | `mass_payout.quote_refreshed` | After successful `POST /:id/refresh-quote` | | `mass_payout.deposit_detected` | Gateway receives deposit callback from Vexo | | `mass_payout.underfunded` | Received amount < `totalToSendRaw` | | `mass_payout.overfunded` | Received amount > `totalToSendRaw` | | `mass_payout.batch_broadcasted` | A batch transaction is broadcast on-chain | | `mass_payout.batch_confirmed` | A batch reaches the required confirmation threshold | | `mass_payout.batch_failed` | A batch fails permanently after exhausting retries | | `mass_payout.completed` | All batches confirmed | | `mass_payout.partial_failed` | Order ends with some batches failed | | `mass_payout.expired` | Deposit not received before `expiresAt` | | `mass_payout.cancelled` | Order cancelled by merchant or operator | Delivery contract: 15-second timeout, retried on 5xx or network error with a 60-second delay (up to 5 attempts). 4xx responses drop the message. After max retries the event is dead-lettered. --- ### §4.3 Legacy adapter IPN When using the legacy adapter (`POST /legacy/api.php`), IPN payloads are signed with the `HMAC` request header using HMAC-SHA-512 over the raw body bytes — the same algorithm as the request signing in §3.6. This is also the shape delivered to any merchant who was issued CoinPayments keys, whatever endpoint they create invoices through. **Body:** form-encoded (not JSON), with the CoinPayments field set: `amount1, amount2, currency1, currency2, fee, ipn_id, ipn_mode, ipn_type, ipn_version, merchant, received_amount, received_confirms, status, status_text, txn_id`, plus optional `buyer_email, invoice, custom`. **Replay protection:** this payload carries **no timestamp**, so the 10-minute window described in §4.1 is impossible here. Deduplicate on `ipn_id`, which is stable across retries. No `x-payzum-event-id` header is sent for this shape. --- ## §5 Mass payouts Mass payouts let treasury teams pay many recipients from a single funded deposit address. Upload a CSV and the gateway splits it into batches, broadcasts each batch as a separate on-chain transaction, and webhooks you throughout. ### §5.1 UTXO mass payouts (BTC / LTC / DOGE) #### Order lifecycle ``` pending_quote → pending_deposit → underfunded | funded → executing → completed | partial_failed | expired | cancelled | refunded ``` After creation the order sits in `pending_quote`. Call `POST /:id/confirm` to acknowledge the quote and advance to `pending_deposit`. Fund the deposit address with exactly `totalToSendRaw` base units. The gateway watches for the deposit and transitions to `funded` → `executing` → terminal state automatically. #### Endpoints | Method | Path | Description | |--------|------|-------------| | `POST` | `/v1/mass-payout` | Create order (multipart CSV; optional `Idempotency-Key` header) | | `GET` | `/v1/mass-payout` | List orders (paginated) | | `GET` | `/v1/mass-payout/:id` | Fetch order with batch detail | | `GET` | `/v1/mass-payout/:id/recipients` | Paginated recipient rows | | `GET` | `/v1/mass-payout/:id/report.csv` | Settlement CSV (generated at terminal state) | | `POST` | `/v1/mass-payout/:id/confirm` | Confirm quote, move to `pending_deposit` | | `POST` | `/v1/mass-payout/:id/cancel` | Cancel order (idempotent) | | `POST` | `/v1/mass-payout/:id/refresh-quote` | Recompute network fee, extend expiry | #### CSV format Header row required. Columns: | Column | Required | Description | |--------|----------|-------------| | `address` | yes | Chain-native recipient address | | `amount` | yes | Decimal string in whole coins (e.g. `0.001`) | | `label` | no | Optional label for audit trail | #### Quote fields All `*Raw` fields are integer strings (no decimal point) in the chain's smallest unit (satoshi / koinu / duff). ``` totalToSendRaw = sumRecipientsRaw + networkFeeRaw + payzumFeeRaw ``` | Field | Description | |-------|-------------| | `sumRecipientsRaw` | Sum of all output amounts in base units | | `networkFeeRaw` | Estimated miner fee across all batches | | `payzumFeeRaw` | Service fee (`payzumFeeBps` bps of `sumRecipientsRaw`) | | `totalToSendRaw` | Total amount to deposit | | `payzumFeeBps` | Fee rate in basis points (100 = 1%) | | `feeRateSatVbSnapshot` | Fee rate used (sat/vbyte at creation time) | #### Error catalog | Error kind | HTTP | Description | |------------|------|-------------| | `invalid_csv` | 400 | Malformed form data, missing field, or CSV parse failure | | `invalid_address` | 400 | Address failed chain-specific validation | | `over_max_decimals` | 400 | Amount precision exceeds chain maximum | | `dust_below_threshold` | 400 | Recipient amount below the dust limit | | `order_too_small` | 400 | Total sum below chain minimum order size | | `chain_disabled` | 400 | Chain is disabled via operator kill-switch | | `idempotency_conflict` | 409 | Duplicate `Idempotency-Key` | #### cURL example — create order ```bash curl -X POST https://merchant.payzum.com/v1/mass-payout \ -H "x-api-key: $PAYZUM_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -F "chain=bitcoin" \ -F "mode=mainnet" \ -F "csv=@payouts.csv" ``` --- ### §5.2 EVM mass payouts (Polygon and more) EVM mass payouts use parallel endpoints under `/v1/evm-mass-payout`. The same CSV format applies; add a `token` field (e.g. `usdc`) to specify the ERC-20 token. The operator wallet is pre-funded; you deposit tokens into the gateway's operator address rather than a one-time deposit address. #### Endpoints | Method | Path | Description | |--------|------|-------------| | `POST` | `/v1/evm-mass-payout` | Create order | | `GET` | `/v1/evm-mass-payout` | List orders | | `GET` | `/v1/evm-mass-payout/:id` | Fetch order | | `GET` | `/v1/evm-mass-payout/:id/recipients` | Paginated recipients | | `GET` | `/v1/evm-mass-payout/:id/deposit-instructions` | Where and how to fund the operator wallet | | `POST` | `/v1/evm-mass-payout/:id/confirm` | Confirm and start execution | | `POST` | `/v1/evm-mass-payout/:id/cancel` | Cancel order | | `POST` | `/v1/evm-mass-payout/:id/refund` | Request a refund | | `POST` | `/v1/evm-mass-payout/:id/refresh-quote` | Refresh gas/fee estimate | Polygon is the first supported chain; additional EVM chains are rolling out. Call `GET /v1/currencies` for the current live set. --- ## §6 Payzum for Agents (x402) > **Beta.** x402 is an HTTP-402-based, pay-per-request payment scheme for AI > agents and other machine clients. payzum acts as the payment/facilitator > layer so you can monetize an API or tool for autonomous agents. ### Resource URL Protected resources are served under: ``` GET https://merchant.payzum.com/v1/x402// ``` A request without valid payment returns **HTTP 402 Payment Required** with the payment requirements (amount, asset, network, and the payment authorization scheme). The client pays, then retries with the payment proof to receive the resource. ### How it works - Settlement asset is USDC on Base; payments use the EIP-3009 `transferWithAuthorization` flow (the `exact` x402 scheme). - You configure protected routes and their price from the dashboard (**API for Agents**), choosing a facilitator: a **sandbox/testnet** facilitator (Base Sepolia) for testing, or the **production** facilitator for mainnet. - Route discovery metadata is published via the facilitator so agent marketplaces can find your endpoints. The exact discovery endpoint and route slug format for your merchant are shown on the in-dashboard **API for Agents** page. This surface is evolving during the beta — confirm behavior for your use case. --- ## §7 Supported chains & tokens **Do NOT hardcode a contract-address table.** Contract addresses and supported tokens change as chains are added or updated. ``` GET /v1/currencies ``` This endpoint is the authoritative, live source of truth for all supported (chain, symbol) tuples. It requires no authentication. Suffix-style codes concatenate symbol + chain suffix (e.g. `usdttrc20` = USDT on Tron). At a high level, payzum supports native coins (BTC, ETH, POL, BNB, TRX, SOL, LTC, DOGE, and more) and stablecoins (USDT, USDC, DAI) across the chains returned by that endpoint. Always use the live endpoint rather than a hardcoded list. --- ## §8 End-to-end recipe — accept a payment This example combines invoice creation, status polling, and IPN verification into a copy-paste runnable flow **using raw HTTP** — it is the language-agnostic fallback. In PHP or Node, the same flow is the §0 quickstart plus a polling loop on `payments.get()`, with no hand-written crypto anywhere. ### Step 1 — Create an invoice ```bash # Store the result in a variable INVOICE=$(curl -s -X POST https://merchant.payzum.com/v1/payment \ -H "x-api-key: $PAYZUM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "price_amount": 49.99, "price_currency": "usd", "pay_currency": "usdttrc20", "order_id": "ORDER-12345", "ipn_callback_url": "https://merchant.example.com/payzum/ipn", "success_url": "https://merchant.example.com/checkout/success", "cancel_url": "https://merchant.example.com/checkout/cancel" }') # Extract fields PAYMENT_ID=$(echo "$INVOICE" | jq -r '.payment_id') INVOICE_URL=$(echo "$INVOICE" | jq -r '.invoice_url') echo "Payment ID: $PAYMENT_ID" echo "Send buyer to: $INVOICE_URL" ``` ### Step 2 — Redirect the buyer Redirect the buyer to `$INVOICE_URL` (the hosted checkout at `https://merchant.payzum.com/pay/:invoiceId`), or display `pay_address` and `pay_amount` from the response in your own UI. ### Step 3 — Poll until terminal Terminal statuses: `finished`, `expired`, `failed`, `cancelled`. ```bash while true; do STATUS=$(curl -s \ -H "x-api-key: $PAYZUM_API_KEY" \ "https://merchant.payzum.com/v1/payment/$PAYMENT_ID" \ | jq -r '.payment_status') echo "Status: $STATUS" case "$STATUS" in finished|expired|failed|cancelled) break ;; esac sleep 15 done ``` ### Step 4 — Verify the IPN in your webhook handler ```js import crypto from "node:crypto"; import express from "express"; // Verify against the RAW request bytes, before JSON.parse. function verifyPaymentIpn(rawBody, sigHeader, secret) { const expected = crypto.createHmac("sha512", secret).update(rawBody).digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(sigHeader, "hex"); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Fixed header — not a setting. express's req.header() is case-insensitive. const PAYZUM_SIG_HEADER = "x-nowpayments-sig"; const app = express(); app.post( "/payzum/ipn", express.raw({ type: "application/json" }), (req, res) => { const sig = req.header(PAYZUM_SIG_HEADER); if (!sig || !verifyPaymentIpn(req.body, sig, process.env.PAYZUM_WEBHOOK_SECRET)) { return res.status(401).send("invalid signature"); } const payload = JSON.parse(req.body.toString("utf8")); if (payload.payment_status === "finished") { // Fulfil the order identified by payload.order_id } res.sendStatus(200); }, ); ``` **Common pitfalls:** - Re-serializing the JSON before verifying — always sign over raw request body bytes. - Returning `4xx` for transient errors — use `5xx` if you want the delivery retried. - Mixing up the webhook signatures. There are THREE and none is interchangeable: payment IPNs use HMAC-SHA-512 with header `x-nowpayments-sig` (§4.1); mass-payout webhooks use HMAC-SHA-256 with header `X-Payzum-Signature` (§4.2); CoinPayments-mode merchants use HMAC-SHA-512 over a form-encoded body with header `HMAC` (§4.3). Using the mass-payout header for a payment IPN is the single most common integration bug with this API. - Making the signature header name configurable. It is fixed per webhook family. A setting invites someone to fill it with the wrong value, and the failure is silent: the signature simply never verifies, deliveries 401, and orders are never fulfilled. - Expecting an `overpaid` or `cancelled` value in `payment_status`, or an `unconfirmed` one. Only five values exist — see §4.1.