API Documentation

Accept non-custodial stablecoin payments on any chain. Integrate with our REST API in minutes.

Base URL

https://staging-api.payzap.cc

Auth

Bearer <JWT>

Format

JSON

Quick Start

1Sign in at payzap.cc/dashboard with your wallet
2Create a product with a name and price
3Share the payment link or create sessions via API
4Set up webhooks to get notified when payments are confirmed

Authentication

All authenticated endpoints require a JWT in the Authorization header. Get a token by signing a nonce with your wallet.

Operating limits

Access tokenValid 24 hours. Refresh token 30 days, rotated on use — a refresh token replayed after rotation is rejected.
Rate limits100 requests/minute overall; 15/minute on authentication; 5/minute on email sign-in. Over the limit returns 429 RATE_LIMITED.
Webhook retries5 attempts at 5s, 30s, 5m, 30m, 1h. A delivery that never succeeds is marked failed and not retried further; the event stays readable through the API.
Failed chargespayment.failed is final — we do not retry a charge on your behalf. Retry it yourself with a new Idempotency-Key, guided by failureCode: insufficient_allowance needs the customer to re-authorize, insufficient_balance is worth retrying later.

Auth header format

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
GET/v1/auth/nonce

Get a one-time nonce for wallet signature authentication. Nonces expire after 5 minutes.

Example response

{
  "success": true,
  "data": {
    "nonce": "a1b2c3d4...",
    "message": "Sign in to PayZap\n\nNonce: a1b2c3d4..."
  }
}
POST/v1/auth

Authenticate with a wallet signature. Returns JWT access + refresh tokens.

Request body

FieldTypeReqDescription
walletAddressstringyesYour wallet address
chainenumyes"evm" | "ton" | "tron" | "solana"
signaturestringyesSigned nonce message
noncestringyesNonce from GET /v1/auth/nonce

Example response

{
  "success": true,
  "data": {
    "token": "eyJhbG...",
    "refreshToken": "eyJhbG...",
    "merchant": { "id": "...", "plan": "free" }
  }
}
GET/v1/auth/oauth/providers

Which sign-in methods this deployment has configured. Render only what is listed — a provider without credentials is not offered.

Example response

{
  "success": true,
  "data": {
    "providers": ["google", "microsoft", "apple"],
    "email": true
  }
}
GET/v1/auth/oauth/:provider/start

Begin an OAuth sign-in. Redirects (302) to the provider. Send the browser here — do not fetch it. State, nonce and the PKCE verifier are held server-side for 10 minutes.

GET/v1/auth/oauth/:provider/callback

