TBL Pay merchant API
Version 1 • Contract reviewed 17 October 2026
Base URL: https://tbl-pay.com. Machine-readable contract: /openapi.json. This guide covers all 11 merchant API operations under /v1. Browser session, administrator, checkout-internal and development APIs are not supported shop integration contracts and are intentionally excluded.
Before you start
Create a merchant account, add a store, configure its receiving wallets and select the assets enabled for that store. Generate a separate API key for each store in Integrations. Add a public HTTPS webhook endpoint on port 443 and securely save its signing secret. Never put the API key or webhook secret in HTML, browser JavaScript, URLs, analytics or client-side storage. Use environment variables or a secret manager on your shop server.
Portal-generated keys have payments:read, payments:write and webhooks:read. Wallet and legacy billing endpoints require additional scopes that these keys do not include. A key cannot access another merchant's resources; a store key is restricted to its store. Revocation takes effect on subsequent requests.
Requests use Authorization: Bearer YOUR_STORE_API_KEY. Send JSON bodies with Content-Type: application/json. The global default limit is 120 requests per minute, keyed by the request IP as interpreted by the configured trusted proxy; it is not an unlimited per-key allowance. Maximum request body: 32 KiB. Keep your server clock synchronized. Responses are not cacheable.
Payment flow
1. Calculate the payable total on your shop server. Keep names, email addresses, shipping information, product descriptions and cart contents in the shop.
2. Assign an opaque UUID reference to the shop order. Persist the reference and a separate idempotency key before calling TBL Pay.
3. Read /v1/assets and choose from the store's configured assets. Create the payment intent and save its returned id and checkout_url against the order.
4. Redirect the customer to the returned checkout URL. TBL Pay creates a quote and receiving instructions after an asset is selected. Funds go directly to the merchant wallet.
5. Verify and durably queue signed webhooks. Fetch the payment from the API again, compare its reference, currency and amount with your saved order, then apply its current state idempotently.
6. Fulfill only when the verified API state is paid. A browser redirect, a transaction hash, or an unsigned callback does not prove payment.
The checkout URL contains a bearer capability. Do not log or publish it. return_route_id is accepted by the create schema but is currently unused; it does not configure a redirect. Save the returned checkout URL rather than constructing one.
Create and retrieve a payment
This example uses BTC mainnet and requires that asset to be enabled and qualified for your store. Replace the reference and idempotency key for each new order. Retain both unchanged when retrying the same operation.
curl --fail-with-body https://tbl-pay.com/v1/payment-intents \
-H "Authorization: Bearer $TBL_PAY_API_KEY" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: order-550e8400-e29b-41d4-a716-446655440000' \
--data '{"reference":"550e8400-e29b-41d4-a716-446655440000","amount":"25.00","currency":"USD","allowed_assets":["btc:mainnet:native"]}'Success is HTTP 201, including an idempotent replay:
{"id":"b3587719-1ba0-4f98-9599-247658fd71ab","reference":"550e8400-e29b-41d4-a716-446655440000","status":"pending","version":1,"amount":"25.00","currency":"USD","checkout_url":"https://tbl-pay.com/c/EXAMPLE_ONLY"}The example checkout token is deliberately nonfunctional. Amount is a positive decimal string with at most 13 integer digits and two decimal places. Only USD and EUR are accepted. allowed_assets has 1–8 entries. Unknown body fields are rejected. Do not send customer data as additional fields.
curl --fail-with-body https://tbl-pay.com/v1/payment-intents/PAYMENT_UUID \
-H "Authorization: Bearer $TBL_PAY_API_KEY"GET returns id, reference, status, version, amount, currency, allowed_assets and created_at. It does not return the checkout URL. Save the original create response. Use decimal strings or integer minor units; avoid floating-point equality when validating totals.
Idempotency and retries
Idempotency-Key is required for creating payment intents and attempts, with 8–128 characters. Repeating the same operation with the same key and payload returns its saved response. A changed payload returns HTTP 409 idempotency_conflict. Preserve the exact payload, including array ordering and amount formatting. Payment references are unique within a store; a new key with an already-used reference returns reference_already_exists.
After a timeout or 5xx response, retry with exponential backoff and jitter using the same key. Do not create another order or change keys merely because a response was lost. On 429, honor Retry-After if present and back off; the error envelope currently marks only 5xx as retryable, so 429 has retryable: false. Fix 400/401/403/422 errors rather than retrying them indefinitely.
An attempt replay returns the original quote, even if it has expired, and currently still checks rate availability first. Inspect expires_at every time. Use a new attempt key for a deliberately refreshed quote. Never display an expired address/amount as valid instructions. Multiple funded attempts or ambiguous allocation can require review.
Merchant endpoints
| Method and path | Scope | Result |
POST /v1/payment-intents | payments:write | 201 create response; store key required |
GET /v1/payment-intents/{id} | payments:read | Current payment |
GET /v1/payment-intents | payments:read | Latest 100 payments in data |
POST /v1/payment-intents/{id}/attempts | payments:write | Quote and receiving instructions |
POST /v1/payment-intents/{id}/cancel | payments:write | Current payment after conditional cancellation |
GET /v1/assets | payments:read | Configured enabled assets in data |
GET /v1/wallets | wallets:read | Latest 100 nondeleted wallets in data |
GET /v1/wallets/{id}/receipts | wallets:read | Latest 100 receipts in data |
GET /v1/billing/summary | billing:read | Legacy EUR aggregate |
GET /v1/billing/invoices/{id} | billing:read | Legacy invoice; merchant-wide key required |
GET /v1/webhook-events | webhooks:read | Latest 100 event metadata records in data |
Payment, receipt and webhook-event lists support cursor pagination and creation-time filters. Wallet listing retains its latest-100 limit. Event metadata is id, type, created_at; it is not a replay API or full event payload. Do not use a latest-100 list as a complete financial ledger export.
Cancellation has no request body. It changes pending or expired payments to cancelled; other states are returned unchanged. It neither sends funds nor refunds a transfer. A later received payment may need review. Do not assume every cancellation generates a webhook; use the response and reconcile outstanding order IDs.
Quotes, currencies and networks
POST /v1/payment-intents/{id}/attempts accepts only {"asset_id":"btc:mainnet:native"} and the idempotency header. It returns payment_id, attempt_id, asset_id, recipient, amount_atomic, decimals, expires_at and network. Convert atomic amounts with integer arithmetic: BTC has 8 decimals, XMR 12, ETH 18, USDT 6. amount_atomic is a string, never a floating-point number. Quotes last 15 minutes.
Rates are separate from blockchain verification. The current CoinGecko public-mode service refreshes rates on a five-minute schedule. Quote selection requires a source timestamp no older than ten minutes and a local fetch no older than six minutes, neither in the future. A missing/stale rate fails closed with rate_stale; never invent a fallback rate in your shop. CoinGecko does not generate addresses or confirm transfers.
Asset IDs include the exact network: btc:mainnet:native, btc:regtest:native, xmr:mainnet:native, xmr:stagenet:native, ethereum:sepolia:native, ethereum:sepolia:usdt, tron:shasta:usdt. Catalog membership or /v1/assets visibility alone does not prove a network is operational. Mainnet qualification and a ready receiving wallet are checked separately. As of this documentation's review, BTC and XMR mainnet are enabled for real-coin testing with synchronized receiving wallets; Ethereum and Tron mainnet remain unavailable. TRX is not a supported payment flow. Check deployment readiness before accepting money.
Use hosted checkout for Ethereum/Tron because shared-address matching requires the payer-signature flow and independent chain verification. Do not build a bare address-only checkout for these chains. Bitcoin uses derived receiving addresses. Monero uses view-only monitoring and checkout subaddresses, without a spend key. Its supported fee scope is checkout receipts, not an assertion that every private-wallet inflow is known.
Payment states
| State | Shop behavior |
| pending | Await payment; no fulfillment |
| confirming | A transfer/partial amount is observed or awaiting qualification; no fulfillment |
| paid | Confirmed eligible amount meets the quote; fulfill once after API verification |
| expired | Quote expired without a qualifying payment; request fresh instructions if continuing |
| cancelled | Payment was cancelled; do not fulfill automatically |
| review | Ambiguity, late funds, reorganization or other exception; manual resolution |
BTC policy requires 3 confirmations; Monero requires 10 and an unlocked qualifying receipt. Ethereum/Tron use their configured finalized/solidified verification, not CoinGecko. States are not a simple irreversible progression: paid can become review after chain evidence changes. version increases when the stored state changes. Read the latest API state instead of allowing delayed webhooks to overwrite newer local state. Fulfillment, stock and notifications need a durable once-only guard in your shop database.
Signed webhooks
Headers: X-Pay-Event-Id, X-Pay-Timestamp (Unix seconds), X-Pay-Key-Id (currently v1) and X-Pay-Signature (hex SHA-256 HMAC). The signed bytes are the timestamp, a literal period, then the exact raw request body. Do not parse and reserialize before verifying. Accept a timestamp only within 300 seconds of your clock and compare signatures in constant time.
<?php
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_PAY_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_PAY_SIGNATURE'] ?? '';
$secret = getenv('TBL_PAY_WEBHOOK_SECRET');
if (!$secret || strlen($raw) > 32768 ||
!preg_match('/^[0-9]{10}$/D', $ts) ||
!preg_match('/^[a-f0-9]{64}$/iD', $sig) ||
abs(time() - (int)$ts) > 300 ||
!hash_equals(hash_hmac('sha256', $ts . '.' . $raw, $secret), strtolower($sig))) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
// Validate event_id/type/schema_version/data.payment_id.
// Insert into a durable inbox with UNIQUE(event_id), committing before 2xx.
// On storage failure return 5xx. A duplicate already stored can return 2xx.
// A worker fetches the payment, validates the saved order, and applies it once.This is signature-verification code, not a complete inbox implementation. Use the bundled PHP SDK/inbox or WooCommerce plugin for the rest of the workflow. Do not add an unconditional success response before durable storage.
{"event_id":"7392e9a6-47f3-49d5-8527-b45001cb128e","type":"payment.paid","schema_version":1,"occurred_at":"2026-10-17T12:00:00.000Z","data":{"payment_id":"b3587719-1ba0-4f98-9599-247658fd71ab","reference":"550e8400-e29b-41d4-a716-446655440000","status":"paid","version":3,"amount":"25.00","currency":"USD"}}Payment events use payment.pending, payment.confirming, payment.paid, payment.expired or payment.review_required according to the transition. Treat extra event fields as extensible. Do not require all optional diagnostic data fields; the payment ID plus a subsequent API lookup is the authority. Verify the event header matches its body ID. Unknown event types should be stored/handled without executing financial actions.
Any 2xx acknowledges delivery. Other outcomes retry up to 16 total attempts with exponential delays starting at 60 seconds, capped at six hours; then delivery is marked dead. Duplicates and out-of-order deliveries are possible. Delivery is not guaranteed indefinitely. Maintain a reconciliation job over your own pending order/payment IDs using GET payment. The worker pins validated public DNS addresses; private/local endpoints and non-HTTPS destinations are unsuitable. Merchant-wide legacy billing events are not routed to store webhook endpoints by the current store-based dispatcher.
Errors
{"error":{"code":"rate_stale","message":"rate_stale","request_id":"REQUEST_ID","retryable":true}}| HTTP | Common code | Action |
| 400 | invalid_request, invalid_amount | Correct JSON, UUIDs, headers or amounts |
| 401 | invalid_credential | Check/reissue the server-side key |
| 403 | insufficient_scope, store_key_required, merchant_scope_required | Use the correct credential scope |
| 404 | resource_not_found | Check saved ID and ownership |
| 409 | idempotency_conflict, reference_already_exists, state_conflict | Reconcile the existing operation |
| 422 | asset_not_enabled | Correct store asset configuration |
| 429 | rate_limited | Back off; honor Retry-After if supplied |
| 503 | mainnet_not_qualified, wallet_not_synced, rate_stale, address_pool_exhausted | Stop offering unavailable instructions; retry only when service is ready |
| 500 | internal_error | Retain request ID and retry safely |
This is not an exhaustive list. Unknown asset identifiers currently can surface as internal_error; select identifiers from /v1/assets. The application error envelope does not guarantee the shape of reverse-proxy/network errors. Never log Authorization headers, checkout tokens or full webhook bodies with customer-related references.
Billing and refunds
The platform fee is 50 basis points (0.5%) of eligible monitored inflows in the accepted scope. API keys identify stores; independent chain receipts establish inflows. The panel's current monthly USD fee workflow is distinct from /v1/billing/summary and /v1/billing/invoices/{id}, which expose the legacy EUR journal. Do not label fee_eur as a current USD monthly debt. No supported public /v1 endpoint currently starts a monthly fee payment.
Funds are not debited automatically. Refunds are manual merchant transfers, with reporting in the portal. There is no public /v1 automatic refund/spend endpoint, and recording a refund hash does not establish independently confirmed refund settlement. The WooCommerce plugin does not automatically send or synchronize cryptocurrency refunds.
Server-side JavaScript
// Run on your shop server. reference and idempotencyKey must already be saved.
async function createPayment({reference, amount, idempotencyKey}) {
const response = await fetch('https://tbl-pay.com/v1/payment-intents', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TBL_PAY_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify({reference, amount, currency: 'USD',
allowed_assets: ['btc:mainnet:native']}),
signal: AbortSignal.timeout(15000)
});
const data = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${data.error?.code}`);
return data; // Persist id and checkout_url before redirecting the customer.
}For WooCommerce install the plugin ZIP, enter the server API key and webhook secret in its settings, select portal-managed assets, and use its connection check. Configure a reliable WP cron runner. The portal's PHP integration generator offers form/popup previews and USD/EUR selection. For an existing shop, download the PHP SDK and durable webhook example. It includes persistent order mappings, signed webhook reception, retries and periodic reconciliation. Connect the documented fulfillment queue to your shop's own idempotent delivery code; it does not deliver goods automatically. Keep configuration and database files outside public web access.
Integration acceptance checklist
- Same create request retried twice returns one payment; changed payload with the same key conflicts.
- Wrong/revoked key and another store's payment ID cannot retrieve or modify the order.
- No API secret appears in browser requests, page source or downloadable configuration.
- Expired/stale quotes are hidden; wrong network and unsupported assets cannot be offered as valid payment instructions.
- Invalid signatures and stale timestamps are rejected; duplicate events change stock/fulfillment only once.
- API outage or webhook loss leaves the order unpaid until independently reconciled.
- Partial, late, cancelled and review states do not trigger automatic fulfillment.
- Complete one real funded test for each network before claiming that network is production-qualified.
The prepared WooCommerce BTC test still requires the merchant's actual payment. Documentation and simulated tests do not replace this funded verification.
History pagination and filters
GET /v1/payment-intents, GET /v1/wallets/{id}/receipts and GET /v1/webhook-events return data, has_more and nullable next_cursor. Existing data readers remain compatible. Use limit from 1 to 100 (default 100), then pass cursor=next_cursor to load older rows. Stop when the cursor is null. Ordering uses creation time and ID, preserving database timestamp precision.
All three accept created_from (inclusive) and created_to (exclusive), ISO timestamps with UTC or an offset. These filter ingestion/creation time, not blockchain block time. Payments also accept status and store_id; events accept type and store_id; wallet receipts accept asset_id. Store keys cannot select another store. Invalid queries/cursors return HTTP 400. Never use a wallet ID as a substitute for authorization.
Keep filters unchanged across pages. Newer inserts do not shift the cursor position, but the result is not an immutable snapshot: statuses and classifications can change. Reconcile individual outstanding payments through their GET endpoint. A cursor is opaque pagination state, never a credential.