Accept non-custodial stablecoin payments on any chain. Integrate with our REST API in minutes.
Base URL
https://staging-api.payzap.ccAuth
Bearer <JWT>Format
JSONAll authenticated endpoints require a JWT in the Authorization header. Get a token by signing a nonce with your wallet.
Operating limits
| Access token | Valid 24 hours. Refresh token 30 days, rotated on use - a refresh token replayed after rotation is rejected. |
| Rate limits | 100 requests/minute per calling address; 15/minute on authentication; 5/minute on email sign-in. Over the limit returns 429 RATE_LIMITED. Your budget is yours - another integrator's traffic does not spend it. |
| Webhook retries | 5 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 charges | payment.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, mandate_revoked needs the customer to pick or add a method first. |
Auth header format
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...The same header carries an API key: Authorization: Bearer sp_.... A key acts as the account itself and is not tied to a person, so it carries full authority - which is why only an administrator can issue one.
Binding a key to your servers
An API key can be restricted to the addresses it may be used from. A copy of the key taken off your network then stops working - the secret alone is no longer enough. Set allowedIps when you create the key, or change it later with PATCH /v1/merchant/api-keys/:id when your servers move. Both single addresses and CIDR blocks are accepted, IPv4 and IPv6.
POST /v1/merchant/api-keys
{ "name": "backend", "allowedIps": ["203.0.113.7", "198.51.100.0/24"] }A request from anywhere else is refused with 403 FORBIDDEN, and the message names the address we saw - which is the address to add if your egress is behind NAT and you are not sure which one it is:
{
"success": false,
"error": {
"code": "FORBIDDEN",
"message": "This API key is not allowed from 198.51.100.9. Add the address in Settings > API keys, ..."
}
}This restricts the key, not the API. The checkout endpoints your customers' browsers call carry no credentials and stay reachable from anywhere - they have to, or nobody could pay you. An empty list is not the same as no list: null means unrestricted, [] means the key works nowhere.
/v1/meAUTHWhat the current credentials are allowed to do. Returns the role behind a dashboard session, or null for an API key, which carries the account's full authority.
Example response
{
"success": true,
"data": {
"userId": "usr_...",
"merchantId": "mch_...",
"role": "admin",
"permissions": ["data:read", "money:write", "settings:write",
"webhooks:write", "keys:write", "payout:write", "team:write"]
}
}/v1/auth/nonceGet 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..."
}
}/v1/authAuthenticate with a wallet signature. Returns JWT access + refresh tokens.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| walletAddress | string | yes | Your wallet address |
| chain | enum | yes | "evm" | "ton" | "tron" | "solana" |
| signature | string | yes | Signed nonce message |
| nonce | string | yes | Nonce from GET /v1/auth/nonce |
Example response
{
"success": true,
"data": {
"token": "eyJhbG...",
"refreshToken": "eyJhbG...",
"merchant": { "id": "...", "plan": "free" }
}
}/v1/auth/oauth/providersWhich 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
}
}/v1/auth/oauth/:provider/startBegin 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.
/v1/auth/oauth/:provider/callbackThe 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.
/v1/authExchange 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
| Field | Type | Req | Description |
|---|---|---|---|
| authMethod | enum | yes | Must be "api_key" - this selects the branch. Omit it and the wallet-signature body above applies. |
| apiKey | string | yes | Your sp_… key |
Example response
{
"success": true,
"data": {
"token": "eyJhbGciOi...",
"refreshToken": "eyJhbGciOi...",
"merchant": { "id": "...", "plan": "free" }
}
}/v1/auth/refreshRefresh an expired access token.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| refreshToken | string | yes | Refresh token from auth response |
/v1/me/accountsAUTHEvery account the signed-in person can open - their own and the ones they were invited to - with the role held in each. Needs only a valid session, so somebody removed from the account they are looking at can still find the way out to one they belong to.
Example response
{
"success": true,
"data": [
{ "merchantId": "9f2c…", "role": "admin", "email": "ops@acme.example", "brandName": "Acme" },
{ "merchantId": "3b7a…", "role": "viewer", "email": "finance@client.example", "brandName": null }
]
}/v1/auth/switchAUTHOpen another of the person's accounts: a fresh token pair for an account they are a member of. From then on every way they sign in - the emailed link, Google - lands in that account until they switch again. Refused for an account they are not in.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| merchantId | uuid | yes | The account to open, from /v1/me/accounts. |
Products represent items or services you sell. Each product has a price and generates a payment link.
/v1/productsAUTHCreate a new product.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| name | string | yes | Product name (1-255 chars) |
| description | string | no | Description (up to 2000 chars) |
| priceAmount | number | yes | Price in USD (max 1,000,000). Note: returned as string in API responses |
| priceCurrency | string | no | Currency code (default: "USD") |
| acceptedChains | string[] | no | "evm" | "ton" | "tron" | "solana" | "binance_pay" | "bybit_pay" |
| successUrl | string | no | URL to redirect customer after successful payment |
| slug | string | no | Custom short-link slug (3-32 chars, lowercase a-z + 0-9 + hyphen). Used at payzap.cc/r/<slug>. Auto-generated if omitted. Globally unique. |
| metadata | object | no | Arbitrary JSON metadata |
/v1/productsAUTHList your products.
Query params
| Param | Type | Description |
|---|---|---|
| limit | number | 1-100 (default: 20) |
| offset | number | Offset (default: 0) |
/v1/products/:idAUTHGet a single product by ID.
/v1/products/:idAUTHUpdate a product. All fields optional.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| name | string | no | New name |
| priceAmount | number | no | New price |
| active | boolean | no | Enable/disable |
| acceptedChains | string[] | no | Accepted payment methods |
| successUrl | string|null | no | Success redirect URL (null to clear) |
| slug | string|null | no | Update or remove the short-link slug. Pass null to drop it (slug becomes unbound; old /r/<slug> URL returns 404). |
/v1/products/:idAUTHDeactivate a product (soft delete).
/v1/public/slug/:slugResolve 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
}
}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: Create the session from your server with your API key and pass amount to price it yourself - carts, discounts, rides, deliveries. Without the key the field is ignored and the product price applies, so a buyer cannot name their own price; the one exception is a tip / donation product, where choosing the amount is the point. An amount in a payment link or in the widget does not change a fixed price.
/v1/payments/sessionCreate a payment session. Returns the session with the address to pay. An API key is optional: with it, amount and currency set the price; without it the product price applies (a tip product takes the buyer's amount).
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| productId | uuid | yes | Product ID |
| chain | enum | yes | "evm" | "ton" | "tron" | "solana" | "binance_pay" | "bybit_pay" |
| asset | enum | yes | "USDT" | "USDC" | "DAI" | "BUSD" |
| amount | number | no | Price of this session instead of the product price. Honoured only with an API key, so call from your server; an anonymous call gets the product price. Tip products accept the buyer's amount within their limits. |
| currency | enum | no | Price 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. |
| customerRef | string | no | Your internal customer ID. Stable per customer - the mandate flow keys on it. |
| orderRef | string | no | Your order ID. Binds the payment to an order for lookup (GET /v1/payments?orderRef=) and refund scoping. |
| createMandate | boolean | no | Pay & save (EVM only): the same approve that funds this order also opens a mandate for future off-session charges. |
| gasless | boolean | no | Buyer pays no native gas; PayZap sponsors settlement. |
| successUrl | string | no | Override product success URL for this session |
| network | string | no | EVM network to pay on: ethereum | bsc | polygon | arbitrum | base. Required for the hosted session page on chain "evm". Rejected when the network does not carry the asset (Base has USDC only). |
| metadata | object | no | Arbitrary 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",
// the hosted page that pays exactly this session (null for exchange pay)
"checkoutUrl": "https://payzap.cc/pay/s/sess_...",
// present only when `currency` was set - the locked quote.
// fxRate is the effective rate, spread already applied.
"fiatAmount": "558.84",
"fiatCurrency": "BOB",
"fxRate": "11.4048",
"fxSource": "binance-p2p",
"fxLockedAt": "2026-03-17T12:15:00Z"
}
}/v1/payments/session/:idGet session status. Use for polling from your frontend. Public - no auth.
/v1/public/session/:id/payerDeclare the wallet that will pay this session (EVM, Tron, Solana). The hosted session page calls it when the wallet connects; call it yourself if you run your own wallet UI. When several open payments fit a transfer - same wallet, same amount - the one whose declared payer sent it wins, and the webhook says so. Can change until a transaction is claimed. Public - the session id is the credential.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| address | string | yes | The paying wallet, in the chain's own format |
Example response
{ "success": true, "data": { "payerAddress": "0xabc…" } }/v1/public/session/:id/txClaim the transaction the wallet returned for this session. Needs a declared payer, and counts only if that payer is the sender on chain - claiming someone else's transaction does nothing. One transaction pays one session: 409 TX_CLAIMED when another holds it, CLAIM_LOCKED when this one holds a different one. The same claim again is a no-op. Public.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| txHash | string | yes | EVM hash, Tron transaction id (with or without 0x) or Solana signature |
Example response
{ "success": true, "data": { "claimedTxHash": "0x…" } }/v1/public/session/:id/checkoutEverything the hosted session page renders: the session, its network and chain id, product name, merchant branding, and the payer and transaction already declared. Checkout sessions only. Public.
/v1/paymentsAUTHList 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
| Param | Type | Description |
|---|---|---|
| limit | number | 1-100 (default: 20) |
| offset | number | Offset (default: 0) |
| customerRef | string | External order ID - exact match. Find a specific session by your own reference. |
| status | enum | pending | confirming | completed | failed | expired | refunded |
/v1/payments/:idAUTHGet a single payment by ID.
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.
Several methods, one of them charged. A customer can connect a wallet and an exchange account and keep both. Exactly one is active - the database enforces it - and that is the one every charge uses. A failed charge stops: we never move to another instrument on its own, because that is the customer's money and their choice. Switch deliberately with POST /v1/mandates/:id/methods/:methodId/activate. A newly bound method becomes the active one only when nothing else is; next to a working method it waits until the customer, or you, choose it.
The cap is on-chain, not ours. capUnits is the allowance the customer granted to spenderAddress, and remainingUnits is what survives after the charges already taken - an allowance is a budget that draws down, not a recurring limit. Read the second one when deciding whether a charge will go through. We cannot raise either from our side; a higher limit means the customer approving again. A charge above the remainder fails with insufficient_allowance before anything is submitted, so no gas is spent proving it.
Binance is a contract, not an allowance. The customer signs a Direct Debit contract in the Binance app with a limit per payment - at most 50 USDT unless Binance has raised it for your account. Nothing draws down, so capUnits and remainingUnits are null, and a charge above the per-payment limit fails with insufficient_allowance before anything is sent. A charge settles when Binance confirms it, usually within seconds. If the customer cancels the contract in the Binance app, that method is retired and you receive mandate.method_changed; if you revoke the mandate or the customer removes the method, we end the contract at Binance within about a minute, so it does not linger in their app.
Asking for a higher limit. Send requestedCap with POST /v1/mandates/manage and the link you get back opens on that figure, so the customer sees the amount you actually asked for rather than a generic default. It is a suggestion: the wallet and the signature are theirs, and they may approve more, less, or unlimited.
Serving your own setup page? setupUrl can point at your host instead of ours, so the customer stays inside your brand at the moment they approve a spending allowance. Ask your PayZap contact to set it - it is configured on our side rather than through the API, because the value goes into a link that carries the mandate id.
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
| Status | Meaning | Webhook |
|---|---|---|
pending | Setup link issued, customer has not finished binding | - |
active | Chargeable. No expiry - it lasts until revoked. | mandate.activated |
revoked | Ended. reason says by whom: user cancelled in the widget, api means you called revoke. An approval withdrawn in the wallet or the Binance app ends that method only, and the mandate stays active (mandate.method_changed). onchain appears only on authorizations that ended before 23 September 2026. | mandate.revoked |
expired | The 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. Each case arrives 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; insufficient_balance - the authorization holds, the funds do not; and mandate_revoked - the customer withdrew the approval behind the method that was charged. That method is retired and you receive mandate.method_changed; the mandate stays active, with any other method on it intact, so send the customer to POST /v1/mandates/manage to pick or add one.
/v1/mandates/sessionAUTHOpen 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
| Field | Type | Req | Description |
|---|---|---|---|
| customerRef | string | yes | Your PERMANENT user ID. Not an order or trip ID - one active mandate is held per customerRef, and every later charge resolves through it. |
| returnUrl | string | no | Where the customer is sent once they are done: after a successful bind, or a revoke. http(s) only. The page redirects there with ?mandateId=<id>&status=active|revoked on the query, so an app that closes its WebView on a redirect to its own URL can act on the return without asking the API. Comes back on every read of the mandate. |
| metadata | object | no | Arbitrary 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",
"returnUrl": "https://app.example.com/wallet/done",
"metadata": { "regionCode": "BO-L" },
"createdAt": "2026-03-17T12:00:00Z"
}
}/v1/payments/chargeAUTHCharge a customer's active mandate off-session. Returns immediately as pending; settlement is asynchronous - usually seconds, on-chain or when Binance confirms the deduction. Accepts an Idempotency-Key header - orderRef is NOT deduplicated on its own.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| customerRef | string | yes | Whose mandate to debit |
| orderRef | string | yes | Your order ID for this charge. Required here (optional on checkout sessions). |
| amount | number | yes | Amount to charge. No API-level minimum or maximum - the ceiling is what the customer authorized, then their balance. |
| currency | enum | no | USD | 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. |
| metadata | object | no | Arbitrary 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" }
}
}/v1/mandatesAUTHList mandates. Filter by customerRef to find a customer's authorization, or by status to audit.
Query params
| Param | Type | Description |
|---|---|---|
| customerRef | string | Exact match |
| status | enum | pending | active | revoked | expired |
| limit | number | 1-100, default 20 |
| offset | number | Default 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 }
}/v1/mandates/:idAUTHFetch one mandate, with the instruments bound to it. isActive marks the one future charges use; displayName is what to print on a saved-method row. A wallet is named only when its connector identified itself at bind time, so anything bound earlier reads as "Crypto wallet" - the label is the client's word and not something the chain records. capUnits and remainingUnits are base units: divide by 10^tokenDecimals. remainingUnits is null when the allowance could not be read just then. The list endpoint omits methods, since reading them touches the chain per instrument.
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",
"methods": [
{
"id": "3d91...",
"kind": "evm_wallet",
"displayName": "MetaMask",
"isActive": true,
"network": "polygon",
"tokenSymbol": "USDT",
"tokenDecimals": 6,
"customerWallet": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"capUnits": "5000000000",
"remainingUnits": "4870000000",
"revokedAt": null
},
{
"id": "a17b...",
"kind": "binance_pay",
"displayName": "Binance",
"isActive": false,
"network": null,
"tokenSymbol": "USDT",
"tokenDecimals": null,
"customerWallet": null,
"capUnits": null,
"remainingUnits": null,
"revokedAt": null
}
]
}
}/v1/mandates/by-customer/:customerRefAUTHThe active mandate for one of your users, addressed by your own id, with the same methods array as GET /v1/mandates/:id. Saves storing our mandate id alongside your user record and keeping the two in sync. 404 when that customer has no active authorization.
/v1/mandates/by-customer/:customerRef/revokeAUTHRevoke by your own user id. Same effect as revoking by mandate id - emits mandate.revoked with reason "api".
/v1/mandates/manageAUTHRe-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
| Field | Type | Req | Description |
|---|---|---|---|
| customerRef | string | yes | Whose authorization to open |
| requestedCap | string | no | The spending limit to suggest when the customer re-approves, in the token's own units ("5000"). A suggestion: the amount is theirs to set. |
| returnUrl | string | no | Where the customer is sent once they are done, replacing whatever the mandate had: a management session may start from a different screen than the setup did. Same redirect and query as on session creation. |
Example response
{
"success": true,
"data": {
"id": "8f2c...",
"status": "active",
"customerRef": "user_44219",
"setupUrl": "https://payzap.cc/setup/8f2c..."
}
}/v1/mandates/:id/revokeAUTHEnd 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. A Binance contract behind it is ended at Binance too, within about a minute.
/v1/public/mandate-setup/:idEverything the setup page renders: which bind options this merchant can offer, the networks a charge can actually settle on, the address an on-chain approval must name, and every method already connected. Public - possession of the unguessable link is the authorization, so it carries only what a payer needs and never the merchant's other data.
Example response
{
"success": true,
"data": {
"id": "9f2c1d5e-4a3b-4c8d-9e10-7b6a5c4d3e2f",
// pending | active | revoked | expired
"status": "active",
// your own id for this customer, as sent at session creation
"customerRef": "fleet_4417",
"setupExpiresAt": "2026-08-19T10:00:00.000Z",
// The address the customer's approve() must name. Null when the
// facilitator is not configured, in which case evm_wallet is absent
// from availableKinds too.
"spenderAddress": "0x...",
// Base58 addresses a Tron approve and a Solana delegation must name.
// Null where that chain is not offered.
"tronSpenderAddress": "T...",
"solanaDelegateAddress": "Ba3j...",
// What this page may offer. Driven by what is actually usable:
// evm_wallet needs the facilitator, binance_pay needs the merchant's
// own exchange credentials on file.
"availableKinds": ["evm_wallet", "binance_pay"],
// The per-payment limit a Binance contract may carry (Binance's own
// default). Null when binance_pay is not offered.
"binanceMaxLimit": 50,
// Networks a charge can settle on - narrower than the chain list used
// for checkout, so the page must not offer the difference.
"supportedNetworks": ["polygon", "arbitrum", "base", "ethereum"],
// Shown so the customer sees WHO will be charging them. Approving an
// allowance to an unnamed page reads as a drainer.
"merchant": {
"brandName": "Yango",
"logoUrl": "https://...",
"accentColor": "#FF0000"
},
// Where the page sends the customer once they are done - the returnUrl
// set on the session or on manage. Null when none was set.
"returnUrl": "https://app.example.com/wallet/done",
// Every connected instrument. Exactly one has isActive: true - the
// database enforces it - and that is the one future charges use.
"methods": [
{
"id": "3e8a...",
// evm_wallet | tron_wallet | solana_wallet | binance_pay
"kind": "evm_wallet",
"isActive": true,
"network": "polygon",
"tokenSymbol": "USDT",
"customerWallet": "0x...",
// What the customer APPROVED, in the token's base units, as a
// string. Divide by 10^tokenDecimals for a human figure.
// MAX_UINT256 means they approved an unlimited allowance.
"capUnits": "1000000000",
// What is actually LEFT, read from the chain when this was built.
// Charging draws the allowance down, so this is the number that
// decides whether the next charge succeeds. Null means we could
// not reach the chain - unknown, never zero.
"remainingUnits": "300000000",
"tokenDecimals": 6,
// Set once revoked; a revoked method can never be made active.
"revokedAt": null
}
]
}
}/v1/mandates/:id/methods/:methodId/activateAUTHChoose which connected method future charges use. A failed charge never moves to another instrument on its own - that is the customer's money and their choice - so this is how the choice gets made. Refuses a revoked method and a revoked mandate rather than activating something the next charge would fail on.
Example response
{
"success": true,
"data": {
// The full line-up after the switch, same shape as in mandate-setup.
// Exactly one carries isActive: true.
"methods": [
{ "id": "3e8a...", "kind": "evm_wallet", "isActive": true, "...": "..." },
{ "id": "7b1c...", "kind": "binance_pay", "isActive": false, "...": "..." }
]
}
}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.
Charging an estimate, then correcting it: when the final price is only known after the fact - a ride, a delivery, metered usage - charge the estimate against the mandate at the start, then settle the difference when you know it. If the estimate was too high, refund the difference with a partial refund. If it was too low, charge the remainder as a second charge against the same mandate; do not try to increase a charge that already settled. Both directions are ordinary calls you already have:
POST /v1/payments/charge
{ "customerRef": "rider_4417", "orderRef": "ride_9912", "amount": 12.00 } # estimate, at pickup
# ... the ride happens, the real price is 9.50 ...
POST /v1/payments/:id/refund
{ "amount": 2.50 } # give back the difference
# or, if the real price was 14.00, charge the remainder against the same mandate:
POST /v1/payments/charge
{ "customerRef": "rider_4417", "orderRef": "ride_9912_extra", "amount": 2.00 }Use a fresh Idempotency-Key for the correcting call, and keep your own order reference on both so the two land together in your ledger. The second charge draws on the same authorization, so it is subject to the same remaining cap - an estimate that consumed most of the cap leaves little room to correct upwards.
| Token | refundMode | Merchant action | Confirm via |
|---|---|---|---|
| EVM USDC / DAI | permit | Sign returned EIP-712 typed-data | /refund/submit |
| EVM USDT, BSC USDC | sponsored | Send ERC-20 transfer (PayZap sponsors gas if balance low) | /refund/confirm-transfer |
| Tron USDT / USDC | tron-transfer | Send TRC-20 transfer (PayZap delegates energy via TronZap → 0 TRX) | /refund/confirm-transfer |
| Solana USDC / USDT | solana-transfer | Sign 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.
/v1/payments/:id/refundAUTHInitiate 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). B2B deposits are refused: a top-up is a partner funding their own balance, and reversing one would send your money to whatever address it happened to arrive from while the credit on your platform stayed put. Correct those in your own ledger.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| amount | number | no | Partial refund. Omit to refund everything still outstanding. Several partial refunds may be issued against one payment; their total may not exceed the amount paid. |
| orderRef | string | no | Scope 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..."
}
}/v1/payments/:id/refund/submitAUTHSubmit 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
| Field | Type | Req | Description |
|---|---|---|---|
| refundId | uuid | yes | Refund ID from /refund initiation |
| signature | hex string | yes | Merchant's EIP-712 signature |
/v1/payments/:id/refund/confirm-transferAUTHConfirm 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
| Field | Type | Req | Description |
|---|---|---|---|
| refundId | uuid | yes | Refund ID |
| txHash | string | yes | On-chain tx hash (EVM 0x..., Tron hex, Solana base58 signature) |
/v1/payments/:id/refundsAUTHList all refunds for a payment session, including past attempts (cancelled, failed) for audit.
/v1/payments/:id/refund/:refundIdAUTHGet a single refund by ID with current status (pending, submitted, confirmed, failed, cancelled).
Let your partners top up their balance with you by sending stablecoins. Built for platforms that hold a balance for business customers - a fleet funding its account, an agency funding a campaign.
Two ways in. If you know the network up front, POST /v1/deposits allocates everything at once. If your partner picks the network themselves - which is what a top-up widget looks like - open a /v1/deposits/draft with the amount in their own currency, then let their browser call /select once they have chosen. The rate is locked at the draft and honoured at selection, so the figure they decided on is the figure they get.
Flow: create a deposit for a partner, open /deposit/<id> in their browser. The page shows one address and one exact amount. When the transfer arrives the deposit settles and your webhook fires, carrying origin: "deposit" so you can route it to a balance top-up rather than an order.
One address per deposit. Every deposit gets its own receiving address, and the amount is exactly what was asked for. The address is the identifier, so a partner who rounds, or whose exchange takes its fee out of the transfer, still settles: whatever lands there is theirs. Addresses are never reused between deposits, and the money is collected into your treasury behind the scenes - the partner sees one address and one figure. The response carries receiverKind: "derived" and a requestedAmount equal to amount; both remain for readers of the older shape. Deposits are switched on per account by PayZap: until the account's receiving wallet has been set up, creating one is refused with 400 VALIDATION_ERROR.
Credit in your own currency. Pass creditCurrency and the response carries a credit block: what to add to the partner balance, at a rate fixed when the deposit opened and honoured whenever the transfer lands. That matters here - a partner withdrawing from an exchange can take hours, and they were quoted a number up front. The same block rides on the webhook, so you never re-derive the local figure at a different rate than the partner was shown. When the amount that arrives differs from the one asked - an exchange withholding its fee is the common case - the credit is recomputed for what arrived, at that same locked rate, and the deposit keeps receivedAmount and the first figure as quotedCredit in its metadata.
Where the money goes. Deposit addresses are swept daily into your treasury - one address per chain family, held in the same wallet, shown in /v1/merchant/payment-config and on every deposit's receiving.sweeps. From the treasury, payouts go to an address your signers have pinned, on a schedule or on demand: see Treasury & Payouts.
When nothing matches. A partner who pays after the deposit expired, or pays the same address twice, produces an arrival that fits no open deposit. The money is already at your address, so it is not lost - it lands in /v1/deposits/unmatched for you to attribute or dismiss. Attributing settles through the same path an automatic match takes, credits what actually arrived rather than what was asked for, and is allowed against an expired deposit: in B2B an invoice paid on the third day is the common case, not the exception.
/v1/deposits/draftAUTHOpen a top-up from a figure in the partner's own currency, before they have chosen a network. Returns a locked rate and no address - the address depends on the network, which they pick next. Server-to-server: the partner never asserts who they are. Refused with 400 VALIDATION_ERROR until deposits are activated for your account.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| partnerRef | string | yes | Your own id for the partner being credited. |
| fiatAmount | number | yes | What they want added to their balance, in their currency. |
| creditCurrency | enum | yes | USD | EUR | AED | BRL | BOB | VES | ARS | TRY | LBP |
| asset | enum | no | Which stablecoin the figure is quoted against. Defaults to USDT. |
| returnUrl | string | no | Where the deposit page sends the partner once they are done - your own cabinet. Only you know that URL, so it is set here. |
| reference | string | no | Up to 140 characters shown to the payer on the deposit page - an invoice number, say. Unlike metadata, it is meant to be seen. |
| metadata | object | no | Arbitrary JSON metadata |
Example response
{
"success": true,
"data": {
"id": "9f2c1d5e-4a3b-4c8d-9e10-7b6a5c4d3e2f",
"status": "draft",
"partnerRef": "fleet_4417",
"asset": "USDT",
// what they will send, held for the window below; if they choose a
// network after it, the figure is re-quoted first
"estimatedAmount": 441.85,
"credit": {
"amount": 5000,
"currency": "BOB",
"rate": 11.3157,
"lockedAt": "2026-08-15T10:00:00.000Z"
},
// short: this holds a rate they are looking at and have not acted on
"expiresAt": "2026-08-15T10:15:00.000Z"
}
}/v1/public/deposit/:id/selectThe partner has chosen where they are sending from. Issues this deposit its own address and fixes the amount, and returns the same shape as a created deposit. Public - this happens in their browser, so possession of the unguessable id is the authorization. The rate is the one locked at the draft, never re-quoted. Choosing twice is refused.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| chain | enum | yes | "evm" | "tron". Deposit addresses are issued on EVM networks and Tron; "ton" and "solana" are refused for now, and the deposit page does not offer them. |
| network | string | no | Which EVM network. Required when chain is "evm". |
| asset | enum | no | Overrides the asset the draft was quoted against. |
/v1/depositsAUTHOpen a top-up for one of your partners. Returns the amount they must send and an address issued for this deposit alone. Idempotent - a retry with the same Idempotency-Key returns the original deposit rather than opening a second one. Refused with 400 VALIDATION_ERROR until deposits are activated for your account.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| partnerRef | string | yes | Your own id for the partner whose balance this credits. Appears on the deposit and in the webhook. |
| amount | number | yes | What the partner must send. Exactly this figure: the address tells deposits apart, so nothing is added to it. |
| chain | enum | yes | "evm" | "tron". Deposit addresses are issued on EVM networks and Tron; "ton" and "solana" are refused for now, and the deposit page does not offer them. |
| asset | enum | yes | "USDT" | "USDC" | "DAI" | "BUSD" |
| network | string | no | Which EVM network. Required when chain is "evm". |
| creditCurrency | enum | no | What the partner's balance is denominated in: USD | EUR | AED | BRL | BOB | VES | ARS | TRY | LBP. The rate is fixed now and honoured whenever the transfer lands. Omit to credit in the stablecoin itself. |
| metadata | object | no | Arbitrary JSON metadata |
Example response
{
"success": true,
"data": {
"id": "9f2c1d5e-4a3b-4c8d-9e10-7b6a5c4d3e2f",
"status": "pending",
"partnerRef": "fleet_4417",
"chain": "evm",
"network": "polygon",
"asset": "USDT",
// what they must send - exactly what was asked for
"amount": 500,
// the same figure, kept for readers of the older shape
"requestedAmount": 500,
// this deposit's own address, never reused
"address": "0x...",
"receiverKind": "derived",
// present only when `creditCurrency` was set
"credit": {
"amount": 5702.74,
"currency": "BOB",
"rate": 11.4048,
"lockedAt": "2026-08-13T22:21:52.791Z"
},
"expiresAt": "2026-08-14T22:21:52.791Z"
}
}/v1/depositsAUTHList your deposits. Filter by status or partnerRef.
Query params
| Param | Type | Description |
|---|---|---|
| status | enum | draft | pending | confirming | completed | expired | failed |
| partnerRef | string | Exact match on your partner id |
| limit | number | 1-100 (default: 20) |
| offset | number | Offset (default: 0) |
/v1/deposits/:idAUTHGet a single deposit by ID. Carries a receiving block: the address the partner was given, and - for a per-deposit address - every sweep of it into your treasury, with the transaction hash and whether the chain confirmed it. That is what reconciliation needs and used to have no source for: the deposit said completed and the money had moved on without a trace here. Once completed, the credit is the one for what arrived, and metadata keeps receivedAmount and the credit first quoted (quotedCredit).
/v1/deposits/:idAUTHEdit a deposit that has not been sent to a partner yet. Only while it is a draft: once a network is chosen the partner is looking at an address and an exact amount, and may already be sending it. Changing the fiat side re-quotes, so the crypto figure follows and the quote window restarts.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| partnerRef | string | no | Your own id for the partner being credited. |
| fiatAmount | number | no | New amount to credit. Triggers a re-quote. |
| creditCurrency | enum | no | Currency the balance is denominated in. |
| asset | enum | no | USDT | USDC | DAI | BUSD |
| returnUrl | string | no | Where the partner goes once it is credited. |
| metadata | object | no | Merged into the existing metadata. |
/v1/deposits/:idAUTHWithdraw a deposit. A draft is removed outright - nothing was handed out, so nothing can arrive against it. Anything with an address issued is expired instead and stops being watched, because that address is already in your partner's hands: a transfer that crosses with the cancellation shows up in the unmatched queue rather than disappearing. A deposit already confirming or completed is refused.
Example response
{
"success": true,
"data": {
"id": "630eda47-0eef-47bd-8af0-afcc9937a1ec",
"deleted": false,
"status": "expired"
}
}/v1/deposits/statsAUTHTotals across your deposits. Volume is in the stablecoin rather than fiat - partners may be credited in several currencies at once. Counted over completed deposits only: a pending one is an invitation, not money. successRate is null until at least one deposit reaches an end state, and excludes those still waiting.
Example response
{
"success": true,
"data": {
"volume": 12480.5,
"completed": 37,
"awaiting": 2,
"unpaid": 5,
"successRate": 88.09523809523809
}
}/v1/deposits/unmatchedAUTHArrivals that matched no open deposit - a deposit's address paid after the deposit expired or was cancelled, or paid a second time. A short transfer to an open deposit does not land here: it settles, credited for what arrived. The money is already yours; this is the queue for deciding whose it is.
/v1/deposits/unmatched/:id/attributeAUTHCredit an unmatched arrival to one of your deposits, or dismiss it. Settles through the same path an automatic match takes, so your webhook fires exactly as it would have - a reviewed deposit is not a second kind of event to handle.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| sessionId | uuid | no | The deposit this arrival belongs to. Pass null to dismiss it as not yours. |
| note | string | no | Why - kept on the audit trail. |
/v1/public/deposit/:idWhat the deposit page renders. Public - possession of the unguessable id is the authorization, same as a checkout session. Carries only what a payer needs: it does not name your other addresses or the partner's balance.
Where deposits collect, who may move them, and how they reach your own address. Dashboard-session endpoints: your integration does not need them, but everything here is also what the Approvals & Logs page does.
Your wallet, your signers. Each account with deposits has a receiving wallet of its own: the keys live in a hardware-isolated enclave and never leave it, and PayZap's own key inside that wallet is allowed exactly four things - issue a deposit address, sweep a deposit address into the treasury, pay the treasury out to the address you pinned, and e-mail a signer a code. Everything else - who the signers are, how many must agree, where payouts go - belongs to the signers.
Signing with a code. A signer is an e-mail address. To act, a signer names the action, receives a one-time code at that address, and enters it; the code unlocks a key for that signer, for that one action, for minutes. The e-mail with the code is not sent by PayZap and contains nothing else - so nobody, PayZap included, can act as a signer without reaching their mailbox. With a threshold above one, the first signature parks the action and the other signers are e-mailed; the action happens when enough have approved, and expires after a day. The first signer's signature is what the others sign against, so a signer who submitted an action must not be removed before it completes.
Signers need a seat. Codes are entered on the Approvals & Logs page of this account, so a signer needs to be a member of it - any role will do; a new signer is invited as a viewer automatically. A person can be a member of several accounts and switch between them in the dashboard.
Payouts. The payout address per chain family is pinned inside the wallet, so PayZap can move the treasury there and nowhere else - a stale or wrong address on our side is a refused signature, not a misdirected payout. Changing it is a signed action like any other. The schedule - manual, daily or weekly at an hour, with a minimum worth moving - and "pay out now" are PayZap's to run but are signed for with a code too. Scheduled runs honour the minimum; a payout on demand moves whatever is there. Each payout moves the whole USDT balance of the treasury on that network; gas on EVM and energy on Tron are arranged by PayZap.
For real money. Keep the threshold at two or more, add a signer who is not on PayZap's side, and read the wallet's own log rather than ours when it matters: /v1/merchant/signers/history is kept outside our database and cannot be edited from here.
/v1/merchant/signersAUTHWho signs for the account's receiving wallet and how many of them must: the signers, the threshold, the actions still waiting for signatures - each saying whether you have already signed it and whether it can still be signed - and the code you have requested but not yet entered. Dashboard session; any role.
Example response
{
"success": true,
"data": {
"owners": [
{ "userId": "63a0…", "userName": "admin ops@acme.example", "userEmail": "ops@acme.example" },
{ "userId": "f859…", "userName": "owner cfo@acme.example", "userEmail": "cfo@acme.example" }
],
"threshold": 2,
"pending": [
{
"fingerprint": "sha256:5fc2…",
"title": "Owners set to 1 with threshold 1 of 1",
"status": "consensus-needed",
"initiatedBy": "ops@acme.example",
"votes": [{ "who": "ops@acme.example", "selection": "approved", "at": "2026-09-15T21:57:07.000Z" }],
"signedByMe": true,
"stale": false
}
],
"myRequest": null
}
}/v1/merchant/signers/requestAUTHName an action and ask for the code that signs it. The action is checked first - an address that is not one, a threshold nobody could reach, a proposal already signed by you - and refused in words before any code goes out. Then a one-time code is emailed to the address you are signed in with, and the code is good for that one action. Signers, threshold, payout address and schedule need settings:write; paying out needs money:write; approving and rejecting need only to be a signer.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| kind | enum | yes | add-owner | remove-owner | set-threshold | approve | reject | set-payout-address | set-payout-schedule | payout-now |
| string | no | add-owner: the new signer's e-mail. They are invited to this account as a viewer so they can sign here. | |
| threshold | number | no | add-owner (optional) and set-threshold: how many signatures every change needs, 1..N. |
| userId | uuid | no | remove-owner: the signer to remove, from /v1/merchant/signers. The last one cannot be removed. |
| fingerprint | string | no | approve | reject: the waiting action, from the pending list. |
| chain | enum | no | set-payout-address: "evm" | "tron". payout-now (optional): limit the run to one chain family. |
| address | string | no | set-payout-address: where the treasury is paid out to on that chain family. One EVM address serves every EVM network. |
| schedule | object | no | set-payout-schedule: { interval: "manual" | "daily" | "weekly", hourUtc: 0..23, weekday: 0..6 (Sunday = 0, weekly only), minAmount: USDT below which a scheduled run leaves the treasury alone } |
Example response
{
"success": true,
"data": {
"sentTo": "ops@acme.example",
"summary": "Set the EVM payout address to 0x53ae…25ae"
}
}/v1/merchant/signers/confirmAUTHEnter the code. The action named at request time is carried out with a key that the code unlocks for the signer alone; the platform never holds it beyond the moment. "completed" means done. "consensus-needed" means your signature is in and the other signers have been emailed; nothing changes until enough of them approve, within a day. "failed" carries the wallet's reason.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| code | string | yes | The 6-digit code from the e-mail. Valid for ten minutes, once. |
Example response
{
"success": true,
"data": {
"status": "consensus-needed",
"summary": "Set the EVM payout address to 0x53ae…25ae",
"fingerprint": "sha256:9a1b…",
"waiting": "the new payout address",
"failure": null
}
}/v1/merchant/signers/historyAUTHThe wallet's own log, in words, newest first: every address issued, every sweep and payout signed, every signer added, every code sent and entered. Kept outside our database, so it is the record that cannot be edited from here.
Query params
| Param | Type | Description |
|---|---|---|
| limit | number | 1-200 (default: 50) |
/v1/merchant/payoutsAUTHWhere the treasury is paid out to per chain family - read back from the wallet's own policy, so a change that has just collected its last signature shows up here - alongside the treasury addresses, the schedule, whether a payout pass is running right now, and the history of payouts with their transaction hashes.
Example response
{
"success": true,
"data": {
"config": {
"addresses": { "evm": "0x53ae…25ae" },
"interval": "weekly", "hourUtc": 9, "weekday": 1, "minAmount": 100,
"lastRunAt": "2026-09-17T11:33:28.371Z", "updatedBy": "ops@acme.example"
},
"treasury": { "evm": "0x6462…4e69", "tron": "TTb7…JBHN" },
"run": null,
"payouts": [
{
"id": "…", "chain": "polygon", "asset": "USDT", "rawAmount": "500000",
"fromAddress": "0x6462…4e69", "toAddress": "0x53ae…25ae",
"txHash": "0xcfa2…55d9", "status": "confirmed", "failure": null,
"initiatedBy": "ops@acme.example", "createdAt": "2026-09-17T11:33:28.280Z", "confirmedAt": "2026-09-17T11:33:41.000Z"
}
]
}
}/v1/merchant/eventsAUTHEverything anybody changed in this account from the dashboard, newest first: who, what, and when - in words, without secrets. This is the account's own log; the wallet's signatures are in /v1/merchant/signers/history.
Query params
| Param | Type | Description |
|---|---|---|
| group | string | team | api_key | wallet | exchange | payment_config | refund_delegation | webhook | product | branding | plan | signing | other |
| limit | number | 1-500 (default: 100) |
How a fiat figure becomes a crypto figure, and back. Applies wherever a currency is named: checkout sessions, mandate charges, and deposit credits.
Currencies. USD, EUR, AED, BRL, BOB, VES, ARS, TRY, LBP. USD settles 1:1 with USDT and USDC and takes no FX step at all. The same list applies to checkout, mandate charges and deposits.
Where the rate comes from. Per currency, and named on every quote in fxSource. Currencies whose official rate tracks reality use a reference feed (er-api). Currencies whose official rate is fiction use the real crypto market instead (binance-p2p) - for VES the official rate sits 13% away from what a dollar actually trades at, and BOB 3%. Each currency has exactly one source: if it is unreachable we serve a recent cached rate, and failing that we return an error rather than quote from a source we know to be wrong for that currency.
Which side of the market. The bid - what a holder receives for selling USDT, not what a buyer pays to acquire it. In both directions you end up holding crypto and turning it into local currency, so the bid is what it is actually worth to you.
Locking. The rate is fixed when the session or deposit is created and stored on it, so the number quoted is the number settled whenever the money lands. On a checkout it is returned as fxRate with fiatAmount, fiatCurrency, fxSource and fxLockedAt; on a deposit it is the credit block.
Spread. One agreed per-account number, applied to the reference rate, and the only margin taken anywhere in the path - the rate itself is chosen to be accurate, not favourable. Default 100 bps. Every quote is written to an audit log with the raw rate, the spread and the result, so any settlement figure can be traced back to the market at that moment.
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 & Token | Mechanism | Endpoint |
|---|---|---|
| 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 USDC | Sponsored (bridged USDC has no permit) | /sponsor-gas |
| TRON USDT / USDC | Energy + bandwidth rented via TronZap and delegated to buyer | /delegate-energy |
| Solana USDC / USDT | Facilitator 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).
/v1/public/session/:id/permit-dataBuild 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
| Field | Type | Req | Description |
|---|---|---|---|
| buyer | address | yes | Buyer wallet address (0x…) |
/v1/public/session/:id/permitSubmit a signed EIP-2612 permit. Backend enqueues `permit() + transferFrom()` for on-chain settlement. Public - no auth.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| owner | address | yes | Buyer address (must match the signer) |
| signature | hex string | yes | EIP-712 signature |
/v1/public/session/:id/sponsor-gasSend 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
| Field | Type | Req | Description |
|---|---|---|---|
| buyerAddress | address | yes | Buyer wallet address |
/v1/public/session/:id/delegate-energyRent ~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
| Field | Type | Req | Description |
|---|---|---|---|
| buyerAddress | string | yes | Buyer TRON address (T…) |
/v1/public/tron-energy-estimateQuote-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
| Param | Type | Description |
|---|---|---|
| buyerAddress | string | Buyer TRON address |
| merchantWallet | string | Merchant TRON address |
/v1/public/session/:id/solana/sponsor-txBuild 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
| Field | Type | Req | Description |
|---|---|---|---|
| senderAddress | string | yes | Buyer Solana address (base58) |
Example response
{
"success": true,
"data": {
"serializedTx": "base64...",
"feePayer": "Ba3jY..."
}
}Share a hosted payment page with your customers. Supports query parameters for customization.
Every product gets a payment link at https://payzap.cc/pay/<product_id>. Customize the checkout experience with query parameters:
| Param | Type | Description |
|---|---|---|
| success_url | string | Redirect URL after successful payment (overrides product setting) |
| amount | number | Preselects the amount on a tip / donation product. Ignored on a fixed-price product: a per-order price comes from a session your server creates with its API key |
| ref | string | Customer reference / order ID |
| theme | string | UI 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.
Your server sets the price - a cart, a discount, a ride - and PayZap hosts the payment for exactly that amount.
1. Create the session on your server with your API key. The amount, token and network are fixed here; the buyer cannot change them.
POST /v1/payments/session
Authorization: Bearer sp_live_...
{
"productId": "prod_abc123",
"chain": "evm",
"network": "polygon",
"asset": "USDT",
"amount": 2.99,
"orderRef": "order_4417",
"gasless": true // optional: the buyer needs no POL for gas
}
→ { "data": { "id": "…", "checkoutUrl": "https://payzap.cc/pay/s/…", … } }2. Send the buyer to checkoutUrl, or embed it. The page pays this one session: the buyer connects a wallet and confirms; it never opens another session or asks for a different price. A buyer coming back from a mobile wallet lands on the payment's current state. The presentation params of payment links apply here too.
<iframe src="https://payzap.cc/pay/s/<id>?layout=inline&theme=light&accentColor=1a1a18" style="width:100%;height:560px;border:0" ></iframe>
3. Fulfil from the payment.completed webhook. Paying through this page declares the buyer's wallet and transaction, so two orders at the same price are told apart: attribution.method is "tx" or "payer". A transfer sent by hand matches on amount and is marked "amount", with ambiguous: true when another open payment fitted too.
Running your own wallet UI instead? Call POST /v1/public/session/:id/payer when the wallet connects and /tx when it returns the transaction - see Payments. Links of the form /pay/<productId>?session=<id> now redirect to this page.
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', // charged at the product price
onSuccess: (data) => {
console.log('Paid!', data.id, data.txHash);
},
onError: (err) => {
console.error(err);
},
onCancel: () => {
console.log('Widget closed');
}
});B2B top-ups
The same widget runs a partner top-up, so the flow finishes inside your interface rather than sending them to a hosted page. It takes a deposit id, never an amount: opening a deposit needs an API key, which belongs on your server. Your page asks your own backend for one, then hands the id over.
<script src="https://payzap.cc/v1.js"></script> <button data-payzap-deposit="<deposit id>"> Top up with crypto </button>
Or programmatically, which is what you want when the id is fetched on demand:
const { id } = await yourBackend.openDeposit({ fleetId, amount: 5000 });
PayZap.openDeposit({
depositId: id,
onSuccess: (d) => {
// For your UI only - this runs in the partner's browser. Credit the
// balance from the payment.completed webhook on your server, which
// carries the same credit block, for what actually arrived.
showToppedUp(d.credit.amount, d.credit.currency);
},
});The partner chooses the network in the widget, since only they know whether they are sending from an exchange or their own wallet. That choice is also what issues the address they send to.
Content Security Policy. If your page sets one, it must allow our API in connect-src and the script host in script-src. A blocked request looks like a widget that opens and then reports an error, with the real cause only in the browser console - worth checking first if that is what you see.
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.
Build your own payment UI using the PayZap API. Two endpoints are all you need.
Flow
POST /v1/payments/session - you get the merchantWallet, amount, and assetmerchantWallet using your UI (wagmi, ethers, viem, TonConnect, etc.)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',
metadata: { network: 'polygon', chainId: 137 }, // the EVM network to pay on
customerRef: 'order_789', // optional: your internal reference
}),
});
// The price is the product's. To price per order, create the session on
// your server with your API key and pass amount - see Dynamic pricing.
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
| Status | Description |
|---|---|
| pending | Waiting for payment |
| confirming | Transaction detected, waiting for block confirmations |
| completed | Payment confirmed on-chain |
| expired | Session timed out (30 min) |
| failed | Transaction 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.
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.
/v1/reports/dailyAUTHThe 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
| Param | Type | Description |
|---|---|---|
| date | YYYY-MM-DD | A whole UTC day. UTC because both sides have to agree which day a row belongs to. |
| from / to | ISO 8601 | An arbitrary window instead of a calendar day |
| format | enum | "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"
}
]
}/v1/reports/monthlyAUTHPeriod 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
}
}Get real-time notifications when payment events occur. Webhooks are signed with HMAC-SHA256 for verification.
Events
| Event | Description |
|---|---|
| payment.pending | Transfer detected on-chain, waiting for required confirmations. Carries attribution and payer, as payment.completed does. |
| payment.completed | Payment fully confirmed on-chain. attribution says how the transfer was tied to this payment: "tx" or "payer" - the checkout declared that transaction or that paying wallet, so it is this order's money; "amount" - it fitted on wallet, amount and asset alone. With "amount" and ambiguous: true another open payment fitted as well: do not treat it as proof of which order was paid. payer is the sending wallet when the chain says. For a B2B deposit the same event carries origin: "deposit", customer_ref (your partnerRef), the amount that actually arrived, and - when the deposit was quoted in a local currency - a credit block for that amount at the rate locked when it opened. Credit the balance from this event: it is the one signed by us and sent to your server. Route it to a balance top-up rather than an order.{
"event": "payment.completed",
"payment_id": "9f2c1d5e-…",
"product_id": null,
"origin": "deposit",
"customer_ref": "fleet_4417",
"amount": "500",
"asset": "USDT",
"chain": "evm",
"tx_hash": "0x…",
"tx_explorer_url": "https://polygonscan.com/tx/0x…",
"merchant_wallet": "0x37c7…411a",
"credit": { "amount": 5702.74, "currency": "BOB", "rate": 11.4048, "lockedAt": "2026-08-13T22:21:52.791Z" },
"attribution": { "method": "amount", "ambiguous": false },
"payer": "0x9a1e…77c0",
"metadata": { "network": "polygon", "receiverKind": "derived", "requestedAmount": 500, "receivedAmount": 500, "quotedCredit": 5702.74 }
} |
| payment.failed | Payment failed or was rejected |
| payment.expired | Session expired without payment |
| refund.completed | Refund settled on-chain (any mode: EVM permit/sponsored, Tron, Solana). Payload includes refund_id, refund_amount, refund_tx_hash. |
| refund.failed | Refund settlement failed permanently (verification mismatch, on-chain revert, etc). Payload includes refund_reason populated from the chain-side check. |
| mandate.activated | A customer now has an active authorization - first bind, or a fresh one after a previous authorization ended. |
| mandate.revoked | The authorization ended. reason names who ended it: user (cancelled in the widget) or api (you called revoke). An approval withdrawn at its source - cleared in the wallet, cancelled in the Binance app - ends only that method, not the authorization: see mandate.method_changed. onchain appears only on authorizations that ended before 23 September 2026. |
| mandate.expired | A setup link lapsed before the customer finished binding. Applies to pending mandates only - an active authorization does not expire on its own. |
| mandate.method_changed | The customer swapped the instrument behind an active authorization - a different wallet, or an exchange account instead of a wallet. It also fires when an instrument stops working at its source (the approval cleared in the wallet, the contract cancelled in the Binance app): that method is retired and nothing else is touched. The mandate itself does not change status; this is how you learn the backing method moved. change says what happened (added, switched, removed, none_active) and method carries the instrument that is live afterwards, with displayName ready to print - null when nothing is active, which means the next charge will fail until the customer chooses. No second call is needed to find out what is now being charged.{
"event": "mandate.method_changed",
"data": {
"id": "9f2a4c18-...",
"customerRef": "rider_7",
"status": "active",
"change": "switched",
"hasActiveMethod": true,
"method": {
"id": "3d91b7e0-...",
"kind": "evm_wallet",
"displayName": "MetaMask",
"isActive": true,
"network": "polygon",
"tokenSymbol": "USDT",
"tokenDecimals": 6,
"customerWallet": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"capUnits": "5000000000",
"revokedAt": null
}
}
}
// method is null when change is "none_active".
// remainingUnits is not here: it costs a chain read, and this event
// fires on a swap, not on spending. GET /v1/mandates/:id carries it. |
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');
}/v1/webhooksAUTHCreate a webhook endpoint. Takes an API key, so an integration can set up its own notifications without a trip through the dashboard. The secret is returned only once - store it securely, it is what verifies our signature. URLs pointing at private or internal networks are refused.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| url | string | yes | HTTPS endpoint URL |
| events | string[] | no | Event filter (default: all events) |
Example response
{
"success": true,
"data": {
"id": "whk_...",
"url": "https://example.com/webhook",
"events": ["payment.completed", "payment.failed"],
"secret": "whsec_..."
}
}/v1/webhooksAUTHList your webhook endpoints.
/v1/webhooks/:idAUTHUpdate a webhook URL or event filter.
/v1/webhooks/:idAUTHDelete a webhook endpoint.
/v1/webhooks/:id/testAUTHSend a test webhook event to verify your endpoint.
Manage your merchant profile, wallets, and API keys.
/v1/merchantAUTHGet your merchant profile, wallets, and usage.
/v1/merchant/walletsAUTHList your connected wallets.
/v1/merchant/walletsAUTHAdd a new wallet address.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| chain | enum | yes | "evm" | "ton" | "tron" | "solana" |
| address | string | yes | Wallet address |
/v1/merchant/wallets/:idAUTHRemove a wallet.
/v1/merchant/api-keysAUTHCreate an API key. The raw key is returned only once.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| name | string | no | Label for this key |
| allowedIps | string[] | no | Addresses or CIDR blocks the key may be used from, e.g. ["203.0.113.7", "198.51.100.0/24"]. Omit or send null for no restriction. Max 50 entries; IPv4 and IPv6. |
/v1/merchant/api-keys/:idAUTHChange where an existing key may be used from, without reissuing it. Use this when your servers move.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| allowedIps | string[] | null | yes | The new list. null lifts the restriction; an empty array keeps the key and allows it nowhere. |
/v1/merchant/api-keysAUTHList API keys (without secret values).
/v1/merchant/api-keys/:idAUTHRevoke an API key.
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.
/v1/merchant/payment-configAUTHGet your enabled payment methods and EVM networks, and the state of B2B deposits: whether they are switched on, whether they are active (the receiving wallet has been set up), and the treasury address per chain that deposits are collected into.
Example response
{
"success": true,
"data": {
"enabledMethods": ["evm", "ton", "binance_pay"],
"evmNetworks": ["ethereum", "base", "arbitrum"],
"depositsEnabled": true,
"depositsActive": true,
"treasury": { "evm": "0x...", "tron": "T..." }
}
}/v1/merchant/payment-configAUTHUpdate enabled payment methods and EVM networks.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| enabledMethods | string[] | yes | "evm" | "ton" | "tron" | "solana" | "binance_pay" | "bybit_pay" |
| evmNetworks | string[] | no | "ethereum" | "base" | "arbitrum" | "polygon" | "bsc" | "optimism" |
Connect Binance Pay or Bybit Pay to accept payments via exchange checkout. Customers pay using their exchange app.
/v1/merchant/exchange-credentialsAUTHList your exchange API credentials (secrets are masked).
Example response
{
"success": true,
"data": [
{
"id": "...",
"provider": "binance_pay",
"apiKey": "abc***xyz",
"merchantIdExt": "123456",
"active": true
}
]
}/v1/merchant/exchange-credentialsAUTHAdd or update exchange API credentials. Required to accept Binance Pay or Bybit Pay. Binance mandates (Direct Debit) also need three things on Binance's side: Direct Debit enabled for your merchant account (Binance whitelists it through merchant@binance.com); if the API key is restricted by IP, our server addresses on it (ask your PayZap contact); and https://api.payzap.cc/v1/webhooks/binance-pay as the webhook URL in the Binance merchant portal. Without that webhook a signature is still picked up seconds later, but a contract cancelled in the Binance app is only noticed at the next charge.
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| provider | enum | yes | "binance_pay" | "bybit_pay" |
| apiKey | string | yes | Exchange API key |
| apiSecret | string | yes | Exchange API secret |
| merchantIdExt | string | no | Binance merchant ID (required for Binance Pay) |
/v1/merchant/exchange-credentials/:providerAUTHRemove exchange credentials. Provider: "binance_pay" or "bybit_pay".
All errors follow a consistent format. HTTP status codes are used meaningfully.
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "Product not found"
}
}| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | Invalid request body or params |
| 401 | UNAUTHORIZED | Missing or invalid credentials |
| 403 | FORBIDDEN | Credentials are valid but not allowed to do this - an API key used from an address outside its allow-list, or a dashboard role without the right |
| 404 | NOT_FOUND | Resource does not exist |
| 409 | CONFLICT | Conflicts with something that already exists |
| 429 | RATE_LIMITED | Too many requests |
| 500 | INTERNAL_ERROR | Server error |
Integrate AI agent payments via the HTTP 402 protocol.
x402 Documentation