The provider returns the customer here; PayZap verifies the ID token and redirects to your dashboard callback with the JWT pair in the URL fragment (#access=…&refresh=…), so tokens never reach server logs or a Referer header. On failure the fragment carries #error= instead.

POST/v1/auth

Exchange an API key for a JWT. The server-to-server path: an sp_ key authenticates this one call, and the returned token authorizes the rest of the API.

Request body

FieldTypeReqDescription
authMethodenumyesMust be "api_key" — this selects the branch. Omit it and the wallet-signature body above applies.
apiKeystringyesYour sp_… key

Example response

{
  "success": true,
  "data": {
    "token": "eyJhbGciOi...",
    "refreshToken": "eyJhbGciOi...",
    "merchant": { "id": "...", "plan": "free" }
  }
}
POST/v1/auth/refresh

Refresh an expired access token.

Request body

FieldTypeReqDescription
refreshTokenstringyesRefresh token from auth response

Products

Products represent items or services you sell. Each product has a price and generates a payment link.

POST/v1/productsAUTH

Create a new product.

Request body

FieldTypeReqDescription
namestringyesProduct name (1-255 chars)
descriptionstringnoDescription (up to 2000 chars)
priceAmountnumberyesPrice in USD (max 1,000,000). Note: returned as string in API responses
priceCurrencystringnoCurrency code (default: "USD")
acceptedChainsstring[]no"evm" | "ton" | "tron" | "solana" | "binance_pay" | "bybit_pay"
successUrlstringnoURL to redirect customer after successful payment
slugstringnoCustom short-link slug (3-32 chars, lowercase a-z + 0-9 + hyphen). Used at payzap.cc/r/<slug>. Auto-generated if omitted. Globally unique.
metadataobjectnoArbitrary JSON metadata
GET/v1/productsAUTH

List your products.

Query params

ParamTypeDescription
limitnumber1-100 (default: 20)
offsetnumberOffset (default: 0)
GET/v1/products/:idAUTH

Get a single product by ID.

PATCH/v1/products/:idAUTH

Update a product. All fields optional.

Request body

FieldTypeReqDescription
namestringnoNew name
priceAmountnumbernoNew price
activebooleannoEnable/disable
acceptedChainsstring[]noAccepted payment methods
successUrlstring|nullnoSuccess redirect URL (null to clear)
slugstring|nullnoUpdate or remove the short-link slug. Pass null to drop it (slug becomes unbound; old /r/<slug> URL returns 404).
DELETE/v1/products/:idAUTH

Deactivate a product (soft delete).

GET/v1/public/slug/:slug

Resolve a short-link slug to its productId. Public — no auth. Atomically increments the slug click counter (used for influencer / channel attribution). The /r/<slug> page on payzap.cc calls this internally before redirecting to /pay/<productId>; merchants typically don't hit this directly except for analytics tooling.

Example response

{
  "success": true,
  "data": {
    "productId": "13463560-8a8b-44e1-8216-63c2bf205a43",
    "slug": "coffee",
    "clicks": 1247
  }
}

Payments

Payment sessions are created when a customer initiates checkout. The session tracks the payment lifecycle from pending to confirmed.

Retrying safely. Every endpoint that moves money — creating a session, charging a mandate, issuing a refund — accepts an Idempotency-Key header. A retry carrying the same key returns the original response instead of executing again, so a timeout you never saw the answer to is safe to repeat. The key is scoped to your account and the route, so an ID can be reused across endpoints without colliding.

Only successful responses are recorded. A failed attempt releases the key, so you may retry with the same key after fixing the request. A retry sent while the first is still in flight gets 409 — back off and try again rather than treating it as a failure.

orderRef is not a deduplication key. It is your order ID, stored and searchable via GET /v1/payments?orderRef=…, but it is not unique — two charges carrying the same one create two charges. Use Idempotency-Key for that.

Dynamic pricing: Pass the amount field when creating a session to override the product price. Useful for marketplaces, carts, and pay-what-you-want.

POST/v1/payments/session

Create a payment session. Returns a session with wallet address and payment link. Public endpoint — no auth required.

Request body

FieldTypeReqDescription
productIduuidyesProduct ID
chainenumyes"evm" | "ton" | "tron" | "solana" | "binance_pay" | "bybit_pay"
assetenumyes"USDT" | "USDC" | "DAI" | "BUSD"
amountnumbernoOverride product price (dynamic pricing)
currencyenumnoPrice the amount in fiat: USD | EUR | AED | BRL | BOB | VES | ARS | TRY | LBP. Converted to crypto at a rate locked when the session is created. Requires an API key — anonymous callers may only use USD.
customerRefstringnoYour internal customer ID. Stable per customer — the mandate flow keys on it.
orderRefstringnoYour order ID. Binds the payment to an order for lookup (GET /v1/payments?orderRef=) and refund scoping.
createMandatebooleannoPay & save (EVM only): the same approve that funds this order also opens a mandate for future off-session charges.
gaslessbooleannoBuyer pays no native gas; PayZap sponsors settlement.
successUrlstringnoOverride product success URL for this session
metadataobjectnoArbitrary JSON metadata

Example response

{
  "success": true,
  "data": {
    "id": "sess_...",
    "productId": "...",
    "merchantWallet": "0x...",
    "amount": "49.00",
    "asset": "USDT",
    "chain": "evm",
    "status": "pending",
    "orderRef": "order_98765",
    "expiresAt": "2026-03-17T12:30:00Z",
    "paymentUrl": "https://payzap.cc/pay/prod_...",

    // present only when `currency` was set — the locked quote
    "fiatAmount": "45.50",
    "fiatCurrency": "BOB",
    "fxRate": "11.2664",
    "fxSource": "er-api",
    "fxLockedAt": "2026-03-17T12:15:00Z"
  }
}
GET/v1/payments/session/:id

Get session status. Use for polling from your frontend. Public — no auth.

GET/v1/paymentsAUTH

List your payment sessions. Supports filtering by customerRef (the external order ID you passed at session creation — exact match) and status. Useful when an upstream system like a taxi backend needs to round-trip "what is the state of order_42?" without keeping its own session-id mapping.

Query params

ParamTypeDescription
limitnumber1-100 (default: 20)
offsetnumberOffset (default: 0)
customerRefstringExternal order ID — exact match. Find a specific session by your own reference.
statusenumpending | confirming | completed | failed | expired | refunded
GET/v1/payments/:idAUTH

Get a single payment by ID.

Mandates

A standing authorization to charge a customer off-session. They approve once; you charge whenever an order completes, with no checkout and nobody present.

A mandate belongs to a customer, not to an order. There is at most one active authorization per customerRef, and it stays active until it is revoked. Charge it as many times as you like — every order, and every later top-up on an order already charged, is just another POST /v1/payments/charge with a new orderRef. You never open a second setup session for the same customer.

Pass a permanent user ID as customerRef. An order or trip ID would force a fresh authorization for every order, which is the thing a mandate exists to remove.

Which method backs it is the customer's business. They choose a web3 wallet or an exchange account in the widget and may swap later; that is invisible to you and changes nothing about the mandate's status.

Lifecycle

StatusMeaningWebhook
pendingSetup link issued, customer has not finished binding
activeChargeable. No expiry — it lasts until revoked.mandate.activated
revokedEnded. reason says by whom: user cancelled in the widget, onchain withdrew the approval directly from their wallet, api means you called revoke.mandate.revoked
expiredThe 24-hour setup link lapsed before the customer bound a method. This only ever applies to a pending mandate — an active authorization does not expire on its own.mandate.expired

A charge can fail without anything being wrong on your side. Both cases arrive as payment.failed with a failureCode that tells them apart: insufficient_allowance — the charge exceeds what the customer authorized, so ask them to re-authorize — and insufficient_balance — the authorization holds, the funds do not.

POST/v1/mandates/sessionAUTH

Open a setup session and get a widget URL to show the customer once. They pick a method and authorize; you charge afterwards without them present.

Request body

FieldTypeReqDescription
customerRefstringyesYour PERMANENT user ID. Not an order or trip ID — one active mandate is held per customerRef, and every later charge resolves through it.
metadataobjectnoArbitrary JSON metadata

Example response

{
  "success": true,
  "data": {
    "id": "8f2c...",
    "status": "pending",
    "customerRef": "user_44219",
    "setupUrl": "https://payzap.cc/setup/8f2c...",
    "setupExpiresAt": "2026-03-18T12:00:00Z",
    "metadata": { "regionCode": "BO-L" },
    "createdAt": "2026-03-17T12:00:00Z"
  }
}
POST/v1/payments/chargeAUTH

Charge a customer's active mandate off-session. Returns immediately as pending; settlement is asynchronous (on-chain, usually seconds). Accepts an Idempotency-Key header — orderRef is NOT deduplicated on its own.

Request body

FieldTypeReqDescription
customerRefstringyesWhose mandate to debit
orderRefstringyesYour order ID for this charge. Required here (optional on checkout sessions).
amountnumberyesAmount to charge. No API-level minimum or maximum — the ceiling is what the customer authorized, then their balance.
currencyenumnoUSD | EUR | AED | BRL | BOB | VES | ARS | TRY | LBP. Defaults to USD (1:1 with USDT); anything else is converted at a rate locked on the charge.
metadataobjectnoArbitrary JSON metadata

Example response

{
  "success": true,
  "data": {
    "id": "pay_...",
    "status": "pending",
    "customerRef": "user_44219",
    "orderRef": "order_98765",
    "mandateId": "8f2c...",        // which authorization was debited
    "amount": "6.53",
    "currency": "USD",
    "fiat": {                       // null unless a non-USD currency was sent
      "amount": 45.5,
      "currency": "BOB",
      "exchangeRate": { "rate": "11.2664", "lockedAt": "2026-03-17T12:15:00Z" }
    },
    "metadata": { "driverId": "drv_123" }
  }
}
GET/v1/mandatesAUTH

List mandates. Filter by customerRef to find a customer's authorization, or by status to audit.

Query params

ParamTypeDescription
customerRefstringExact match
statusenumpending | active | revoked | expired
limitnumber1-100, default 20
offsetnumberDefault 0

Example response

{
  "success": true,
  "data": [
    {
      "id": "8f2c...",
      "status": "active",
      "customerRef": "user_44219",
      "setupExpiresAt": "2026-03-18T12:00:00Z",
      "metadata": {},
      "createdAt": "2026-03-17T12:00:00Z"
    }
  ],
  "pagination": { "total": 1, "limit": 20, "offset": 0 }
}
GET/v1/mandates/:idAUTH

Fetch one mandate.

Example response

{
  "success": true,
  "data": {
    "id": "8f2c...",
    "status": "active",
    "customerRef": "user_44219",
    "setupExpiresAt": "2026-03-18T12:00:00Z",
    "metadata": { "regionCode": "BO-L" },
    "createdAt": "2026-03-17T12:00:00Z"
  }
}
GET/v1/mandates/by-customer/:customerRefAUTH

The active mandate for one of your users, addressed by your own id. Saves storing our mandate id alongside your user record and keeping the two in sync. 404 when that customer has no active authorization.

POST/v1/mandates/by-customer/:customerRef/revokeAUTH

Revoke by your own user id. Same effect as revoking by mandate id — emits mandate.revoked with reason "api".

POST/v1/mandates/manageAUTH

Re-open the widget for a customer who is ALREADY set up, so they can see or change their payment method. Use this rather than creating another setup session: a second session mints a second mandate, and the customer only discovers the problem at the end, when binding fails against the one-active-per-customer rule after they have already signed.

Request body

FieldTypeReqDescription
customerRefstringyesWhose authorization to open

Example response

{
  "success": true,
  "data": {
    "id": "8f2c...",
    "status": "active",
    "customerRef": "user_44219",
    "setupUrl": "https://payzap.cc/setup/8f2c..."
  }
}
POST/v1/mandates/:id/revokeAUTH

End an authorization from your side — the customer deleted their payment method, closed the account, or anti-fraud fired. Emits mandate.revoked with reason "api". Idempotent: revoking twice does not emit twice.

GET/v1/public/mandate-setup/:id

What the setup page renders for the customer: available method kinds, supported networks, and the address an on-chain approval must name. Public — possession of the unguessable link is the authorization.

Refunds

Refund a completed payment back to the buyer. Cross-chain — every chain we support for buy-side payments supports refunds with the same gasless ergonomics as the buy flow.

Flow: initiate via POST /refund; the response carries a refundMode discriminator with one of four values, each with its own follow-up endpoint:

Partial refunds: send an amount to refund part of a payment, or omit it to refund whatever is still outstanding. A payment that has been partly refunded sits at partially_refunded and can be refunded again; it moves to refunded once the confirmed refunds add up to the amount paid.

TokenrefundModeMerchant actionConfirm via
EVM USDC / DAIpermitSign returned EIP-712 typed-data/refund/submit
EVM USDT, BSC USDCsponsoredSend ERC-20 transfer (PayZap sponsors gas if balance low)/refund/confirm-transfer
Tron USDT / USDCtron-transferSend TRC-20 transfer (PayZap delegates energy via TronZap → 0 TRX)/refund/confirm-transfer
Solana USDC / USDTsolana-transferSign pre-built fee_payer tx (PayZap covers SOL fee)/refund/confirm-transfer

All flows fire refund.completed webhook on success or refund.failed on permanent failure (with refund_reason populated). Backend verifies amount delta with 0.1% tolerance regardless of chain.

POST/v1/payments/:id/refundAUTH

Initiate a refund for a completed payment. Returns a discriminated union keyed on `refundMode`. Cross-chain — works for EVM (permit + sponsored), Tron, and Solana. The shape of the response tells the merchant exactly what to do next: sign typed-data (permit), build an ERC-20 transfer (sponsored), build a TRC-20 transfer (tron), or sign a pre-built fee_payer transaction (solana).

Request body

FieldTypeReqDescription
amountnumbernoPartial refund. Omit to refund everything still outstanding. Several partial refunds may be issued against one payment; their total may not exceed the amount paid.
orderRefstringnoScope the refund to one order when the payment carries several.

Example response

// EVM permit-mode (USDC / DAI / ERC-3009)
{
  "success": true,
  "data": {
    "refundId": "rfnd_...",
    "amount": 49,
    "asset": "USDC",
    "buyerWallet": "0x...",
    "refundMode": "permit",
    "permitData": {
      "domain": { "name": "USD Coin", "version": "2", "chainId": 8453, "verifyingContract": "0x..." },
      "types": { "Permit": [...] },
      "primaryType": "Permit",
      "message": { "owner": "0x...", "spender": "0x...", "value": "49000000", "nonce": "0", "deadline": "..." }
    }
  }
}

// EVM sponsored-mode (USDT, BSC USDC, etc — non-permit tokens)
{
  "success": true,
  "data": {
    "refundId": "rfnd_...",
    "amount": 49,
    "asset": "USDT",
    "buyerWallet": "0x...",
    "refundMode": "sponsored",
    "merchantWallet": "0x...",
    "tokenAddress": "0x...",
    "chainId": 8453,
    "rawAmount": "49000000",
    "gasSponsored": true,
    "gasSponsorTxHash": "0x..."
  }
}

// Tron-mode (USDT / USDC TRC-20)
// energyDelegated=true means PayZap rented ~65k energy + bandwidth via
// TronZap and delegated it to the merchant address for 1 hour, so the
// refund broadcast costs the merchant 0 TRX (PayZap absorbs ~$1).
{
  "success": true,
  "data": {
    "refundId": "rfnd_...",
    "amount": 49,
    "asset": "USDT",
    "buyerWallet": "T...",
    "refundMode": "tron-transfer",
    "merchantWallet": "T...",
    "tokenAddress": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
    "rawAmount": "49000000",
    "energyDelegated": true,
    "energyAmount": 64285,
    "energyTransactionId": "01kqq..."
  }
}

// Solana-mode (USDC / USDT SPL)
// serializedTx, when non-null, is base64 of a transaction with feePayer
// set to PayZap's facilitator and partial-signature already applied.
// Merchant adds their token-authority signature in their wallet and
// submits — pays 0 SOL. If serializedTx is null, the facilitator was
// unavailable; merchant builds + sends the SPL transfer themselves
// (paying ~$0.0004 in SOL).
{
  "success": true,
  "data": {
    "refundId": "rfnd_...",
    "amount": 49,
    "asset": "USDC",
    "buyerWallet": "Buyer11...",
    "refundMode": "solana-transfer",
    "merchantWallet": "Merch11...",
    "mintAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
    "rawAmount": "49000000",
    "decimals": 6,
    "serializedTx": "AQA...base64...",
    "feePayer": "FacilitatorAddr11..."
  }
}
POST/v1/payments/:id/refund/submitAUTH

Submit a signed permit for a refund (EVM permit-mode only — USDC, DAI, ERC-3009 tokens). Enqueues `permit() + transferFrom(merchant → buyer)` via the x402 settlement queue. Returns immediately; the refund.completed webhook fires when the on-chain settlement confirms.

Request body

FieldTypeReqDescription
refundIduuidyesRefund ID from /refund initiation
signaturehex stringyesMerchant's EIP-712 signature
POST/v1/payments/:id/refund/confirm-transferAUTH

Confirm a refund where the merchant has already broadcast the on-chain transfer. Multi-chain — backend dispatches based on the refund's chain. EVM: 0x-prefixed 64-hex tx hash; backend reads receipt + verifies the Transfer event recipient + amount within 0.1%. Tron: 64-char hex txID (with or without 0x); backend hits TronGrid getTransactionInfoByID and matches the TRC-20 Transfer event. Solana: base58 transaction signature; backend reads getTransaction.meta.postTokenBalances delta on the buyer's ATA and requires it == expected raw amount within 0.1%. Each chain enforces a 0.1% tolerance on the amount delta to catch wrong-amount refunds; rejects fire `refund.failed` webhook.

Request body

FieldTypeReqDescription
refundIduuidyesRefund ID
txHashstringyesOn-chain tx hash (EVM 0x..., Tron hex, Solana base58 signature)
GET/v1/payments/:id/refundsAUTH

List all refunds for a payment session, including past attempts (cancelled, failed) for audit.

GET/v1/payments/:id/refund/:refundIdAUTH

Get a single refund by ID with current status (pending, submitted, confirmed, failed, cancelled).

Gasless Payments

PayZap covers buyer gas across multiple chains so they can pay without holding native tokens. The widget calls these endpoints automatically — they're documented here for custom checkouts.

Chain & TokenMechanismEndpoint
EVM L2 USDC
Base, Arbitrum, Polygon
ERC-2612 permit, facilitator submits permit() + transferFrom()/permit-data → /permit
EVM USDT
Base, Arbitrum, Polygon, BSC
Facilitator sends gas to buyer, buyer signs a normal ERC-20 transfer/sponsor-gas
BSC USDCSponsored (bridged USDC has no permit)/sponsor-gas
TRON USDT / USDCEnergy + bandwidth rented via TronZap and delegated to buyer/delegate-energy
Solana USDC / USDTFacilitator co-signs as fee_payer; wallet adds buyer signature and broadcasts/solana/sponsor-tx

Enable per-merchant: set gasless.enabled = true via the payment-config endpoint or the dashboard. Per-merchant fee-handling modes: absorb (merchant pays gas), passthrough (default — added to buyer's total), fixed (flat fee).

POST/v1/public/session/:id/permit-data

Build EIP-2612 permit data for a gasless USDC/DAI payment. Buyer's wallet signs the returned typed-data; the signed permit is then submitted via /permit. Public — no auth.

Request body

FieldTypeReqDescription
buyeraddressyesBuyer wallet address (0x…)
POST/v1/public/session/:id/permit

Submit a signed EIP-2612 permit. Backend enqueues `permit() + transferFrom()` for on-chain settlement. Public — no auth.

Request body

FieldTypeReqDescription
owneraddressyesBuyer address (must match the signer)
signaturehex stringyesEIP-712 signature
POST/v1/public/session/:id/sponsor-gas

Send a small amount of native gas (ETH/MATIC/BNB) to the buyer so they can broadcast a normal ERC-20 transfer. Used for USDT and BSC USDC where there's no permit. Public — no auth, rate-limited.

Request body

FieldTypeReqDescription
buyerAddressaddressyesBuyer wallet address
POST/v1/public/session/:id/delegate-energy

Rent ~65k Energy + ~1.5k Bandwidth from TronZap and delegate to the buyer's TRON address so a TRC-20 USDT transfer costs 0 TRX. Idempotent. Public — no auth, rate-limited.

Request body

FieldTypeReqDescription
buyerAddressstringyesBuyer TRON address (T…)
GET/v1/public/tron-energy-estimate

Quote-only — returns the estimated Energy + cost for delegating to a buyer's TRON address. Used by the widget to show gas-fee badges. Public.

Query params

ParamTypeDescription
buyerAddressstringBuyer TRON address
merchantWalletstringMerchant TRON address
POST/v1/public/session/:id/solana/sponsor-tx

Build an SPL transfer with feePayer set to PayZap's Solana facilitator and pre-sign as fee_payer. Returns the base64 transaction; the buyer's wallet adds their signature and broadcasts. Buyer pays 0 SOL. Public — no auth.

Request body

FieldTypeReqDescription
senderAddressstringyesBuyer Solana address (base58)

Example response

{
  "success": true,
  "data": {
    "serializedTx": "base64...",
    "feePayer": "Ba3jY..."
  }
}

Every product gets a payment link at https://payzap.cc/pay/<product_id>. Customize the checkout experience with query parameters:

ParamTypeDescription
success_urlstringRedirect URL after successful payment (overrides product setting)
amountnumberOverride product price (dynamic pricing)
refstringCustomer reference / order ID
themestringUI theme: "dark" (default) or "light"

Example

https://payzap.cc/pay/prod_abc123?success_url=https://myshop.com/thanks&ref=order_456

Success redirect: After payment, the customer is automatically redirected to the success URL with query parameters: session_id, tx_hash, amount, asset, status.

Embed Widget

Add a payment button to any website with a single script tag. No framework required.

Include the widget script and add a button with a data-payzap attribute pointing to your product ID:

<script src="https://payzap.cc/v1.js"></script>

<button data-payzap="prod_abc123">
  Pay $49.00
</button>

JavaScript API

For programmatic control, use PayZap.open():

PayZap.open({
  productId: 'prod_abc123',
  amount: 99.00,       // optional: override price
  ref: 'order_789',    // optional: your internal reference
  theme: 'dark',       // optional: 'dark' | 'light'
  onSuccess: (data) => {
    console.log('Paid!', data.sessionId, data.txHash);
  },
  onClose: () => {
    console.log('Widget closed');
  }
});

Note: The API returns priceAmount as a string (e.g. "49.00") due to PostgreSQL numeric precision. Always use parseFloat() or Number() before arithmetic, and Intl.NumberFormat for display formatting.

Custom Checkout

Build your own payment UI using the PayZap API. Two endpoints are all you need.

Flow

1Create a payment session via POST /v1/payments/session — you get the merchantWallet, amount, and asset
2User sends tokens to merchantWallet using your UI (wagmi, ethers, viem, TonConnect, etc.)
3Poll GET /v1/payments/session/:id until status is "completed" (or set up a webhook)

Step 1 — Create session

const res = await fetch('https://staging-api.payzap.cc/v1/payments/session', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    productId: 'prod_abc123',
    chain: 'evm',
    asset: 'USDT',
    network: 'base',          // optional: specific EVM network
    amount: 49.00,             // optional: override product price
    customerRef: 'order_789',  // optional: your internal reference
  }),
});

