# 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. --- ## §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`, `offset`, `status`, `order_id`. --- ### §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 | --- ### §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` | 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. --- ### §4.1 Payment IPN **Algorithm:** HMAC-SHA-512 **Signature header:** payzum sends the hex HMAC in a dedicated request header. Read its exact name from the delivered request (inspect the incoming headers) or from your merchant webhook settings, and set `PAYZUM_SIG_HEADER` to it. **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. 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"; // The exact signature header name is visible in the incoming request headers // and in your merchant webhook settings. const PAYZUM_SIG_HEADER = process.env.PAYZUM_SIG_HEADER; 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 `waiting` / `unconfirmed` → `partially_paid` → `finished` | `expired` | `failed` | `cancelled` IPNs are delivered when an invoice transitions to `partially_paid`, `finished`, `expired`, or `failed`. 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. --- ## §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, MATIC, 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. ### 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); } // Set PAYZUM_SIG_HEADER to the signature header name payzum sends (visible in // the incoming request headers and in your merchant webhook settings). const PAYZUM_SIG_HEADER = process.env.PAYZUM_SIG_HEADER; 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 two webhook signatures — payment IPNs use HMAC-SHA-512 over their own signature header (§4.1); mass-payout webhooks use `X-Payzum-Signature` with HMAC-SHA-256 (§4.2). Don't reuse one for the other.