# BitPal Docs — full text > All BitPal developer documentation pages in one file, generated from https://bitpal.io/docs/. > Index: https://bitpal.io/llms.txt Non-custodial crypto checkout # Accept crypto. Get paid directly. Each order gets its own deposit address. The buyer sends stablecoins from any wallet or exchange — no wallet connection — and funds settle straight to you. No escrow, no custody. > **New here?** Take a test payment end to end in about ten minutes → [Quickstart](/docs/md/quickstart.md). ## Three ways to integrate - **SDK / hosted checkout** — create a session in code, redirect the buyer to the returned URL. → [SDK](/docs/md/sdk.md) - **Payment links** — no code. Create a link, share the URL, get paid. → [Payment links](/docs/md/links.md) - **x402** — charge per API call. The caller pays with a signed authorization and retries, no page involved. → [x402](/docs/md/x402.md) ## What you can accept | Token | Networks | | --- | --- | | **USDC** | Base · Arbitrum · Solana | | **USDT** | Tron · BNB (BSC) · Solana | | **JPYC** | Polygon · Kaia | Same-chain, same-token — no bridging or swapping. Full identifiers & decimals in [Chains & contracts](/docs/md/reference.md). ## How a payment flows 1. You create a session (or link) with an amount. 2. Buyer opens the hosted page, picks a network, sends the exact amount to a unique address. 3. BitPal detects & confirms it, settles to you, and sends a `checkout.session.paid` webhook. --- Source: https://bitpal.io/docs/#overview · Markdown: https://bitpal.io/docs/md/overview.md # Quickstart Take one test payment end to end: create a session, pay it on a testnet, and receive the signed webhook. About ten minutes. ## Before you start 1. **Console account in test mode.** Everything below uses testnets and test keys — no real funds. 2. **Payout wallet.** Console → Settings → **Payout Wallet**. Each deposit address is committed to this wallet, so a session cannot issue an address without one. Register an address for every network family you accept (EVM `0x…`, Tron `T…`, Solana) — see **Payout wallets** below. 3. **Test API key.** Console → Developers → **API Keys** → create a `bp_test_…` key. It is shown once. Keep it on your server — never in a browser or app bundle. 4. **A testnet wallet with a little gas** (for example Base Sepolia ETH) to send the payment. The checkout page's test faucet mints test tokens, not gas. ## Payout wallets Payments settle straight to your own wallet — BitPal never holds them. You register one payout address per **network family** (VM) in **Console → Settings → Payout Wallet**. There is no API for this; it is console-only. | Network family | Address to register | Used for | | --- | --- | --- | | EVM | One `0x…` address | Every EVM network you accept (Base, Arbitrum, BNB Chain, Polygon, Kaia …) — the same address works on all of them. | | Tron | A base58 address starting with `T…` | Tron payments. | | Solana | Your **wallet (owner) address** — not a token account (ATA) | Solana payments. | - **Test and live are separate.** Wallets are registered per mode: switch the console to TEST to register test payout wallets, and to LIVE for live ones. A test wallet is never used for live payments, or the other way round. - **A family without a wallet is not offered.** If the buyer can choose, networks of a family with no payout wallet in that mode are left out of the payment options. If you fix the network with `pay_chain` (or a deposit address is requested on it), the call fails with `400 SELLER_WALLET_NOT_CONFIGURED`. There is no fallback to another family or another wallet. A session created without `pay_chain` needs at least one payout wallet (any family) in that mode — if `allowed_pay_chains` / `allowed_assets` are set, a wallet for one of the listed networks' families. Without any network restriction, a session offers Base USDC if you have an EVM wallet, otherwise the networks of the families you registered. - **Changing a wallet** applies to deposit addresses issued afterwards. An address that was already issued is committed to the payout wallet of that moment (the commitment is part of the address), so a payment to it still settles there. ## 1. Create a checkout session Install the SDK, or call the REST API directly. ``` npm install @bitpal/checkout ``` ``` import { BitPal } from '@bitpal/checkout'; const bitpal = new BitPal(process.env.BITPAL_API_KEY); // bp_test_… const { data: session } = await bitpal.checkout.createSession({ line_items: [{ name: 'Pro plan', amount: '29', currency: 'USDC' }], pay_chain: 'eip155:84532', // Base Sepolia (test key → testnet values only) expires_in_minutes: 30, // payment window, 1–60 (default 15) success_url: 'https://your.store/thanks?session={CHECKOUT_SESSION_ID}', }); // store session.id with your order, then send the buyer to session.checkout_url ``` The same request over HTTP: ``` curl -X POST https://api.bitpal.io/v1/checkout/sessions \ -H "Authorization: Bearer $BITPAL_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1024" \ -d '{ "line_items": [{ "name": "Pro plan", "amount": "29", "currency": "USDC" }], "pay_chain": "eip155:84532", "expires_in_minutes": 30, "success_url": "https://your.store/thanks?session={CHECKOUT_SESSION_ID}" }' ``` Response `201`: ``` { "data": { "id": "cs_Q2FxKhomCEju_OqvKfrQsTOh", "checkout_url": "https://bitpal.io/pay/cs_Q2FxKhomCEju_OqvKfrQsTOh?token=", "session_token": "", "status": "created", "amount_total": "29000000", "currency": "USDC", "expires_at": "2026-11-06T07:58:28.776Z", "tax": { "tax_amount": "0", "total_with_tax": "29000000" } }, "meta": { "mode": "TEST", "requestId": "ec091faf-…" } } ``` - `amount` is a plain number string in the line item's currency — no decimals to compute. `amount_total` comes back as a 6-decimal integer string (`"29000000"` = 29). - **Minimum:** the session total (sum of `amount × quantity`) must be at least **1.00 in the session currency** ($1 for USDC/USDT, ¥1 for JPYC), otherwise `400 CHECKOUT_AMOUNT_TOO_LOW`. Some networks have a higher minimum — Tron currently $300 (testnet included), JPYC ¥100 on every network; such a network is not offered for a smaller total (or `400 CHECKOUT_BELOW_MIN_PAYMENT` if you fixed it with `pay_chain`). - `Idempotency-Key` (8–128 chars) makes retries safe: the same key and body within 24 hours returns the original response with `X-Idempotency-Replayed: true`. - `{CHECKOUT_SESSION_ID}` in `success_url` is replaced with the session id when the buyer returns. ## 2. Send the buyer to the checkout page Redirect to `checkout_url`, or open it in a popup so the buyer stays on your page (see [Popup & headless](/docs/md/flows.md)). On the page the buyer: 1. picks a network and token (if you allowed more than one), 2. enters a **refund address** — over/under-payments are returned there, 3. gets a one-time **deposit address** and the exact amount, and sends it from any wallet or exchange. In test mode the page shows **Get test assets**: it mints test tokens to the buyer's wallet so you can complete a real on-chain payment. > **No testnet wallet?** Console → Developers → **Sandbox** creates a session and sends the deposit for you — pay in full, underpay, overpay or send the wrong token — and shows every webhook event as your endpoint receives it. Test mode only; 10 simulations per minute and 300 per day per account. ## 3. Receive the webhook Console → Developers → Webhooks → **Add Endpoint**. The URL must be publicly reachable over HTTPS (for local development use a tunnel such as ngrok or cloudflared). Copy the signing secret (`whsec_…`) — it is shown once. ``` import express from 'express'; import { verifyWebhookSignature, parseWebhookEvent, WEBHOOK_HEADERS } from '@bitpal/checkout'; const app = express(); app.post('/webhooks/bitpal', express.raw({ type: 'application/json' }), async (req, res) => { const raw = req.body.toString('utf8'); // exact bytes BitPal signed const ok = await verifyWebhookSignature( // async — always await it raw, req.header(WEBHOOK_HEADERS.signature) ?? '', process.env.BITPAL_WEBHOOK_SECRET, // whsec_… { timestamp: req.header(WEBHOOK_HEADERS.timestamp) ?? '' }, ); if (!ok) return res.status(400).send('invalid signature'); const event = parseWebhookEvent(raw); if (await alreadyProcessed(event.id)) return res.sendStatus(200); // at-least-once delivery if (event.event === 'checkout.session.paid') { await fulfilOrder(event.data.session_id, { late: event.data.late === true }); } await markProcessed(event.id); res.sendStatus(200); // any 2xx within 10 s = delivered }); ``` For one successful payment you receive, in order: `checkout.session.created` → `checkout.session.deposit_detected` → `checkout.session.paid` → `checkout.session.settled`. `deposit_detected` is not guaranteed: `paid` can arrive without it (a late payment, or a deposit that was already fully confirmed when BitPal first saw it), so never wait for it before fulfilling. Use **Send Test** in the console to check your endpoint before paying. ## 4. Fulfil on `checkout.session.paid` - `paid` means the deposit reached the required confirmations — safe to fulfil. `settled` follows when funds are swept to your payout wallet. - Treat the webhook (or a server-side `getSession(id)`) as the source of truth. The redirect to `success_url` and popup messages are hints for your UI only. - Check `data.late`: `true` means the full amount arrived after the payment window (accepted for up to 7 days). Decide whether to fulfil or refund it from the console — or create sessions with `"late_payments": "refund"` so late payments are refunded automatically instead ([Payment lifecycle](/docs/md/lifecycle.md)). ## Going live 1. Create a `bp_live_…` key and switch chain values to mainnet ([Chains & contracts](/docs/md/reference.md)). 2. Register live payout wallets — test and live are separate environments. 3. Add the webhook endpoint again in live mode. It gets its own `whsec_…` secret. 4. Handle the non-happy paths: `checkout.session.unresolved`, `checkout.session.expired`, `checkout.refund.recorded` and late payments ([Payment lifecycle](/docs/md/lifecycle.md)). --- Source: https://bitpal.io/docs/#quickstart · Markdown: https://bitpal.io/docs/md/quickstart.md # SDK Create checkout sessions from your backend and redirect buyers to the hosted page. ## Install & create a session ``` npm install @bitpal/checkout ``` ``` import { BitPal } from '@bitpal/checkout'; const bitpal = new BitPal(process.env.BITPAL_API_KEY); // bp_test_… or bp_live_… // A CAIP-2 id is absolute: 'eip155:8453' (Base mainnet) works only with a live key — // with a test key it fails with CHAIN_MODE_MISMATCH. Use 'eip155:84532' for test, or a short name. const { data: session } = await bitpal.checkout.createSession({ line_items: [{ name: 'Pro plan', amount: '29', currency: 'USDC' }], pay_chain: 'base', // short name → Base Sepolia with bp_test_, Base with bp_live_ expires_in_minutes: 30, // payment window 1–60, default 15 success_url: 'https://your.store/thanks?s={CHECKOUT_SESSION_ID}', }); redirect(session.checkout_url); ``` ## Amounts — just pass the number Set `amount` to a plain number (`"29"`, `"0.5"`) in the line item's `currency`. No decimals to compute. > **You never compute token decimals.** The amount is quoted in the line item's `currency` — `USDC`/`USDT` mean USD, `JPYC` means JPY (no FX conversion). It is independent of chain/token decimals: USDC (6dp) on Base or USDT (18dp) on BNB take the same `amount`, and BitPal rescales at deposit time. (Already have 6-decimal integers? Pass `amount_atomic` instead — the two are mutually exclusive.) ## Example: buyer chooses chain & token ``` await bitpal.checkout.createSession({ line_items: [{ name: 'Order #1024', amount: '49.90', currency: 'USDC' }], // currency = price denomination allowed_assets: [ // (chain, token) pairs — the buyer picks one; mix tokens freely { chain: 'base', token: 'USDC' }, { chain: 'arbitrum', token: 'USDC' }, { chain: 'tron', token: 'USDT' }, { chain: 'solana', token: 'USDC' }, ], // shorthand for one token across chains: allowed_pay_chains: ['base','arbitrum','solana'] }); ``` > **Payment options** `allowed_assets` (chain×token pairs) is the standard way — the buyer picks any listed pair. `chain` takes a short-name (`'base'`, `'tron'`, `'solana'`) or a CAIP-2 id and resolves to your key's mode (test/live). Shorthands: `allowed_pay_chains` (one token across chains) and `pay_chain` (single fixed chain). `allowed_assets` takes precedence. ## Example: poll status ``` const { data } = await bitpal.checkout.getStatus(sessionId); if (data.status === 'paid_confirmed' || data.status === 'swept') showSuccess(); // UI only — fulfil from the webhook ``` Status values: `created` → `awaiting_deposit` → `deposit_detected` → `paid_confirmed` → `swept`, plus `expired`, `unresolved_underpaid`, `wrong_token`. (Sessions opened through the legacy wallet-connect flow may report `pending_payment`; treat it like `created`.) See [Payment lifecycle](/docs/md/lifecycle.md). ## Method reference | Method | Purpose | | --- | --- | | `createSession(params)` | Create a session → `checkout_url`. | | `getSession(id)` | Full session (authenticated). | | `getPublicSession(id)` | Buyer-facing: payment options, status. | | `getStatus(id)` | Lightweight status for polling. | | `previewFee(amount)` | Fee preview before charging. | | `products` / `prices` | Catalog: reusable products & prices (for payment links). | | `paymentLinks` | Create & manage shareable payment links from a price. | Amount helpers (advanced, for `amount_atomic`): `toAtomic(v, decimals)` / `fromAtomic(v, decimals)`. --- Source: https://bitpal.io/docs/#sdk · Markdown: https://bitpal.io/docs/md/sdk.md # Popup & headless Choose how the buyer sees the payment. Every flow ends the same way: you fulfil from the webhook. ## Choose a flow | Flow | Buyer experience | You build | | --- | --- | --- | | **Redirect** | Leaves your site for the hosted page, returns to `success_url`. | Create a session, redirect. Simplest. | | **Popup** | Hosted page in a small window; your page stays open. | Open the window, listen for one message. | | **Headless** | Your own payment UI: options, address, QR, status. | A few backend endpoints plus your UI. | | **Payment link** | A shareable URL; no code. → [Payment links](/docs/md/links.md) | Nothing. | | **x402** | No page — an API client pays per call. → [x402](/docs/md/x402.md) | Middleware on the route. | ## Popup Browsers block `window.open` unless it runs directly in the click handler. Open an empty window first, then point it at the checkout URL once your backend has created the session. ``` async function onPayClick() { // 1) open synchronously, inside the click handler const popup = window.open('', 'bitpal_checkout', 'width=460,height=720'); // 2) your backend creates the session (secret key stays on the server) const { checkout_url, session_id } = await fetch('/api/checkout', { method: 'POST' }).then((r) => r.json()); // 3) navigate the popup — or fall back to a full redirect if it was blocked if (popup && !popup.closed) popup.location.href = checkout_url; else window.location.href = checkout_url; const origin = new URL(checkout_url).origin; window.addEventListener('message', function onMessage(e) { if (e.origin !== origin) return; // trust only the checkout page const m = e.data; if (!m || m.source !== 'bitpal-checkout' || m.type !== 'terminal') return; window.removeEventListener('message', onMessage); refreshOrderFromServer(session_id); // m.step: done | expired | error }); } ``` When the payment reaches a final screen, the checkout page posts one message to its opener: ``` { "source": "bitpal-checkout", "type": "terminal", "step": "done", "session_id": "cs_…" } ``` - `step` is `done`, `expired` or `error`. One `terminal` message is sent per final screen. - If the buyer sends the wrong token, the page posts `{ "type": "status", "step": "wrong_token" }` instead. It is not final: the wrong token is returned and the session can still be paid with the right one, so keep listening for `terminal`. - The message is a **UI hint**. Re-read the order from your server (which learned the result from the webhook) before showing success. - Messages can be lost (the buyer closes the window, browser privacy settings). Keep a fallback: poll your own backend for the order status. - Don't close the popup yourself — the completion screen lets the buyer email a receipt. The popup closes itself only after the buyer sends that receipt; otherwise the buyer closes it with the **Close** button, so don't wait for the window to close. ## Headless (your own UI) Use this when you render the payment screen yourself. The buyer still pays to a per-order deposit address; you just display it. > **Call BitPal from your backend.** Browsers on your domain may only call the public session endpoint directly (CORS). Issue the deposit address and poll status through your server, and never ship a secret key to the browser. 1. **Create the session** on your server (as in the Quickstart). Keep `id` and `session_token`. 2. **Show the payment options.** `getPublicSession(id)` returns `payment_options` — one entry per allowed (chain, token) with `caip2`, `token_symbol`, `decimals` and the exact `amount_atomic`. 3. **Collect a refund address** from the buyer, in the format of the chosen network (EVM `0x…`, Tron `T…`, Solana base58). It is committed into the deposit address and cannot change. 4. **Issue the deposit address** (backend): ``` const { data } = await bitpal.checkout.issueDepositAddress(session.id, { session_token: session.session_token, refundAddress: '0xBuyerRefundWallet…', chain: 'eip155:84532', token: 'USDC', }); // { // "sessionId": "cs_…", // "chain": "eip155:84532", // "depositAddress": "0x6b19e871c934aB52775B6712e110f3e4b0626347", // "expectedToken": "0x943b94dacf1507f2658f1cfe103ac36ebc13ef65", // "expectedAmount": "29000000", // in the token's own decimals // "feeAmount": "435000", // "refundAddress": "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720", // "deadline": "1793957308", // on-chain deadline (unix s) = expiresAt + 5400 (90-minute grace) // "expiresAt": "2026-11-06T07:58:28.776Z" // } ``` 5. **Display** the address, a QR code and the exact amount — format `expectedAmount` with the token's decimals (6 for USDC, 18 for BNB USDT and JPYC). Show the countdown to `expiresAt`. 6. **Track status** through your backend (`getStatus(id)`) for the UI, and **fulfil from the webhook**. Calling `issueDepositAddress` again for the same session returns the same address **and keeps the first refund address** — a later call cannot change it. A deposit address belongs to one order; don't reuse it. --- Source: https://bitpal.io/docs/#flows · Markdown: https://bitpal.io/docs/md/flows.md # Payment links no code A shareable URL for a price. The flow is **Product → Price → Payment link**. ## From the dashboard (no code) 1. Create a **product** (e.g. "Pro plan"). 2. Add a **price** — amount + token. 3. Generate a **payment link** from that price and share the URL. Buyers pay on the hosted page; you still get webhooks and dashboard orders. ## From the API / SDK Same three steps in code: create a **product**, add a **price**, then a **payment link** from that price. ``` // 1. Product const { data: product } = await bitpal.checkout.products.create({ name: 'Pro plan' }); // 2. Price — just the number (no decimals to compute). The asset is token_symbol (USDC / USDT = USD, JPYC = JPY). const { data: price } = await bitpal.checkout.prices.create(product.id, { amount: '29', token_symbol: 'USDC', }); // 3. Payment link from the price → share link.share_token. const { data: link } = await bitpal.checkout.paymentLinks.create({ price_id: price.id, allowed_pay_chains: ['eip155:8453', 'solana:5eykt4…'], }); // Hosted URL: `${checkoutBase}/plink/${link.share_token}` ``` > **Prices take a plain `amount`** (`"29"`) — same as line items; `amount_atomic` stays available for raw μUSD. `price_id` is required to create a link. --- Source: https://bitpal.io/docs/#links · Markdown: https://bitpal.io/docs/md/links.md # x402 Charge per API call. The caller pays with a signed authorization and retries — no checkout page, no redirect. ## How it works 1. An unpaid request to your route returns `402` with the payment terms. 2. The caller signs an EIP-3009 authorization (gasless — signature only) and retries with an `X-PAYMENT` header. 3. BitPal verifies the signature and broadcasts it. Funds move from the payer through a per-terms settlement address that pays you and the fee wallet in the same transaction — BitPal never holds them. 4. Your handler runs and the response carries `X-PAYMENT-RESPONSE` with the transaction hash. > Payments settle to the **same payout wallet as checkout**. Register one in the console first — x402 has no separate wallet setting. ## Add it to a route ``` npm install @bitpal/checkout ``` ``` import { x402 } from '@bitpal/checkout'; // $1.00 USDC per call app.get('/report', x402({ apiKey: process.env.BITPAL_API_KEY, // bp_test_… or bp_live_… payTo: '0xYourPayoutWallet', // the payout wallet registered in Console for this mode — anything else: PAYTO_NOT_MERCHANT_WALLET network: 'base', // test key → 'base-sepolia' amount: '1.00', // 사람 표기 — decimals 계산 불필요 }), (req, res) => { res.json({ report: '…' }); }); ``` ## Other runtimes — Hono, Fastify, Next.js The `x402()` adapter above is Express-shaped. Everywhere else, use `X402Gate` directly — it takes the incoming header and hands back what to send. Same logic, no framework assumptions. ``` import { X402Gate, createMemoryReplayStore, X402_PAYMENT_RESPONSE_HEADER } from '@bitpal/checkout'; // 반드시 모듈 스코프에 하나 — 아래 경고 참조 const replayStore = createMemoryReplayStore(); const gate = new X402Gate({ apiKey: process.env.BITPAL_API_KEY, payTo: '0xYourPayoutWallet', network: 'base', amount: '1.00', replayStore, }); // Hono 예시 — Next.js Route Handler 도 모양이 같다 app.get('/report', async (c) => { const url = new URL(c.req.url); // 결제는 이 문자열에 묶인다. 402 와 재요청에서 같은 값이어야 한다(쿼리 제외). const resource = `${url.origin}${url.pathname}`; const result = await gate.collect({ header: c.req.header('x-payment'), resource }); if (result.kind !== 'settled') { // 402(미결제) · 409(재사용) · 503(미확정) 을 그대로 옮긴다 return c.json(result.body, result.status); } c.header(X402_PAYMENT_RESPONSE_HEADER, result.paymentResponseHeader); return c.json({ report: '…' }); // 결제된 요청만 여기 도달 }); ``` > **Create the replay store once, at module scope.** The gate uses it to remember which authorizations have already been served — that is what makes one payment unlock exactly one response. Build it inside the handler and every request gets a fresh, empty memory, so the same `X-PAYMENT` can be replayed for more calls. (Your funds are never at risk — BitPal settles a given authorization once and the on-chain nonce is spent — but you would be giving the resource away.) Running more than one instance? Back it with a shared store: `X402ReplayStore` is an interface, so Redis or a table works. ## The 402 response This is **standard x402** — a stock client such as `x402-fetch` can pay it. `payTo` is **not your wallet**: it is a CREATE2 address derived from the payment terms, which is what lets the caller sign a single authorization and still have the fee split correctly. `feeBreakdown` is a BitPal extension and is informational — a caller may ignore it and still pay correctly. > **Do not hand-roll this body.** `payTo` is computed on-chain from the terms, so only BitPal can produce a valid one. Without the middleware, call `POST /v1/x402/requirements` and return what it gives you — then `/verify` and `/settle` with the caller's `X-PAYMENT`. ``` { "x402Version": 1, "accepts": [{ "scheme": "exact", "network": "base", "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // 정산 지갑이 아니라 조건에서 유도된 정산 주소 (CREATE2) "payTo": "0xSettlementAddressDerivedFromTerms", // gross — 수수료 차감 전 총액. 호출자는 이 금액을 payTo 로 서명한다. "maxAmountRequired": "1000000", "resource": "https://your.api/report", "extra": { "name": "USD Coin", "version": "2", "chainId": 8453 }, "feeBreakdown": { "netAmount": "985000", "feeAmount": "15000", "feeBps": 150, "feeRecipient": "0x…" } }] } ``` ## Rules worth knowing - **Exact amounts only.** Over- and underpayment are both rejected — there is no refund path on this lane. - **One payment, one response.** A given authorization unlocks a single response; replaying it does not buy another call. - **Bound to the resource.** A payment verified for one route cannot be settled against another. - **EOA signers only.** Smart-contract wallets cannot sign EIP-3009 authorizations. - **Standard x402 payload.** The caller signs one authorization — any x402 client works, including ones that have never heard of BitPal. - If `settle` times out, poll the payment status instead of retrying blindly — the call is idempotent either way. ## When settle is uncertain If the chain is slow or the broadcast result is unknown, the gate returns `503` instead of your resource. **Do not retry blindly** — the payment may well have gone through. You have two ways to learn the outcome, and they carry the same answer: - **Poll** — `GET /v1/x402/payments/:id` with the `paymentId` from the 503 body. - **Webhook** — `x402.payment.resolved` is pushed once BitPal settles the question, so you do not have to sit on a polling loop. > Record the `paymentId` when you get a 503. It is how you tie the later webhook back to the caller who is waiting — and how you answer them if they ask. ## Supported | Network | Token | `network` | | --- | --- | --- | | Base | USDC | `base` | | Base Sepolia | USDC | `base-sepolia` | Test keys accept only `base-sepolia`; live keys only `base`. More chains follow the same flow as they are enabled — see [Chains & contracts](/docs/md/reference.md). --- Source: https://bitpal.io/docs/#x402 · Markdown: https://bitpal.io/docs/md/x402.md # Payment lifecycle What a session goes through, what each state means for you, and how amounts, late payments and refunds work. ## States ``` created ──▶ awaiting_deposit ──▶ deposit_detected ──▶ paid_confirmed ──▶ swept │ ▲ │ │ │ │ └─▶ unresolved_underpaid ──(deadline)──▶ expired (partial refund) │ └────── wrong_token (wrong asset returned; still payable) └──(deadline, nothing paid)──▶ expired ``` | Status | Meaning | What you do | | --- | --- | --- | | `created` | Session exists; the buyer has not picked a payment option yet. | Nothing. | | `awaiting_deposit` | A deposit address was issued; waiting for funds. | Nothing. | | `deposit_detected` | Funds seen on-chain, waiting for confirmations. | Show "processing". **Don't fulfil yet.** | | `paid_confirmed` | The full amount is confirmed. | **Fulfil** (webhook `checkout.session.paid`). | | `swept` | Funds moved to your payout wallet, minus the fee. | Reconcile (webhook `checkout.session.settled`). | | `unresolved_underpaid` | Less than the amount arrived. The buyer can top up until the deadline. | Wait. If it is still short at the deadline, it is refunded and the session expires. | | `wrong_token` | A different token was sent. It is returned to the refund address; the session stays payable. | Nothing. | | `expired` | The payment window closed without a full payment. | Release the order. A full payment can still arrive later — see Late payments. | > **Chain reorganisations.** Rarely, a confirmed deposit disappears after a chain reorg. You then receive `checkout.session.unresolved` with `reason: "reorg"` and the session goes back to `awaiting_deposit` (or `expired` past the deadline). If you have already fulfilled, treat it like a reversed payment. ## Amounts | Field | Unit | | --- | --- | | `line_items[].amount` (input) | Plain number string in the line item's `currency`: `"29"`, `"0.5"`. `USDC`/`USDT` = USD, `JPYC` = JPY. No FX conversion. | | `amount_atomic` (input), `amount_total` | Always a **6-decimal integer string**, whatever token the buyer pays with: `"29000000"` = 29. | | `expectedAmount`, `pay_amount`, `amount_received`, webhook `amount_expected`/`amount_received`, refund `amount` | Integer string in the **pay token's own decimals** (`token_decimals`: 6 for USDC/USDT on most chains, 18 for BNB USDT and JPYC). | | `fee_amount`, `net_amount` | 6-decimal integer strings; `fee_rate` is a decimal fraction (`"0.015"` = 1.5%). | Handle all amounts as strings or big integers — never as floating-point numbers. **Minimum amount.** A session's total (sum of `amount × quantity`) must be at least **1.00 in the session currency** (USDC/USDT: $1, JPYC: ¥1) — a lower total returns `400 CHECKOUT_AMOUNT_TOO_LOW`. The schema can't express this (it is a rule on the sum, and `amount` alone may be `"0"`), so check it before calling. Per-network minimums come on top: Tron currently $300 (testnet included) and JPYC ¥100 on every network. A network whose minimum is above the total is not offered to the buyer; requesting a deposit address on it returns `400 CHECKOUT_BELOW_MIN_PAYMENT`. ## Session fields worth knowing - `deposit_tx_hash` is the buyer's payment into the deposit address; `sweep_tx_hash` is the transaction that settled it to your payout wallet (or, for a refunded or rescued session, that on-chain action). - `tx_hash` is **deprecated**. It belonged to an earlier payment path and is kept only for compatibility — it now returns the same value as `deposit_tx_hash`. Use `deposit_tx_hash` / `sweep_tx_hash`. - `received_token` is the **address** of the token contract (or Solana mint) that actually arrived — not a symbol. `received_token_symbol` gives the symbol (`USDC`, `USDT` …), or `null` for a token BitPal doesn't know (for example a wrong token). - `payer_wallet` is the address the deposit came from (or the wallet the buyer connected), in the network's own format — Tron addresses are base58 `T…`. It may be an exchange's hot wallet, so don't use it as the buyer's identity or refund destination; refunds go to `refund_address`. - `metadata` is returned on the session as you sent it, and is also included in every webhook's `data` (see Webhooks → Event data). Keys BitPal writes itself — `_bitpal`, `payer_email`, `allowed_pay_chains`, `allowed_assets` — are reserved and are left out of both; don't use them for your own data. ## Buyer experience - **Receipts.** BitPal does not email the buyer automatically. After paying, the buyer can request an emailed receipt on the payment completion screen; `customer_email` only pre-fills that form. If every buyer needs a receipt or order confirmation, send it yourself from your `checkout.session.paid` handler. - **Status link.** `checkout_url` (`/pay/{id}?token=…`) is also the buyer's status page — opening it later shows the payment's current state, and the receipt email links back to it. The `token` grants access to that page, so share the URL only with the buyer. - **Language.** The payment page picks its language from the buyer's browser (12 languages, English as fallback), and the buyer can switch it. You can't set the language from the session or the URL yet. - **Your name and logo.** The payment page does not show a merchant name or logo yet. Buyers see the line item `name`s and amounts — make item names recognisable (for example include your store name). - **Support.** BitPal doesn't contact your buyers. Product, order and refund questions are yours to answer (completed payments are refunded by you — see Refunds above). For payment problems — funds sent but not detected, a wrong network, a late transfer — contact BitPal support with the session id and the transaction hash. ## Payment window and late payments - The buyer has `expires_in_minutes` (1–60, default 15) from session creation to pay. That deadline is the session's `expires_at`. - `late` compares the **block time of the deposit transaction** with `expires_at`. A transfer sent in time is `"late": false` even if its confirmations finish after the deadline; `checkout.session.settled` carries the same value as `paid`. - Once a deposit address has been issued, BitPal keeps the session open for **90 minutes after `expires_at`** so transfers already in flight can confirm. Underpayment refunds and `checkout.session.expired` for an issued session happen at the end of that window. - **A full payment that arrives after the deadline is accepted as a late payment** for up to 7 days by default: you get `checkout.session.paid` with `"late": true` and the funds settle normally. Decide whether to fulfil, or refund it from the console (see Refunds below). - **Don't want late payments?** Create the session with `"late_payments": "refund"`. A full payment that arrives after the deadline (`expires_at` + 90 minutes) is then never accepted: it stays in the deposit address and is refunded automatically to the buyer's refund address **about 7 days after the deadline** (refund kind `late_deposit`, `checkout.refund.recorded`). The refund can't be instant — BitPal is non-custodial and the deposit contract only lets anyone but you take the funds back after that 7-day window. A transfer sent before `expires_at` whose confirmations finish later is still accepted. - This only applies to a session with no earlier on-chain action. If a wrong token was already returned or an underpayment already refunded, the expired session is closed for good: later deposits to its address are not accepted as payment (contact support with the session id and transaction hash). - Acceptance ends 1 hour before the on-chain refund window opens (`expires_at` + 90 minutes + 7 days). A full payment that arrives after that cutoff is not accepted; it is refunded automatically once the window opens (refund kind `late_deposit`). - A **partial** payment after the deadline is refunded automatically (refund kind `late_deposit`). - Funds sent more than 7 days after the deadline are not watched automatically. Contact support with the session id and transaction hash. ## Refunds **BitPal is non-custodial.** A payment goes from the buyer to a one-time deposit address and then straight to your payout wallet — BitPal never holds it and has no right to pull it back. So there is **no refund API**. There are two kinds of refund, and both fire `checkout.refund.recorded`: ### 1. Automatic refunds (mismatched payments) Payments that don't match the order are returned on-chain from the deposit address to the buyer's refund address — automatically, with no action from you. | `kind` | When | Session outcome | | --- | --- | --- | | `overpay_excess` | More than the amount arrived. | Paid; the exact amount settles, the excess is refunded. | | `underpay_expired` | Still short at the deadline. | Expired; everything received is refunded. | | `wrong_token` | A token other than the selected one was sent. | Unchanged; still payable with the right token. | | `late_deposit` | A partial payment arrived after the deadline, or a full payment arrived after the late-acceptance cutoff (see above). | Stays expired. | ### 2. Refunding a completed payment (from the console) Like an app store, a completed payment is refunded by you, in the BitPal console: 1. Open **Console → Sessions → the session → Refund**. It shows the network, token, the buyer's refund address (entered at payment time) and how much is still refundable (received minus refunds so far). Partial refunds are fine. 2. **Send the refund from your own wallet** — one click with a connected EVM wallet, or copy the address, amount and token contract (with a QR code) for Tron, Solana or any other wallet. 3. **Record the transaction hash.** BitPal checks it on-chain (confirmed, successful, the session's token sent to the refund address, after the payment, not more than what is left) and records it. You get `checkout.refund.recorded` with `kind: "merchant"`, the `reason_code` you picked and `from_address`; `GET /v1/checkout/sessions/{id}/refunds` lists it. Current policy: you pay the network fee and the refund amount; the BitPal fee on the original payment is not returned. Recording is console-only — an API key gets `403 REFUND_CONSOLE_ONLY`. Recording the same transaction again returns the existing record. Treat `kind` as open: new values may be added. Handle unknown kinds as a generic refund. --- Source: https://bitpal.io/docs/#lifecycle · Markdown: https://bitpal.io/docs/md/lifecycle.md # Webhooks Signed JSON events as a session moves through its lifecycle. Delivered at least once — keep handlers idempotent. ## Events | Event | Fires when | | --- | --- | | `checkout.session.created` | A session is created. | | `checkout.session.deposit_detected` | Funds seen on-chain, awaiting confirmations. **Optional** — skipped when the deposit is already confirmed the first time BitPal sees it (common for a normal payment); `paid` then arrives on its own. Never wait for it. | | `checkout.session.paid` | Confirmed — safe to fulfil. `late: true` if it arrived after the deadline. | | `checkout.session.settled` | Funds swept to your payout wallet. | | `checkout.session.unresolved` | Underpaid, wrong token, or a confirmed deposit reverted by a reorg (`reason`). | | `checkout.session.expired` | Deadline passed without full payment. | | `checkout.refund.recorded` | A refund was made on-chain (`kind`): automatic, or one you sent and recorded in the console (`merchant`). | | `x402.payment.resolved` | An x402 call that settled with `503` reached its outcome. **Only fires for those** — the normal path answers inline. | ## Verify & handle Each request carries `X-Webhook-Signature-256` (`sha256=`) and `X-Webhook-Timestamp`. Verify both, against the **raw bytes**. ``` import express from 'express'; import { verifyWebhookSignature, parseWebhookEvent, WEBHOOK_HEADERS } from '@bitpal/checkout'; app.post('/webhooks/bitpal', express.raw({ type: 'application/json' }), async (req, res) => { const raw = req.body.toString('utf8'); // exact bytes, not re-serialized JSON const ok = await verifyWebhookSignature( // returns a Promise — always await it raw, req.header(WEBHOOK_HEADERS.signature) ?? '', process.env.BITPAL_WEBHOOK_SECRET, { timestamp: req.header(WEBHOOK_HEADERS.timestamp) ?? '' }, // replay defense — required ); if (!ok) return res.status(400).send('invalid signature'); const event = parseWebhookEvent(raw); if (event.event === 'checkout.session.paid') await fulfil(event.data.session_id); // idempotent, dedup on event.id res.sendStatus(200); }); ``` > **Verify against the raw body, and await the result.** Re-serializing the JSON changes the bytes and breaks the signature — capture the raw body before JSON middleware. `verifyWebhookSignature` is async: without `await` the check is always truthy and forged requests pass. ## Request format Every delivery is an HTTP `POST` with a JSON body: ``` { "id": "1ff63374-9417-4f87-850d-e869a3bd2dbe", // event id — dedup on this "event": "checkout.session.paid", "created_at": "2026-11-06T07:20:49.549Z", // when the event happened (unchanged on retries) "data": { "session_id": "cs_…", … } } ``` | Header | Value | | --- | --- | | `X-Webhook-Signature-256` | `sha256=` — HMAC-SHA256 of `.` | | `X-Webhook-Timestamp` | ISO-8601 UTC time of **this attempt** (new on every retry) | | `X-Webhook-Event` | Event name (unsigned — read it from the body) | | `X-Webhook-Id` | Event id (unsigned — dedup on the body `id`) | | `X-Webhook-Delivery-Id` | Id of this delivery, for support requests | ## Verifying the signature 1. Read the raw request body as bytes, before any JSON parsing. 2. Reject the request if `X-Webhook-Timestamp` is more than 5 minutes from your clock (replay protection). 3. Compute `HMAC-SHA256(key = your whsec_… secret, message = timestamp + "." + raw body)` as lowercase hex. 4. Compare it with the part after `sha256=` using a constant-time comparison. ### Python ``` import hmac, hashlib, time from datetime import datetime def verify_bitpal_webhook(raw_body: bytes, signature: str, timestamp: str, secret: str, tolerance_sec: int = 300) -> bool: if not signature.startswith("sha256=") or not timestamp: return False try: sent = datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp() except (ValueError, OverflowError, OSError): return False if abs(time.time() - sent) > tolerance_sec: return False expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() # compare bytes: compare_digest raises TypeError on non-ASCII str input return hmac.compare_digest(signature[len("sha256="):].encode(), expected.encode()) # Flask: verify_bitpal_webhook(request.get_data(), request.headers.get("X-Webhook-Signature-256", ""), # request.headers.get("X-Webhook-Timestamp", ""), os.environ["BITPAL_WEBHOOK_SECRET"]) ``` ### Go ``` import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "strings" "time" ) func VerifyBitPalWebhook(rawBody []byte, signature, timestamp, secret string) bool { const prefix = "sha256=" if !strings.HasPrefix(signature, prefix) || timestamp == "" { return false } sent, err := time.Parse(time.RFC3339Nano, timestamp) if err != nil { return false } if d := time.Since(sent); d > 5*time.Minute || d < -5*time.Minute { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp + ".")) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(signature[len(prefix):]), []byte(expected)) } ``` ### PHP ``` function verify_bitpal_webhook(string $rawBody, string $signature, string $timestamp, string $secret, int $toleranceSec = 300): bool { if (strpos($signature, 'sha256=') !== 0 || $timestamp === '') return false; $sent = strtotime($timestamp); if ($sent === false || abs(time() - $sent) > $toleranceSec) return false; $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); return hash_equals($expected, substr($signature, 7)); } $secret = getenv('BITPAL_WEBHOOK_SECRET'); // false when unset if ($secret === false || $secret === '') { http_response_code(500); // misconfigured — never verify against an empty key exit; } $ok = verify_bitpal_webhook( (string) file_get_contents('php://input'), $_SERVER['HTTP_X_WEBHOOK_SIGNATURE_256'] ?? '', $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '', $secret ); ``` ### Java ``` import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.DateTimeException; import java.time.Duration; import java.time.Instant; public static boolean verifyBitPalWebhook(byte[] rawBody, String signature, String timestamp, String secret) throws Exception { if (signature == null || !signature.startsWith("sha256=") || timestamp == null || timestamp.isEmpty()) return false; Instant sent; try { sent = Instant.parse(timestamp); } catch (DateTimeException e) { return false; } if (Duration.between(sent, Instant.now()).abs().getSeconds() > 300) return false; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8)); byte[] digest = mac.doFinal(rawBody); StringBuilder hex = new StringBuilder(); for (byte b : digest) hex.append(String.format("%02x", b)); return MessageDigest.isEqual(hex.toString().getBytes(StandardCharsets.UTF_8), signature.substring(7).getBytes(StandardCharsets.UTF_8)); } ``` ## Event data Every `data` object has `session_id` and `metadata` — the `metadata` you set when creating the session (`{}` if none), so you can map an event to your order without a lookup. BitPal's reserved keys (`_bitpal`, `payer_email`, `allowed_pay_chains`, `allowed_assets`) are left out. Session events also carry `amount_total` (6-decimal integer string), `currency` and `late`. Token amounts are in the pay token's decimals — see [Payment lifecycle → Amounts](/docs/md/lifecycle.md). | Event | Extra fields in `data` | | --- | --- | | `checkout.session.created` | — (only `session_id`, `amount_total`, `currency`) | | `checkout.session.deposit_detected` | `tx_hash`, `amount_received`, `received_token` (token contract) — optional event, see above | | `checkout.session.paid` | `tx_hash`, `late` | | `checkout.session.settled` | `sweep_tx_hash` | | `checkout.session.unresolved` | `reason`: `underpaid` (+ `amount_received`, `amount_expected`, `received_token`, `tx_hash`) · `wrong_token` (+ `received_token`) · `reorg` | | `checkout.session.expired` | `reason: "no_deposit"`, or `refund_tx_hash` when a partial payment was refunded | | `checkout.refund.recorded` | `kind` (`overpay_excess` · `underpay_expired` · `wrong_token` · `late_deposit` · `merchant`), `amount`, `token`, `tx_hash`, `refund_address`; for `merchant` also `from_address` and `reason_code` | ### Example: `checkout.session.paid` ``` { "id": "1ff63374-9417-4f87-850d-e869a3bd2dbe", "event": "checkout.session.paid", "created_at": "2026-11-06T07:20:49.549Z", "data": { "session_id": "cs_HFoj91aFFbHSRk1LN1-9K01q", "amount_total": "1000000", "currency": "USDC", "late": false, "tx_hash": "0xd9a75dfe753b20027a0a93841c4e70af1b6be061b59e530d1bc381e88c196fe3", "metadata": { "order_id": "ord_1042" } } } ``` ### Example: `checkout.session.unresolved` (underpaid) ``` { "id": "18d80275-45be-43d7-a57b-4b5038bd2736", "event": "checkout.session.unresolved", "created_at": "2026-11-06T07:14:19.252Z", "data": { "session_id": "cs_1ATJ7V5QknzeFpTNPMU_449s", "amount_total": "5000000", "currency": "USDC", "late": false, "reason": "underpaid", "amount_expected": "5000000", "amount_received": "2500000", "received_token": "0x943b94Dacf1507F2658f1CfE103Ac36Ebc13Ef65", "tx_hash": "0x4461291b5d743d3950a474c015098b8cb53b4373346bd510f74481dd8dbb9d65", "metadata": { "order_id": "ord_1041" } } } ``` ### Example: `checkout.refund.recorded` ``` { "id": "f2fdcce6-1880-44b2-8351-906c42b7c4cd", "event": "checkout.refund.recorded", "created_at": "2026-11-06T07:20:29.547Z", "data": { "session_id": "cs_YsrZGcZzVsVHJo8ZNVmsTL1D", "kind": "overpay_excess", "amount": "1000000", "token": "0x943b94Dacf1507F2658f1CfE103Ac36Ebc13Ef65", "tx_hash": "0xe943412e2be8b3e3fc6359b47a8eea0cc4de17768b069f52ebab1aacd873aec4", "refund_address": "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720", "metadata": { "order_id": "ord_1040" } } } ``` ## Delivery, retries and ordering - Respond with any **2xx within 10 seconds**. Do slow work after responding (queue it). - A failed delivery (non-2xx, timeout or connection error) is retried right away twice (after 1 s and 2 s), then after **1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h** — 10 attempts over about a day. After the last one the delivery moves to the **dead-letter queue**; resend it from Console → Developers → Webhooks once your endpoint is fixed (dead-lettered deliveries can be resent for 30 days). - Delivery is **at least once**: the same event can arrive more than once. Deduplicate on the body `id` and store it in the same transaction as your side effect. - Order is not guaranteed — a retried `paid` can arrive after `settled`. Keep the furthest state you have seen instead of overwriting it with an older one. Not every event is sent for every payment: `paid` can arrive with no `deposit_detected` before it (a late payment, or a deposit already confirmed when it was first seen). - Test and live are separate: each environment has its own endpoints and secrets. ## Testing - **Send Test** (Console → Developers → Webhooks) posts a signed sample event to your endpoint. Its id starts with `test_`. - In the Sandbox event list (and the MCP `list_test_events` tool) every event is a test-mode event. The `is_test` field does **not** mean test mode: it is `true` only for **Send Test** samples and `false` for events produced by a (simulated) payment. - For local development, expose your server with a tunnel (ngrok, cloudflared) and register the tunnel URL in test mode. - Run a real test payment ([Quickstart](/docs/md/quickstart.md)) to receive the full sequence of events. --- Source: https://bitpal.io/docs/#webhooks · Markdown: https://bitpal.io/docs/md/webhooks.md # Errors How the API reports failures, and what to do about the ones you are likely to see. ## Response format Successful responses are `{ "data": … }`. Failed responses use an HTTP status code and this JSON body: ``` { "error": "checkout not enabled for USDC on eip155:8453", // human-readable "code": "FEATURE_NOT_ENABLED", // machine-readable "retryable": false, "trace_id": "4f1c…" // quote it to support } ``` `error` is a human-readable message and may change — **branch on `code`** (after the HTTP status), never on the text of `error`. Validation failures add a `details` array. The SDK exposes the same fields: ``` import { BitPalError } from '@bitpal/checkout'; try { await bitpal.checkout.createSession(params); } catch (err) { if (err instanceof BitPalError) { err.statusCode; // 400, 404, 429, … err.code; // 'CHECKOUT_AMOUNT_TOO_LOW' (when the API sent one) err.apiMessage; // the API's description err.retryable; // true / false / undefined err.traceId; // for support } throw err; } ``` ## HTTP status codes | Status | Meaning | Retry? | | --- | --- | --- | | `400` | The request is invalid — fix it. | No | | `401` | Missing, invalid, revoked or expired API key, or a key for the other mode. | No | | `403` | Not allowed — for example the merchant account is frozen or suspended. | No | | `404` | The object doesn't exist in this environment (test and live are separate). | No | | `409` | Conflicts with the current state, or the same `Idempotency-Key` is still in progress. | After re-reading state | | `410` | Gone — the session or link has expired, sold out or been deactivated. | No | | `422` | `Idempotency-Key` reused with a different body or on a different endpoint. | No — use a new key | | `429` | Rate limited. Honour `Retry-After` (seconds). | Yes, with backoff | | `5xx` | BitPal-side problem. | Yes, with backoff (same `Idempotency-Key`) | ## Common error codes | Code | HTTP | Cause → fix | | --- | --- | --- | | `UNAUTHORIZED` | 401 | Bad, missing, revoked or expired `Authorization: Bearer bp_…` key. | | `VALIDATION_FAILED` | 400 | Body doesn't match the schema — see `details`. Typical: a line item with both or neither of `amount` / `amount_atomic`. | | `MIXED_LINE_ITEMS` | 400 | Raw line items and `price_id` items in one session. Use one kind. | | `CURRENCY_MIX_NOT_ALLOWED` | 400 | Line items in different currencies. Use one currency per session. | | `CHECKOUT_AMOUNT_TOO_LOW` | 400 | The total (sum of `amount × quantity`) is below 1.00 in the session currency ($1 for USDC/USDT, ¥1 for JPYC). | | `UNSUPPORTED_CHAIN` · `UNSUPPORTED_ASSET` · `UNSUPPORTED_TOKEN` · `UNSUPPORTED_CURRENCY` | 400 | Unknown chain/token, or a chain not enabled for checkout. See [Chains & contracts](/docs/md/reference.md). | | `CHAIN_MODE_MISMATCH` | 400 | A mainnet CAIP-2 id with a test key, or a testnet id with a live key. Use the matching id, or a short name (`base`) that resolves to your key's mode. | | `ALLOWED_CHAINS_CONFLICT` | 400 | `pay_chain` and `allowed_pay_chains` were both set. Send one. | | `FEATURE_NOT_ENABLED` | 400 | That asset is not enabled for checkout on that chain. | | `SELLER_WALLET_NOT_CONFIGURED` | 400 | No payout wallet for that network family (EVM / Tron / Solana). Add one in Console → Settings → Payout Wallet. | | `INVALID_REFUND_ADDRESS` | 400 | The refund address is not valid for the chosen network. | | `ASSET_SELECTION_REQUIRED` | 400 | Multi-option session: pass `chain` and `token` when issuing the deposit address. | | `ASSET_NOT_OFFERED` | 400 | The `chain`/`token` you passed is not one of the session's `payment_options`. | | `CHECKOUT_BELOW_MIN_PAYMENT` | 400 | The amount is below the minimum payment for the chosen network (some networks, such as Tron, have a higher floor). Pick another option. | | `SESSION_NOT_FOUND` | 404 | Wrong id, or the session belongs to the other environment. | | `REFUND_CONSOLE_ONLY` | 403 | Recording a refund is console-only — there is no refund API. Refund in Console → Sessions → session → Refund. | | `PRODUCT_NOT_FOUND` · `PAYMENT_LINK_NOT_FOUND` | 404 | Wrong id or environment. | | `PRICE_NOT_FOUND` | 404 / 400 | 404 when you fetch a price by id; 400 when a `price_id` in a session's line items or a payment link doesn't exist (wrong id or environment). | | `INVALID_STATE_TRANSITION` | 409 | The session can't do that in its current state — for example issuing a deposit address for a session that is already paid or expired. Re-read the session; create a new one if it has expired. | | `CHECKOUT_SESSION_EXPIRED` | 410 | A deposit address was requested after `expires_at` for a session that never got one. Create a new session. | | `LINK_EXPIRED` · `LINK_INACTIVE` · `LINK_SOLD_OUT` | 410 | The payment link can't take payments any more. | | `MERCHANT_FROZEN` · `MERCHANT_SUSPENDED` | 403 | The account is restricted. Contact support. | | `TOO_MANY_ACTIVE_SESSIONS` | 429 | Over 1,000 open sessions. Let unused ones expire, and don't create a session before the buyer intends to pay. | | `RATE_LIMIT_EXCEEDED` | 429 | Too many requests. Wait `Retry-After` seconds. | | `INVALID_IDEMPOTENCY_KEY` | 400 | `Idempotency-Key` must be 8–128 characters. | | `IDEMPOTENCY_KEY_REUSED` | 422 | Same key with a different body, or on a different endpoint (method and path are part of the fingerprint). Use a new key for a new request. | | `IDEMPOTENCY_IN_PROGRESS` | 409 | The first request with this key is still running. Retry shortly. | | `INTERNAL_ERROR` | 500 | Retry with backoff, with the same `Idempotency-Key`; if it persists, contact support with `trace_id`. | ## Retrying safely - Send an `Idempotency-Key` (8–128 characters, unique per operation — an order id works) on any merchant `POST` (sessions, products, prices, payment links, webhook endpoints, …) — it is honoured on all of them. Replays within 24 hours return the original response with `X-Idempotency-Replayed: true`. A key is bound to one request: reusing it with a different body or on a different endpoint returns `422 IDEMPOTENCY_KEY_REUSED`. Failed requests (4xx/5xx) are not cached, so you can retry them with the same key. - Retry only `429`, `5xx` and network errors, with exponential backoff. - Rate-limited responses carry `Retry-After` and `X-RateLimit-Limit` / `-Remaining` / `-Reset`. --- Source: https://bitpal.io/docs/#errors · Markdown: https://bitpal.io/docs/md/errors.md # Use with AI Give your AI coding tool BitPal's docs, API spec and webhook schemas so it writes integrations against the real API — and, with a test key, runs a test payment for you. ## BitPal MCP server - **Remote:** `https://mcp.bitpal.io/mcp` (Streamable HTTP). Nothing to install. - **Local:** `npx -y @bitpal/mcp` (stdio). - Docs tools work without a key. Add a `bp_test_…` key to unlock test-mode tools. Live keys are rejected. The server checks the key with BitPal: a revoked or unknown key keeps the test-mode tools hidden and says why in the server instructions. | Tool | What it does | | --- | --- | | `get_integration_guide` | Integration rules plus the docs for one flow: `hosted`, `popup`, `headless`, `payment_link`, `x402`, `webhooks`. | | `search_docs` · `get_doc` | Search the docs; read a page as Markdown. | | `list_api_operations` · `get_api_operation` | REST operations with request/response schemas, from the [OpenAPI spec](/docs/openapi.json). | | `get_webhook_event` · `get_error_code` | Webhook payload schemas; what an error code means and how to fix it. | | `create_test_session` · `simulate_payment` · `get_session_status` · `list_test_events` | **Test key only.** Create a test session, send the deposit (`exact`, `underpay`, `overpay`, `wrong_token`) and read the webhook events — the same as Console → Developers → Sandbox. | ## Setup Replace `bp_test_…` with your test key (Console → Developers → API Keys), or leave the key out for docs only. > **Restart your AI client after adding the server.** MCP clients load their tool list when a session starts. In Claude Code, start a new session after `claude mcp add` (check with `/mcp`); in Cursor, VS Code, Windsurf, Claude Desktop and Codex, restart the app or reload the window. Do the same after changing the key. ### Claude Code ``` # remote claude mcp add --transport http bitpal https://mcp.bitpal.io/mcp --header "Authorization: Bearer bp_test_…" # local claude mcp add bitpal -e BITPAL_API_KEY=bp_test_… -- npx -y @bitpal/mcp ``` ### Cursor — `~/.cursor/mcp.json` ``` { "mcpServers": { "bitpal": { "url": "https://mcp.bitpal.io/mcp", "headers": { "Authorization": "Bearer bp_test_…" } } } } ``` ### VS Code — `.vscode/mcp.json` ``` { "servers": { "bitpal": { "type": "http", "url": "https://mcp.bitpal.io/mcp", "headers": { "Authorization": "Bearer bp_test_…" } } } } ``` ### Windsurf — `~/.codeium/windsurf/mcp_config.json` ``` { "mcpServers": { "bitpal": { "serverUrl": "https://mcp.bitpal.io/mcp", "headers": { "Authorization": "Bearer bp_test_…" } } } } ``` ### Claude Desktop — `claude_desktop_config.json` ``` { "mcpServers": { "bitpal": { "command": "npx", "args": ["-y", "@bitpal/mcp"], "env": { "BITPAL_API_KEY": "bp_test_…" } } } } ``` ### Codex CLI — `~/.codex/config.toml` ``` [mcp_servers.bitpal] command = "npx" args = ["-y", "@bitpal/mcp"] env = { BITPAL_API_KEY = "bp_test_…" } ``` ## Without MCP - [llms.txt](/llms.txt) — an index of every page plus the rules below. - [llms-full.txt](/docs/llms-full.txt) — all docs in one file. - Every page has **Copy as Markdown** and a `.md` version (`/docs/md/.md`). - [openapi.json](/docs/openapi.json) — the OpenAPI 3.1 spec, generated from the API code. ## Rules for your AGENTS.md Paste this into `AGENTS.md`, `CLAUDE.md` or your editor's rules file so every session follows it. ``` ## BitPal integration rules - Keep bp_test_/bp_live_ API keys and the whsec_ webhook secret on the server. Browsers only get checkout_url. - Fulfil orders from the checkout.session.paid webhook (signature verified) or a server-side session lookup — never from the success_url redirect or a popup message. - Verify webhooks on the raw body with X-Webhook-Signature-256 + X-Webhook-Timestamp; await verifyWebhookSignature. - Webhooks are at-least-once and unordered: dedupe on the body id, keep the furthest session state. - Amounts are strings: amount = plain number in the line-item currency ("29"); amount_total = 6-decimal integer; deposit amounts use the token's decimals. No floating-point math. - Paid statuses are paid_confirmed and swept (there is no "paid"/"settled" status value). - Payment window: expires_in_minutes (1–60). Full payments up to 7 days late arrive with late: true — handle them. - Test keys take testnet chain ids only; live keys mainnet only. - Headless UIs call BitPal through your backend (CORS); only the public session endpoint works from the browser. - Send Idempotency-Key when creating sessions. - Session total must be at least 1.00 in the session currency (CHECKOUT_AMOUNT_TOO_LOW); Tron needs $300. - Webhook data carries your session metadata — put your order id there. - Docs: https://bitpal.io/docs/ · MCP: https://mcp.bitpal.io/mcp ``` --- Source: https://bitpal.io/docs/#ai · Markdown: https://bitpal.io/docs/md/ai.md # Chains & contracts ## Chains & identifiers The `pay_chain` / `allowed_pay_chains` values are [CAIP-2](https://chainagnostic.org/CAIPs/caip-2) identifiers. Use the value from the column matching your API key's mode. | Network | Tokens | Decimals | `pay_chain` — mainnet | `pay_chain` — testnet | | --- | --- | --- | --- | --- | | **Base** | USDC | 6 | `eip155:8453` | `eip155:84532` | | **Arbitrum** | USDC | 6 | `eip155:42161` | `eip155:421614` | | **BNB Smart Chain** | USDT | 18 | `eip155:56` | `eip155:97` | | **Tron** | USDT | 6 | `tron:0x2b6653dc` | `tron:0xcd8690dc` | | **Solana** | USDC · USDT | 6 | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` | | **Polygon** | JPYC | 18 | `eip155:137` | `eip155:80002` | | **Kaia** | JPYC | 18 | `eip155:8217` | `eip155:1001` | > JPYC (Polygon/Kaia) settles 1:1 to JPY — no FX conversion. > Test keys (`bp_test_…`) accept only testnet values; live keys only mainnet. ## Contract addresses The deposit-address contracts (EVM/Tron deterministic forwarder factory, Solana program). Published here as each network goes live. | Network | Contract / program | Mainnet | Testnet | | --- | --- | --- | --- | | Base | Forwarder factory | `0xC5d358f4346d59D8eCDb5cE7DE77367b068a7E7c` | `0x5bA4F84B3002B9F94670f296134aE4f823662B9f` | | Arbitrum | Forwarder factory | `0xC7989977010890AE78938a84E487573a1D993366` | `0x38B54b7F8a032E8087cd04f12a7Bdb9011c5Bd95` | | BNB | Forwarder factory | `0xC7989977010890AE78938a84E487573a1D993366` | `0xeD24d9ED87a9937528520413891e8f78EF7ED3C5` | | Tron | Forwarder factory | `TH2t7eXXVbmHxoBvWk5YTAGVtMTomcv6ie` | `TBRQHfn5cRiEd2htYF7Xwae61RZD32T4ee` | | Solana | Program ID | `2cZyQxMgWToiCWho9q24VyAvs6xy7t3JSEEwNpGjf4ec` | `2cZyQxMgWToiCWho9q24VyAvs6xy7t3JSEEwNpGjf4ec` | | Polygon | Forwarder factory | `0xA2F1bB858DBc25355D8cb5180D7e58bDC2D696De` | `0x42A70E1a47C9AAeF10923f5345b2ad64D2F05D3F` | | Kaia | Forwarder factory | `0xA2F1bB858DBc25355D8cb5180D7e58bDC2D696De` | `0xf07BB5243636D8C834b31f224E2567E6e6aF38ce` | These are the factories that derive deposit addresses. Always treat the deposit address returned by BitPal as authoritative for a given order — funds are cryptographically committed to your destination. ## Refunds & edge cases Payments are push transfers, so a buyer can pay the wrong amount or token. BitPal resolves each case automatically to the buyer's refund address (set at pay time, committed & immutable). | Case | What happens | | --- | --- | | **Overpaid** | Settles the exact amount; surplus refunded. | | **Underpaid** | Open for a top-up; refunded if the deadline passes. | | **Wrong token** | Returned to the buyer; order stays open. | | **Expired** | No funds arrived — address reclaimed. | | **Late, full amount** | Accepted as a late payment for up to 7 days (`late: true`); settles normally. Not for sessions that already had a wrong-token return or underpayment refund; after the cutoff it is refunded (`late_deposit`). | | **Late, full amount, `late_payments: "refund"`** | Not accepted. Refunded automatically about 7 days after the deadline (`late_deposit`) — the non-custodial deposit contract does not allow an earlier refund. | | **Late, partial** | Refunded (`late_deposit`). | | **Customer return (completed payment)** | No refund API — BitPal never holds the funds. Refund in Console → Sessions → session → Refund: send from your own wallet, record the transaction, and BitPal verifies it and sends `checkout.refund.recorded` (`merchant`). You pay the gas and the refund; the BitPal fee is not returned. | ## Test mode & faucet - Keys are environment-scoped: `bp_test_…` on testnets, `bp_live_…` on mainnet. - In test mode the hosted page shows a **faucet** — the buyer mints mock tokens to run a real end-to-end payment. (Gas is separate; the faucet sends tokens only.) --- Source: https://bitpal.io/docs/#reference · Markdown: https://bitpal.io/docs/md/reference.md # API reference Every public endpoint, generated from the OpenAPI 3.1.0 spec (version 1.0.0). Download it: [openapi.json](/docs/openapi.json). ## Basics | Topic | Details | | --- | --- | | Base URL | `https://api.bitpal.io` — test and live share the host; the API key prefix selects the mode. | | Auth | `Authorization: Bearer bp_test_…` (test) or `bp_live_…` (live). Keep keys on your server. | | Format | JSON. Success: `{ "data": … }` (authenticated endpoints add `meta: { mode, requestId }`). Amounts are strings. | | Errors | `{ "error", "code", "retryable", "trace_id", "details"? }` — `error` is the human-readable message; branch on `code`. See [Errors](/docs/md/errors.md). | | Idempotency | `Idempotency-Key` (8–128 chars) is honoured on every authenticated merchant `POST`; endpoints that do not enforce it say so. | ## Checkout sessions Create a session per order, send the buyer to the hosted page (or build your own UI), and track it. ### `POST /v1/checkout/sessions` — Create a checkout session Creates a session for one order and returns the hosted `checkout_url`. Line items take either `amount` (plain decimal string, e.g. "29") or `amount_atomic` (6-decimal integer string), or reference a `price_id`. **Minimum amount:** the session total (sum of `amount × quantity`) must be at least 1.00 in the session currency (USDC/USDT: $1, JPYC: ¥1); a lower total returns 400 `CHECKOUT_AMOUNT_TOO_LOW`. Some networks also have a higher per-network minimum (Tron: currently $300, testnet included; JPYC: ¥100) — a network whose minimum is above the total is not offered to the buyer, and requesting a deposit address on it returns 400 `CHECKOUT_BELOW_MIN_PAYMENT`. **Payout wallet:** you need a payout wallet registered in the session's mode (test/live). With `pay_chain`, a wallet for that network's type (EVM, Tron or Solana) is required. With `allowed_pay_chains` / `allowed_assets`, at least one listed network must have a wallet of its type, and the buyer is only offered networks you can receive on. Without any network restriction, any payout wallet is enough: with an EVM wallet the buyer is offered Base USDC as before; without one, the networks of the wallet types you registered (if the total is below the minimum of all of them, 400 `CHECKOUT_BELOW_MIN_PAYMENT`). No usable payout wallet returns 400 `SELLER_WALLET_NOT_CONFIGURED`. **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | No | 8–128 characters, unique per operation. A retry with the same key and body within 24 hours returns the original response with `X-Idempotency-Replayed: true`. (8–128 chars) | **Request body** (`CreateCheckoutSessionRequest`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `line_items` | array of (object \| any \| any) | Yes | Raw items (`name`, `currency`, `amount` XOR `amount_atomic`) or price items (`price_id`). Do not mix the two kinds. The total (sum of `amount × quantity`) must be at least 1.00 in the session currency, otherwise 400 `CHECKOUT_AMOUNT_TOO_LOW`. (≥ 1 items) | | `currency` | string | No | Session currency. Defaults to the line items’ currency. (≥ 1 chars) | | `success_url` | string (uri) | No | Where the buyer returns after paying. `{CHECKOUT_SESSION_ID}` is replaced with the session id. (≤ 2048 chars) | | `cancel_url` | string (uri) | No | Where the buyer goes when cancelling. (≤ 2048 chars) | | `customer_email` | string (email) | No | Buyer email. Pre-fills the receipt form on the payment completion screen; no receipt is sent automatically — the buyer requests it there. | | `metadata` | map | No | Your key/value data, echoed back on the session and in webhooks (`data.metadata`). Reserved keys BitPal writes itself (`_bitpal`, `payer_email`, `allowed_pay_chains`, `allowed_assets`) are not returned — do not use them. | | `expires_in_minutes` | integer | No | Payment window in minutes (1–60). Default 15. (1–60, default `15`) | | `tolerance_percent` | string | No | Deprecated. Underpayments are never accepted — a short payment becomes `unresolved_underpaid` and can be topped up to the same address. Only `"0"` is accepted; any other value returns 400. | | `pay_chain` | string | No | Fix the payment network: CAIP-2 (`eip155:84532`) or a short name (`base`), which resolves to the testnet in test mode and the mainnet in live mode. Mutually exclusive with `allowed_pay_chains`. | | `allowed_pay_chains` | array of string | No | Networks the buyer may choose from (CAIP-2 or short names, resolved like `pay_chain`). (1–8 items) | | `allowed_assets` | array of object | No | Allowed (chain, token) cells. Takes precedence over `allowed_pay_chains`. (1–16 items) | | `late_payments` | "accept" \| "refund" | No | What to do with a full payment that arrives after the payment deadline. `accept` (default): accepted as a late payment for up to 7 days (`checkout.session.paid` with `late: true`). `refund`: never accepted — it is refunded automatically to the buyer about 7 days after the deadline (refund kind `late_deposit`); the non-custodial deposit contract does not allow an earlier refund. | ``` { "line_items": [ { "name": "Pro plan", "amount": "29", "currency": "USDC" } ], "pay_chain": "eip155:84532", "expires_in_minutes": 30, "success_url": "https://your.store/thanks?session={CHECKOUT_SESSION_ID}" } ``` **Response `201`** — Session created. ``` { "data": { "id": "cs_Q2FxKhomCEju_OqvKfrQsTOh", "checkout_url": "https://bitpal.io/pay/cs_Q2FxKhomCEju_OqvKfrQsTOh?token=", "session_token": "", "status": "created", "amount_total": "29000000", "currency": "USDC", "expires_at": "2026-11-06T07:58:28.776Z", "tax": { "tax_amount": "0", "total_with_tax": "29000000" } }, "meta": { "mode": "TEST", "requestId": "ec091faf-…" } } ``` **Errors:** `400` · `401` · `403` · `409` · `422` · `429` — see [Errors](/docs/md/errors.md). ### `GET /v1/checkout/sessions` — List checkout sessions Newest first, in the API key’s mode (test/live). **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `status` | query | string | No | Filter by status, or `succeeded` for all successful states. | | `limit` | query | integer | No | 1–100, default `50` | | `offset` | query | integer | No | ≥ 0, default `0` | **Response `200`** — Sessions. ``` { "data": [ { "id": "string", "status": "string", "mode": "TEST", "amount_total": "1000000", "amount_received": "string", "currency": "string", "pay_chain": "string", "pay_asset": "string", "token_decimals": 0, "payment_method": "string", "payer_wallet": "string", "seller_wallet": "string", "tolerance_percent": "string", "late_payments": "accept", "deposit_address": "string", "refund_address": "string", "received_token": "string", "received_token_symbol": "string", "fee_amount": "string", "fee_rate": "string", "net_amount": "string", "fee_amount_token": "string", "net_amount_token": "string", "tx_hash": "string", "deposit_tx_hash": "string", "sweep_tx_hash": "string", "success_url": "string", "cancel_url": "string", "metadata": {}, "payment_source": "string", "payment_link_id": "string", "expires_at": "2026-11-06T07:00:00.000Z", "paid_at": "2026-11-06T07:00:00.000Z", "settled_at": "2026-11-06T07:00:00.000Z", "created_at": "2026-11-06T07:00:00.000Z", "raw_status": "string" } ], "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `401` — see [Errors](/docs/md/errors.md). ### `GET /v1/checkout/sessions/{id}` — Retrieve a checkout session Server-side source of truth for an order, together with webhooks. **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | Yes | Checkout session id (`cs_…`). | **Response `200`** — The session. ``` { "data": { "id": "string", "status": "string", "mode": "TEST", "amount_total": "1000000", "amount_received": "string", "currency": "string", "pay_chain": "string", "pay_asset": "string", "token_decimals": 0, "payment_method": "string", "payer_wallet": "string", "seller_wallet": "string", "tolerance_percent": "string", "late_payments": "accept", "deposit_address": "string", "refund_address": "string", "received_token": "string", "received_token_symbol": "string", "fee_amount": "string", "fee_rate": "string", "net_amount": "string", "fee_amount_token": "string", "net_amount_token": "string", "tx_hash": "string", "deposit_tx_hash": "string", "sweep_tx_hash": "string", "success_url": "string", "cancel_url": "string", "metadata": {}, "payment_source": "string", "payment_link_id": "string", "expires_at": "2026-11-06T07:00:00.000Z", "paid_at": "2026-11-06T07:00:00.000Z", "settled_at": "2026-11-06T07:00:00.000Z", "created_at": "2026-11-06T07:00:00.000Z", "line_items": [ { "id": "string", "name": "string", "amount": "1000000", "currency": "string", "quantity": 0, "price_id": "string", "product_id": "string", "created_at": "2026-11-06T07:00:00.000Z" } ] }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `401` · `404` — see [Errors](/docs/md/errors.md). ### `GET /v1/checkout/sessions/{id}/status` — Get session status Lightweight status for polling from your server. Requires your API key (N13 — it is not a public endpoint). Buyer-facing pages should use `GET /v1/checkout/sessions/{id}/public`. **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | Yes | Checkout session id (`cs_…`). | **Response `200`** — Status. ``` { "data": { "status": "awaiting_deposit", "paid_at": null } } ``` **Errors:** `404` — see [Errors](/docs/md/errors.md). ### `GET /v1/checkout/sessions/{id}/public` — Get the public session view Buyer-facing view used by the checkout page: payment options, deposit address once issued, refunds. No authentication; CORS-open. **Auth:** None — public endpoint **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | Yes | Checkout session id (`cs_…`). | **Response `200`** — Public view. ``` { "data": { "id": "string", "status": "string", "token_decimals": 0, "pay_amount": "string", "mode": "TEST", "chain_id": 0, "token_address": "string", "payment_options": [ { "caip2": "string", "chain_id": 0, "token_symbol": "string", "token_address": "string", "decimals": 0, "amount_atomic": "string", "available": true, "unavailable_reason": "coming_soon" } ], "currency": "string", "amount_total": "1000000", "amount_received": "string", "chain": "string", "tx_hash": "string", "release_tx_hash": "string", "expires_at": "2026-11-06T07:00:00.000Z", "success_url": "string", "cancel_url": "string", "fee_amount": "string", "fee_rate": "string", "net_amount": "string", "tax_amount": null, "tax_rate": null, "pay_chain": "string", "pay_asset": "string", "deposit_address": "string", "deposit_owner": "string", "payment_deadline_at": "2026-11-06T07:00:00.000Z", "payment_method": "string", "payer_wallet": "string", "seller_wallet": "string", "tolerance_percent": "string", "paid_at": "2026-11-06T07:00:00.000Z", "settled_at": "2026-11-06T07:00:00.000Z", "created_at": "2026-11-06T07:00:00.000Z", "line_items": [ { "name": "string", "amount": "1000000", "currency": "string", "quantity": 0 } ], "payment_source": "string", "refunds": [ { "kind": "string", "amount": "string", "token_symbol": "string", "decimals": 0, "tx_hash": "string", "caip2": "string" } ] }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `404` — see [Errors](/docs/md/errors.md). ### `POST /v1/checkout/sessions/{id}/deposit-address` — Issue the deposit address Fixes the buyer’s network, token and refund address, and returns the one-time deposit address with the exact amount. Calling again returns the same address and keeps the first refund address. Requires the session token (header `X-Session-Token` or body `session_token`). **Auth:** Session token — `X-Session-Token` header or `session_token` body field (no API key) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | Yes | Checkout session id (`cs_…`). | | `Idempotency-Key` | header | string | No | Accepted but not yet enforced on this endpoint — retries are not de-duplicated by key. (8–128 chars) | | `X-Session-Token` | header | string | No | Session token from the create-session response. Alternative to the `session_token` body field. | **Request body** (`IssueDepositAddressRequest`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `session_token` | string | No | From the create-session response. May be sent as the `X-Session-Token` header instead. | | `refundAddress` | string | Yes | Buyer refund address in the chosen network’s format (EVM 0x…, Tron T…, Solana base58). Cannot change later. | | `chain` | string | No | CAIP-2 or short name of the chosen network. Required for multi-option sessions. (≥ 1 chars) | | `token` | string | No | Token symbol of the chosen asset, e.g. USDC. Required for multi-option sessions. (≥ 1 chars) | ``` { "session_token": "", "refundAddress": "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720", "chain": "eip155:84532", "token": "USDC" } ``` **Response `200`** — Deposit address. ``` { "data": { "sessionId": "cs_…", "chain": "eip155:84532", "depositAddress": "0x6b19e871c934aB52775B6712e110f3e4b0626347", "expectedToken": "0x943b94dacf1507f2658f1cfe103ac36ebc13ef65", "expectedAmount": "29000000", "feeAmount": "435000", "refundAddress": "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720", "deadline": "1793951938", "expiresAt": "2026-11-06T07:58:28.776Z", "depositOwner": null } } ``` **Errors:** `400` · `401` · `404` · `409` · `410` — see [Errors](/docs/md/errors.md). ### `GET /v1/checkout/sessions/{id}/refunds` — List refunds for a session Refunds recorded for this session: automatic on-chain refunds from the deposit address (overpayment, expired underpayment, wrong token, late deposit) and refunds you sent from your own wallet and recorded in the console (`kind: merchant`). There is no API to make a refund — BitPal is non-custodial; refund a completed payment from Console → Sessions → session → Refund. **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | Yes | Checkout session id (`cs_…`). | **Response `200`** — Refunds. ``` { "data": [ { "id": "string", "kind": "string", "token": "string", "token_symbol": "string", "amount": "string", "amount_display": "string", "decimals": 0, "refund_address": "string", "tx_hash": "string", "caip2": "string", "block_number": "string", "created_at": "2026-11-06T07:00:00.000Z", "from_address": "string", "reason_code": "string", "note": "string", "explorer_url": "string" } ], "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `401` · `404` — see [Errors](/docs/md/errors.md). ### `GET /v1/checkout/fee-preview` — Preview the fee for an amount Pass exactly one of `amount` or `amount_atomic`. The response is always atomic. **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `amount` | query | string | No | Decimal string, e.g. "100". | | `amount_atomic` | query | string | No | 6-decimal integer string, e.g. "100000000". | **Response `200`** — Fee breakdown. ``` { "data": { "amount": "100000000", "fee_rate": "0.015", "fee_amount": "1500000", "net_amount": "98500000" } } ``` **Errors:** `400` · `401` — see [Errors](/docs/md/errors.md). ## Products & prices Catalog objects. Prices are immutable. ### `POST /v1/checkout/products` — Create a product **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | No | 8–128 characters, unique per operation. A retry with the same key and body within 24 hours returns the original response with `X-Idempotency-Replayed: true`. (8–128 chars) | **Request body** (`CreateProductRequest`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | Yes | 1–200 chars | | `description` | string | No | ≤ 2000 chars | | `image_url` | string (uri) | No | ≤ 2048 chars | | `metadata` | map | No | | ``` { "name": "Pro plan" } ``` **Response `201`** — Product created. ``` { "data": { "id": "string", "name": "string", "description": "string", "image_url": "string", "status": "active", "metadata": {}, "mode": "TEST", "created_at": "2026-11-06T07:00:00.000Z", "updated_at": "2026-11-06T07:00:00.000Z" }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` · `403` — see [Errors](/docs/md/errors.md). ### `GET /v1/checkout/products` — List products **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `status` | query | "active" \| "inactive" | No | | | `limit` | query | integer | No | 1–100, default `50` | | `offset` | query | integer | No | ≥ 0, default `0` | **Response `200`** — Products. ``` { "data": [ { "id": "string", "name": "string", "description": "string", "image_url": "string", "status": "active", "metadata": {}, "mode": "TEST", "created_at": "2026-11-06T07:00:00.000Z", "updated_at": "2026-11-06T07:00:00.000Z" } ], "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` — see [Errors](/docs/md/errors.md). ### `GET /v1/checkout/products/{id}` — Retrieve a product **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | Yes | Product id (`prod_…`). | **Response `200`** — The product. ``` { "data": { "id": "string", "name": "string", "description": "string", "image_url": "string", "status": "active", "metadata": {}, "mode": "TEST", "created_at": "2026-11-06T07:00:00.000Z", "updated_at": "2026-11-06T07:00:00.000Z" }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` · `404` — see [Errors](/docs/md/errors.md). ### `PATCH /v1/checkout/products/{id}` — Update a product **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | Yes | Product id (`prod_…`). | **Request body** (`UpdateProductRequest`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | No | 1–200 chars | | `description` | string \| null | No | ≤ 2000 chars | | `image_url` | string (uri) \| null | No | ≤ 2048 chars | | `metadata` | map | No | | | `status` | "active" \| "inactive" | No | | **Response `200`** — The updated product. ``` { "data": { "id": "string", "name": "string", "description": "string", "image_url": "string", "status": "active", "metadata": {}, "mode": "TEST", "created_at": "2026-11-06T07:00:00.000Z", "updated_at": "2026-11-06T07:00:00.000Z" }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` · `403` · `404` — see [Errors](/docs/md/errors.md). ### `POST /v1/checkout/products/{id}/prices` — Create a price Prices are immutable: to change an amount, create a new price. Pass exactly one of `amount` (decimal string) or `amount_atomic` (6-decimal integer string). The currency is derived from `token_symbol`. **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | Yes | Product id (`prod_…`). | | `Idempotency-Key` | header | string | No | 8–128 characters, unique per operation. A retry with the same key and body within 24 hours returns the original response with `X-Idempotency-Replayed: true`. (8–128 chars) | **Request body** (`CreatePriceRequest`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `amount` | string | No | Decimal string, e.g. "29". Mutually exclusive with `amount_atomic`. | | `amount_atomic` | string | No | 6-decimal integer string ("29000000" = 29). | | `token_symbol` | "USDC" \| "USDT" \| "JPYC" | No | The price’s asset. The currency is derived from it. (default `"USDC"`) | | `billing_type` | "one_time" | No | Only `one_time` for now. (default `"one_time"`) | | `nickname` | string | No | Display name. (≤ 200 chars) | | `lookup_key` | string | No | Your own stable key for this price. (1–200 chars) | ``` { "amount": "29", "token_symbol": "USDC" } ``` **Response `201`** — Price created. ``` { "data": { "id": "string", "product_id": "string", "amount_atomic": "1000000", "currency": "string", "token_symbol": "string", "billing_type": "one_time", "nickname": "string", "lookup_key": "string", "mode": "TEST", "created_at": "2026-11-06T07:00:00.000Z" }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` · `403` · `404` — see [Errors](/docs/md/errors.md). ### `GET /v1/checkout/products/{id}/prices` — List a product’s prices **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | Yes | Product id (`prod_…`). | | `limit` | query | integer | No | 1–100, default `50` | | `offset` | query | integer | No | ≥ 0, default `0` | **Response `200`** — Prices. ``` { "data": [ { "id": "string", "product_id": "string", "amount_atomic": "1000000", "currency": "string", "token_symbol": "string", "billing_type": "one_time", "nickname": "string", "lookup_key": "string", "mode": "TEST", "created_at": "2026-11-06T07:00:00.000Z" } ], "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` · `404` — see [Errors](/docs/md/errors.md). ### `GET /v1/checkout/prices/{id}` — Retrieve a price **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | Yes | Price id (`price_…`). | **Response `200`** — The price. ``` { "data": { "id": "string", "product_id": "string", "amount_atomic": "1000000", "currency": "string", "token_symbol": "string", "billing_type": "one_time", "nickname": "string", "lookup_key": "string", "mode": "TEST", "created_at": "2026-11-06T07:00:00.000Z" }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `401` · `404` — see [Errors](/docs/md/errors.md). ### `PATCH /v1/checkout/prices/{id}` — Update a price’s nickname Only `nickname` can change. Sending an amount/currency field returns `PRICE_IMMUTABLE_FIELD`. **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | Yes | Price id (`price_…`). | **Request body** (`UpdatePriceRequest`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `nickname` | string \| null | No | ≤ 200 chars | **Response `200`** — The updated price. ``` { "data": { "id": "string", "product_id": "string", "amount_atomic": "1000000", "currency": "string", "token_symbol": "string", "billing_type": "one_time", "nickname": "string", "lookup_key": "string", "mode": "TEST", "created_at": "2026-11-06T07:00:00.000Z" }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` · `403` · `404` — see [Errors](/docs/md/errors.md). ## Payment links Shareable hosted URLs for a price. ### `POST /v1/checkout/payment-links` — Create a payment link A shareable hosted URL for one price. The link ↔ price binding is fixed after creation. **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | No | 8–128 characters, unique per operation. A retry with the same key and body within 24 hours returns the original response with `X-Idempotency-Replayed: true`. (8–128 chars) | **Request body** (`CreatePaymentLinkRequest`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `price_id` | string | Yes | The price this link sells (`price_…`). | | `title` | string | No | 1–200 chars | | `description` | string | No | ≤ 2000 chars | | `collect_email` | boolean | No | Ask the buyer for an email. Default true. (default `true`) | | `email_required` | boolean | No | Require the email: the hosted link page asks for it and no checkout is created without it. Default true; needs `collect_email`. (default `true`) | | `allowed_pay_chains` | array of string | No | Networks the buyer may choose from (CAIP-2 or short names). (1–8 items) | | `allowed_assets` | array of object | No | Allowed (chain, token) cells. Takes precedence over `allowed_pay_chains`. (1–16 items) | | `success_url` | string (uri) | No | ≤ 2048 chars | | `cancel_url` | string (uri) | No | ≤ 2048 chars | | `max_uses` | integer | No | Stop accepting payments after this many paid uses. (≥ 1) | | `expires_at` | string (date-time) | No | ISO-8601 time after which the link stops working. | | `metadata` | map | No | | ``` { "price_id": "price_…", "allowed_pay_chains": [ "eip155:8453" ] } ``` **Response `201`** — Link created. ``` { "data": { "id": "string", "share_token": "string", "price_id": "string", "title": "string", "description": "string", "collect_email": true, "email_required": true, "allowed_pay_chains": [ "string" ], "allowed_assets": [ { "caip2": "string", "token": "string" } ], "success_url": "string", "cancel_url": "string", "status": "active", "max_uses": 0, "paid_use_count": 0, "expires_at": "2026-11-06T07:00:00.000Z", "metadata": {}, "mode": "TEST", "created_at": "2026-11-06T07:00:00.000Z", "updated_at": "2026-11-06T07:00:00.000Z" }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` · `403` · `404` — see [Errors](/docs/md/errors.md). ### `GET /v1/checkout/payment-links` — List payment links **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `status` | query | "active" \| "inactive" | No | | | `limit` | query | integer | No | 1–100, default `50` | | `offset` | query | integer | No | ≥ 0, default `0` | **Response `200`** — Links. ``` { "data": [ { "id": "string", "share_token": "string", "price_id": "string", "title": "string", "description": "string", "collect_email": true, "email_required": true, "allowed_pay_chains": [ "string" ], "allowed_assets": [ { "caip2": "string", "token": "string" } ], "success_url": "string", "cancel_url": "string", "status": "active", "max_uses": 0, "paid_use_count": 0, "expires_at": "2026-11-06T07:00:00.000Z", "metadata": {}, "mode": "TEST", "created_at": "2026-11-06T07:00:00.000Z", "updated_at": "2026-11-06T07:00:00.000Z" } ], "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` — see [Errors](/docs/md/errors.md). ### `GET /v1/checkout/payment-links/{id}` — Retrieve a payment link **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | Yes | Payment link id (`plink_…`). | **Response `200`** — The link, with product/price summary fields. ``` { "data": { "id": "string", "share_token": "string", "price_id": "string", "title": "string", "description": "string", "collect_email": true, "email_required": true, "allowed_pay_chains": [ "string" ], "allowed_assets": [ { "caip2": "string", "token": "string" } ], "success_url": "string", "cancel_url": "string", "status": "active", "max_uses": 0, "paid_use_count": 0, "expires_at": "2026-11-06T07:00:00.000Z", "metadata": {}, "mode": "TEST", "created_at": "2026-11-06T07:00:00.000Z", "updated_at": "2026-11-06T07:00:00.000Z" }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `401` · `404` — see [Errors](/docs/md/errors.md). ### `PATCH /v1/checkout/payment-links/{id}` — Update a payment link Operational settings and active ↔ inactive. The price cannot change — create a new link instead. Deactivating (`status: "inactive"`) requires `reason_code` (`user_request`, `security_concern`, `rotation`, `no_longer_needed` or `other`, plus an optional `reason_note`); without it the request returns 400 `REASON_CODE_REQUIRED`. Reactivating accepts the same fields optionally. Every status change is recorded in your audit log with its reason. **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | Yes | Payment link id (`plink_…`). | **Request body** (`UpdatePaymentLinkRequest`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `title` | string \| null | No | 1–200 chars | | `description` | string \| null | No | ≤ 2000 chars | | `collect_email` | boolean | No | | | `email_required` | boolean | No | | | `allowed_pay_chains` | array of string \| null | No | 1–8 items | | `allowed_assets` | array of object \| null | No | 1–16 items | | `success_url` | string (uri) \| null | No | ≤ 2048 chars | | `cancel_url` | string (uri) \| null | No | ≤ 2048 chars | | `status` | "active" \| "inactive" | No | | | `max_uses` | integer \| null | No | ≥ 1 | | `expires_at` | string (date-time) \| null | No | | | `metadata` | map | No | | | `reason_code` | "user_request" \| "security_concern" \| "rotation" \| "no_longer_needed" \| "other" | No | | | `reason_note` | string | No | ≤ 500 chars | **Response `200`** — The updated link. ``` { "data": { "id": "string", "share_token": "string", "price_id": "string", "title": "string", "description": "string", "collect_email": true, "email_required": true, "allowed_pay_chains": [ "string" ], "allowed_assets": [ { "caip2": "string", "token": "string" } ], "success_url": "string", "cancel_url": "string", "status": "active", "max_uses": 0, "paid_use_count": 0, "expires_at": "2026-11-06T07:00:00.000Z", "metadata": {}, "mode": "TEST", "created_at": "2026-11-06T07:00:00.000Z", "updated_at": "2026-11-06T07:00:00.000Z" }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` · `403` · `404` · `409` — see [Errors](/docs/md/errors.md). ## x402 Charge per API call with HTTP 402 and EIP-3009 authorizations. ### `GET /v1/x402/assets` — List x402 assets for this mode **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Response `200`** — Assets. ``` { "data": { "mode": "TEST", "assets": [ { "caip2": "string", "network": "string", "chain_name": "string", "symbol": "string", "decimals": 0, "address": "string" } ] }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `401` — see [Errors](/docs/md/errors.md). ### `POST /v1/x402/requirements` — Build the 402 payment requirements Returns the body your resource server sends with `402 Payment Required`, including the fee split. Pass exactly one of `amount` or `amountAtomic`. **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | No | 8–128 characters, unique per operation. A retry with the same key and body within 24 hours returns the original response with `X-Idempotency-Replayed: true`. (8–128 chars) | **Request body** (`X402RequirementsRequest`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `network` | string | No | x402 network name (`base`, `base-sepolia`). Defaults to the mode’s first asset. (≥ 1 chars) | | `asset` | string | No | Token symbol. Default USDC. (≥ 1 chars, default `"USDC"`) | | `payTo` | string | Yes | Your payout wallet (0x) — must be the one registered in Console for this mode, otherwise `PAYTO_NOT_MERCHANT_WALLET`. | | `amount` | string | No | Gross amount per call as a decimal string, e.g. "0.1". The fee is taken from it. | | `amountAtomic` | string | No | Same as `amount`, as a 6-decimal integer string. | | `resource` | string | Yes | The URL being paid for. (1–2048 chars) | | `description` | string | No | ≤ 512 chars | | `mimeType` | string | No | ≤ 128 chars | | `maxTimeoutSeconds` | integer | No | How long the payer has to sign (max 3600). Default 300. (≤ 3600, > 0) | ``` { "payTo": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "amount": "0.1", "resource": "https://api.example.com/report" } ``` **Response `200`** — 402 body. ``` { "data": { "x402Version": 1, "accepts": [ { "scheme": "exact", "network": "string", "asset": "string", "payTo": "string", "maxAmountRequired": "1000000", "resource": "string", "description": "string", "mimeType": "string", "maxTimeoutSeconds": 0, "extra": { "name": "string", "version": "string", "chainId": 0 }, "feeBreakdown": { "netAmount": "1000000", "feeAmount": "1000000", "feeBps": 0, "feeRecipient": "string" } } ] }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` · `403` — see [Errors](/docs/md/errors.md). ### `POST /v1/x402/verify` — Verify an X-PAYMENT header Checks the EIP-3009 authorization against the requirements. Sends nothing on-chain. **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | No | 8–128 characters, unique per operation. A retry with the same key and body within 24 hours returns the original response with `X-Idempotency-Replayed: true`. (8–128 chars) | **Request body** (`X402PaymentRequest`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `x402Version` | 1 | Yes | | | `paymentPayload` | object | Yes | | | `paymentRequirements` | object | Yes | | **Response `200`** — Valid. ``` { "data": { "valid": true, "payment_id": "string", "status": "string", "idempotent": true }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` · `403` · `409` — see [Errors](/docs/md/errors.md). ### `POST /v1/x402/settle` — Settle an x402 payment Broadcasts the transfer and waits for the receipt. Serve the resource only when `status` is `confirmed`. Settlement is idempotent per authorization — retry with the same body on 5xx. **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | No | 8–128 characters, unique per operation. A retry with the same key and body within 24 hours returns the original response with `X-Idempotency-Replayed: true`. (8–128 chars) | **Request body** (`X402PaymentRequest`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `x402Version` | 1 | Yes | | | `paymentPayload` | object | Yes | | | `paymentRequirements` | object | Yes | | **Response `200`** — Settlement result. ``` { "data": { "success": true, "payment_id": "string", "status": "string", "tx_hash": "string", "fee_tx_hash": "string", "network": "string", "idempotent": true }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` · `403` · `409` · `503` — see [Errors](/docs/md/errors.md). ### `GET /v1/x402/payments/{id}` — Retrieve an x402 payment Recovery path when a settle response was lost — check here before settling again. **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | Yes | Payment id. | **Response `200`** — The payment. ``` { "data": { "id": "string", "status": "pending", "raw_status": "string", "resource": "string", "chain": "string", "asset": "string", "decimals": 0, "amount": "1000000", "gross_amount": "1000000", "payer": "string", "pay_to": "string", "tx_hash": "string", "created_at": "2026-11-06T07:00:00.000Z", "settled_at": "2026-11-06T07:00:00.000Z" }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `401` · `404` — see [Errors](/docs/md/errors.md). ### `GET /v1/x402/payments` — List x402 payments **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `limit` | query | integer | No | ≤ 100, > 0, default `20` | | `offset` | query | integer | No | ≥ 0, default `0` | | `status` | query | string | No | pending \| confirmed \| failed \| needs_review | **Response `200`** — Payments. ``` { "data": [ { "id": "string", "status": "pending", "raw_status": "string", "resource": "string", "chain": "string", "asset": "string", "decimals": 0, "amount": "1000000", "gross_amount": "1000000", "payer": "string", "pay_to": "string", "tx_hash": "string", "created_at": "2026-11-06T07:00:00.000Z", "settled_at": "2026-11-06T07:00:00.000Z" } ], "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` — see [Errors](/docs/md/errors.md). ## Webhook endpoints Manage where BitPal delivers signed events. ### `POST /v1/webhooks` — Create a webhook endpoint **Console only.** Manage this from Console → Developers → Webhooks; calls with an API key return 403. The response carries the `whsec_…` signing secret — it is shown only once. **Auth:** Console login only — manage this in Console → Developers → Webhooks (API keys get 403) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | No | 8–128 characters, unique per operation. A retry with the same key and body within 24 hours returns the original response with `X-Idempotency-Replayed: true`. (8–128 chars) | **Request body** (`CreateWebhookEndpointRequest`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `endpoint_url` | string (uri) | Yes | HTTPS URL that receives events. Private hosts are rejected. | | `subscribed_events` | array of ("*" \| "checkout.session.created" \| "checkout.session.deposit_detected" \| "checkout.session.paid" \| "checkout.session.settled" \| "checkout.session.expired" \| "checkout.session.unresolved" \| "checkout.refund.recorded" \| "x402.payment.resolved") | Yes | Event names to deliver (see the `webhooks` section), or `*` for every event. Unknown names are rejected with 400. (≥ 1 items) | | `retry_policy` | object | No | `max_retries` caps the total delivery attempts (1–20, default 10: 3 right away, then 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h). The schedule itself is fixed; `backoff` is ignored. | | `dead_letter_destination` | string (uri) \| null | No | Optional URL for dead-lettered deliveries. | ``` { "endpoint_url": "https://your.store/webhooks/bitpal", "subscribed_events": [ "checkout.session.paid" ] } ``` **Response `201`** — Endpoint created. ``` { "data": { "id": "00000000-0000-4000-8000-000000000000", "mode": "TEST", "endpoint_url": "string", "subscribed_events": [ "string" ], "retry_policy": { "max_retries": 0, "backoff": "exponential" }, "dead_letter_destination": "string", "status": "active", "created_at": "2026-11-06T07:00:00.000Z", "updated_at": "2026-11-06T07:00:00.000Z", "secret": "string" }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` — see [Errors](/docs/md/errors.md). ### `GET /v1/webhooks` — List webhook endpoints **Console only.** Manage this from Console → Developers → Webhooks; calls with an API key return 403. Endpoints of the API key’s environment and mode. Note: list items use camelCase keys. **Auth:** Console login only — manage this in Console → Developers → Webhooks (API keys get 403) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `limit` | query | integer | No | 1–100, default `50` | | `offset` | query | integer | No | ≥ 0, default `0` | **Response `200`** — Endpoints, newest first. `meta` carries total/limit/offset. ``` { "data": [ { "id": "00000000-0000-4000-8000-000000000000", "mode": "TEST", "endpoint_url": "string", "subscribed_events": [ "string" ], "retry_policy": { "max_retries": 0, "backoff": "exponential" }, "dead_letter_destination": "string", "status": "active", "created_at": "2026-11-06T07:00:00.000Z", "updated_at": "2026-11-06T07:00:00.000Z" } ], "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `401` — see [Errors](/docs/md/errors.md). ### `PATCH /v1/webhooks/{id}` — Update a webhook endpoint **Console only.** Manage this from Console → Developers → Webhooks; calls with an API key return 403. Setting `status` to `disabled` removes the endpoint and requires `reason_code` (otherwise 400 `REASON_CODE_REQUIRED`). It is recorded in the audit log. **Auth:** Console login only — manage this in Console → Developers → Webhooks (API keys get 403) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | Yes | Webhook endpoint id (uuid). | **Request body** (`UpdateWebhookEndpointRequest`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `endpoint_url` | string (uri) | No | | | `subscribed_events` | array of ("*" \| "checkout.session.created" \| "checkout.session.deposit_detected" \| "checkout.session.paid" \| "checkout.session.settled" \| "checkout.session.expired" \| "checkout.session.unresolved" \| "checkout.refund.recorded" \| "x402.payment.resolved") | No | ≥ 1 items | | `status` | "active" \| "paused" \| "disabled" | No | | | `retry_policy` | object | No | | | `dead_letter_destination` | string (uri) \| null | No | | | `reason_code` | "user_request" \| "security_concern" \| "rotation" \| "no_longer_needed" \| "other" | No | Required when `status` is `disabled`: one of user_request, security_concern, rotation, no_longer_needed, other. | | `reason_note` | string | No | Optional free text (≤ 500 chars) stored with the audit record. (≤ 500 chars) | **Response `200`** — The updated endpoint. ``` { "data": { "id": "00000000-0000-4000-8000-000000000000", "mode": "TEST", "endpoint_url": "string", "subscribed_events": [ "string" ], "retry_policy": { "max_retries": 0, "backoff": "exponential" }, "dead_letter_destination": "string", "status": "active", "created_at": "2026-11-06T07:00:00.000Z", "updated_at": "2026-11-06T07:00:00.000Z" }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` · `404` — see [Errors](/docs/md/errors.md). ### `POST /v1/webhooks/{id}/rotate-secret` — Rotate the signing secret **Console only.** Manage this from Console → Developers → Webhooks; calls with an API key return 403. Takes effect immediately. The new secret is returned once — update your verifier. Requires `reason_code` (400 `REASON_CODE_REQUIRED` if missing); recorded in the audit log. **Auth:** Console login only — manage this in Console → Developers → Webhooks (API keys get 403) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | Yes | Webhook endpoint id (uuid). | | `Idempotency-Key` | header | string | No | 8–128 characters, unique per operation. A retry with the same key and body within 24 hours returns the original response with `X-Idempotency-Replayed: true`. (8–128 chars) | **Request body** (`RotateWebhookSecretRequest`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `reason_code` | "user_request" \| "security_concern" \| "rotation" \| "no_longer_needed" \| "other" | Yes | One of user_request, security_concern, rotation, no_longer_needed, other. | | `reason_note` | string | No | Optional free text (≤ 500 chars) stored with the audit record. (≤ 500 chars) | ``` { "reason_code": "rotation" } ``` **Response `200`** — Endpoint with the new secret. ``` { "data": { "id": "00000000-0000-4000-8000-000000000000", "mode": "TEST", "endpoint_url": "string", "subscribed_events": [ "string" ], "retry_policy": { "max_retries": 0, "backoff": "exponential" }, "dead_letter_destination": "string", "status": "active", "created_at": "2026-11-06T07:00:00.000Z", "updated_at": "2026-11-06T07:00:00.000Z", "secret": "string" }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` · `404` — see [Errors](/docs/md/errors.md). ### `POST /v1/developers/webhooks/test` — Send a test event Posts one signed sample event to an endpoint (no retries). The envelope id is prefixed `test_`, `data.test` is true and the request carries `X-Webhook-Test: true`. **Auth:** API key — `Authorization: Bearer bp_test_…` / `bp_live_…` (server only) **Parameters** | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | No | 8–128 characters, unique per operation. A retry with the same key and body within 24 hours returns the original response with `X-Idempotency-Replayed: true`. (8–128 chars) | **Request body** (`SendTestWebhookRequest`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `webhook_id` | string (uuid) | Yes | Endpoint id. | | `event_type` | string | Yes | Event name to put in the envelope, e.g. checkout.session.paid. (1–200 chars) | | `payload` | map | No | Custom `data` object. Defaults to a sample message. | ``` { "webhook_id": "00000000-0000-4000-8000-000000000000", "event_type": "checkout.session.paid" } ``` **Response `200`** — Delivery result. ``` { "data": { "delivery_id": "00000000-0000-4000-8000-000000000000", "webhook_id": "00000000-0000-4000-8000-000000000000", "endpoint_url": "string", "event_type": "string", "http_status": 0, "response_body": "string", "error": "string", "duration_ms": 0, "test": true, "sent_at": "2026-11-06T07:00:00.000Z" }, "meta": { "mode": "TEST", "requestId": "string", "total": 0, "limit": 0, "offset": 0 } } ``` **Errors:** `400` · `401` · `404` · `409` — see [Errors](/docs/md/errors.md). ## Webhook events Envelope: `{ id, event, created_at, data }`, signed with `X-Webhook-Signature-256`. Verification and retries: [Webhooks](/docs/md/webhooks.md). | Event | Fires when | `data` fields | | --- | --- | --- | | `checkout.session.created` | A session is created. | `session_id`, `metadata`, `amount_total`, `currency`, `late` | | `checkout.session.deposit_detected` | Funds seen on-chain, awaiting confirmations. Optional — skipped when the first time BitPal sees the deposit it is already confirmed; then `checkout.session.paid` (or `unresolved`) arrives with no `deposit_detected` before it. Do not wait for it. | `session_id`, `metadata`, `amount_total`, `currency`, `late`, `tx_hash`, `amount_received`, `received_token` | | `checkout.session.paid` | Confirmed — safe to fulfil. `late: true` if it arrived after the deadline. | `session_id`, `metadata`, `amount_total`, `currency`, `late`, `tx_hash` | | `checkout.session.settled` | Funds swept to your payout wallet. | `session_id`, `metadata`, `amount_total`, `currency`, `late`, `sweep_tx_hash` | | `checkout.session.unresolved` | Underpaid, wrong token, or a confirmed deposit reverted by a reorg (`reason`). | `session_id`, `metadata`, `amount_total`, `currency`, `late`, `reason`, `amount_expected`, `amount_received`, `received_token`, `tx_hash` | | `checkout.session.expired` | Deadline passed without full payment. | `session_id`, `metadata`, `amount_total`, `currency`, `late`, `reason`, `expired_at`, `source`, `refund_tx_hash` | | `checkout.refund.recorded` | A refund was made on-chain (`kind`): automatic, or recorded by you in the console (`merchant`). | `session_id`, `metadata`, `kind`, `amount`, `token`, `tx_hash`, `refund_address`, `from_address`, `reason_code` | | `x402.payment.resolved` | An x402 call that settled with `503` reached its outcome. Only fires for those — the normal path answers inline. | `payment_id`, `status`, `resource`, `amount`, `asset`, `chain`, `tx_hash`, `payer`, `resolved_by` | --- Source: https://bitpal.io/docs/#api · Markdown: https://bitpal.io/docs/md/api.md