const { data: session } = await res.json();
// session.id              — session ID (for polling)
// session.merchantWallet  — send tokens here
// session.amount          — amount to send (string, e.g. "49.00")
// session.asset           — token symbol (e.g. "USDT")
// session.expiresAt       — session expires (30 min)

Step 2 — Send tokens (EVM example with viem)

import { parseUnits } from 'viem';

// ERC-20 transfer to merchant wallet
const tx = await walletClient.writeContract({
  address: USDT_CONTRACT,       // token contract on chosen network
  abi: [{
    name: 'transfer',
    type: 'function',
    inputs: [
      { name: 'to', type: 'address' },
      { name: 'amount', type: 'uint256' },
    ],
    outputs: [{ type: 'bool' }],
  }],
  functionName: 'transfer',
  args: [
    session.merchantWallet,
    parseUnits(session.amount, 6),  // 6 decimals for USDT/USDC
  ],
});

Step 3 — Poll for confirmation

async function waitForPayment(sessionId) {
  while (true) {
    const res = await fetch(
      `https://staging-api.payzap.cc/v1/payments/session/${sessionId}`
    );
    const { data } = await res.json();

    if (data.status === 'completed') {
      return { txHash: data.txHash, explorerUrl: data.txExplorerUrl };
    }
    if (data.status === 'expired' || data.status === 'failed') {
      throw new Error(`Payment ${data.status}`);
    }

    await new Promise(r => setTimeout(r, 3000)); // poll every 3s
  }
}

Session statuses

StatusDescription
pendingWaiting for payment
confirmingTransaction detected, waiting for block confirmations
completedPayment confirmed on-chain
expiredSession timed out (30 min)
failedTransaction failed or reverted

Important: amount is returned as a string (e.g. "49.00"). Use parseFloat() for arithmetic and Intl.NumberFormat for display.

Tip: You don't need auth to create sessions or poll status. These are public endpoints — safe to call from the browser. Use webhooks for server-side confirmation.

Reports

Settlement registers for accounting reconciliation, by API rather than by attachment.

A day is settled by settled_at, not by when it was ordered. A payment created just before midnight and confirmed just after belongs to the second day, because that is when the money moved. Every figure is read from what was recorded at settlement rather than recalculated, so a register re-requested months later returns exactly what it returned the first time — including after a price change.

GET/v1/reports/dailyAUTH

The settlement register for a day: every payment that confirmed and every refund that settled, one stream, with the commission charged on each. Refunds carry a negative amount so a period nets without special-casing rows. Figures come from what was recorded at settlement, so re-requesting an old day returns the same file.

Query params

ParamTypeDescription
dateYYYY-MM-DDA whole UTC day. UTC because both sides have to agree which day a row belongs to.
from / toISO 8601An arbitrary window instead of a calendar day
formatenum"json" (default) or "csv" — csv downloads as a file

Example response

{
  "success": true,
  "period": { "from": "2026-03-17T00:00:00Z", "to": "2026-03-18T00:00:00Z" },
  "count": 2,
  "data": [
    {
      "trust_payment_id": "order_98765",   // your order id
      "transaction_id": "pay_01jt6cd...",  // ours
      "trust_refund_id": null,
      "refund_id": null,
      "amount": "45.50",
      "currency": "USDT",
      "commission": "0.34",
      "currency_commission": "USD",
      "payment_method": "evm",             // where the money went
      "network": "polygon",
      "status": "completed",
      "settled_at": "2026-03-17T12:04:11Z"
    },
    {
      "trust_payment_id": "order_98765",
      "transaction_id": "pay_01jt6cd...",
      "trust_refund_id": "yandex_refund_11",
      "refund_id": "rfnd_01jt...",
      "amount": "-45.50",                  // negative: it reverses the row above
      "currency": "USDT",
      "commission": "0",
      "currency_commission": "USD",
      "payment_method": "evm",
      "network": "polygon",
      "status": "confirmed",
      "settled_at": "2026-03-17T15:20:03Z"
    }
  ]
}
GET/v1/reports/monthlyAUTH

Period totals. Opening and closing balance are always zero and chargebacks are always zero — funds move from payer to merchant without resting with us, and an on-chain transfer cannot be reversed by an issuer. The lines are kept rather than dropped so the shape matches a conventional acquirer report.

Example response

{
  "success": true,
  "data": {
    "period": { "from": "2026-03-01T00:00:00Z", "to": "2026-04-01T00:00:00Z" },
    "opening_balance": 0,
    "total_authorized": 128450.75,
    "total_refunded": 3200.00,
    "total_chargebacks": 0,
    "commission": 962.51,
    "transferred_to_merchant": 125250.75,
    "closing_balance": 0
  }
}

Webhooks

Get real-time notifications when payment events occur. Webhooks are signed with HMAC-SHA256 for verification.

Events

EventDescription
payment.pendingTransfer detected on-chain, waiting for required confirmations
payment.completedPayment fully confirmed on-chain
payment.failedPayment failed or was rejected
payment.expiredSession expired without payment
refund.completedRefund settled on-chain (any mode: EVM permit/sponsored, Tron, Solana). Payload includes refund_id, refund_amount, refund_tx_hash.
refund.failedRefund settlement failed permanently (verification mismatch, on-chain revert, etc). Payload includes refund_reason populated from the chain-side check.
mandate.activatedA customer now has an active authorization — first bind, or a fresh one after a previous authorization ended.
mandate.revokedThe authorization ended. reason names who ended it: user (cancelled in the widget), onchain (approval withdrawn directly from their wallet), api (you called revoke).
mandate.expiredA setup link lapsed before the customer finished binding. Applies to pending mandates only — an active authorization does not expire on its own.
mandate.method_changedThe customer swapped the instrument behind an active authorization — a different wallet, or an exchange account instead of a wallet. The mandate itself does not change status; this is how you learn the backing method moved.

Verifying webhook signatures

const crypto = require('crypto');

function verifyWebhook(body, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(body))
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// In your handler:
const sig = req.headers['x-payzap-signature'];
if (!verifyWebhook(req.body, sig, 'whsec_...')) {
  return res.status(401).send('Invalid signature');
}
POST/v1/webhooksAUTH

Create a webhook endpoint. The secret is returned only once — store it securely.

Request body

FieldTypeReqDescription
urlstringyesHTTPS endpoint URL
eventsstring[]noEvent filter (default: all events)

Example response

{
  "success": true,
  "data": {
    "id": "whk_...",
    "url": "https://example.com/webhook",
    "events": ["payment.completed", "payment.failed"],
    "secret": "whsec_..."
  }
}
GET/v1/webhooksAUTH

List your webhook endpoints.

PATCH/v1/webhooks/:idAUTH

Update a webhook URL or event filter.

DELETE/v1/webhooks/:idAUTH

Delete a webhook endpoint.

POST/v1/webhooks/:id/testAUTH

Send a test webhook event to verify your endpoint.

Merchant

Manage your merchant profile, wallets, and API keys.

GET/v1/merchantAUTH

Get your merchant profile, wallets, and usage.

GET/v1/merchant/walletsAUTH

List your connected wallets.

POST/v1/merchant/walletsAUTH

Add a new wallet address.

Request body

FieldTypeReqDescription
chainenumyes"evm" | "ton" | "tron" | "solana"
addressstringyesWallet address
DELETE/v1/merchant/wallets/:idAUTH

Remove a wallet.

POST/v1/merchant/api-keysAUTH

Create an API key. The raw key is returned only once.

Request body

FieldTypeReqDescription
namestringnoLabel for this key
GET/v1/merchant/api-keysAUTH

List API keys (without secret values).

DELETE/v1/merchant/api-keys/:idAUTH

Revoke an API key.

Payment Config

Configure which payment methods and EVM networks are available on your checkout pages.

Available methods: evm, ton, tron, solana, binance_pay, bybit_pay. Exchange pay methods require configured exchange credentials.

GET/v1/merchant/payment-configAUTH

Get your enabled payment methods and EVM networks.

Example response

{
  "success": true,
  "data": {
    "enabledMethods": ["evm", "ton", "binance_pay"],
    "evmNetworks": ["ethereum", "base", "arbitrum"]
  }
}
PUT/v1/merchant/payment-configAUTH

Update enabled payment methods and EVM networks.

Request body

FieldTypeReqDescription
enabledMethodsstring[]yes"evm" | "ton" | "tron" | "solana" | "binance_pay" | "bybit_pay"
evmNetworksstring[]no"ethereum" | "base" | "arbitrum" | "polygon" | "bsc" | "optimism"

Exchange Credentials

Connect Binance Pay or Bybit Pay to accept payments via exchange checkout. Customers pay using their exchange app.

GET/v1/merchant/exchange-credentialsAUTH

List your exchange API credentials (secrets are masked).

Example response

{
  "success": true,
  "data": [
    {
      "id": "...",
      "provider": "binance_pay",
      "apiKey": "abc***xyz",
      "merchantIdExt": "123456",
      "active": true
    }
  ]
}
POST/v1/merchant/exchange-credentialsAUTH

Add or update exchange API credentials. Required to accept Binance Pay or Bybit Pay.

Request body

FieldTypeReqDescription
providerenumyes"binance_pay" | "bybit_pay"
apiKeystringyesExchange API key
apiSecretstringyesExchange API secret
merchantIdExtstringnoBinance merchant ID (required for Binance Pay)
DELETE/v1/merchant/exchange-credentials/:providerAUTH

Remove exchange credentials. Provider: "binance_pay" or "bybit_pay".

Errors

All errors follow a consistent format. HTTP status codes are used meaningfully.

{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "Product not found"
  }
}
StatusCodeMeaning
400VALIDATION_ERRORInvalid request body or params
401UNAUTHORIZEDMissing or invalid JWT
404NOT_FOUNDResource does not exist
429RATE_LIMITEDToo many requests
500INTERNAL_ERRORServer error

Looking for x402 protocol docs?

Integrate AI agent payments via the HTTP 402 protocol.

x402 Documentation