# Stablora API > Crypto payment API: invoices priced in USD, hosted checkout, reusable payment links, permanent user wallets, payouts with approval rules, refunds and signed webhooks. Sandbox/testnet stage — no real funds. Base URL: https://localhost:3000/api/v1 · Auth: Authorization: Bearer qk_test_… · OpenAPI: https://localhost:3000/docs/openapi.json ## Getting started ### Introduction Stablora is a custodial crypto payment API for shops, SaaS, casinos, games and marketplaces. Accept coins with hosted checkout, give every user a permanent deposit address, pay users out over the API and optionally convert everything to a stablecoin. | --- | --- Base URL | `https://localhost:3000/api/v1` Format | JSON over HTTPS. Amounts are decimal strings, never floats. Auth | `Authorization: Bearer qk_live_…` (live) or `qk_test_…` (testnets) Webhooks | Signed with HMAC-SHA256 (`Stablora-Signature`) Machine-readable | [OpenAPI 3.1](/docs/openapi.json) · [llms.txt](/llms.txt) > Live keys (`qk_live_…`) settle real funds on mainnets: TRON, BNB Chain, Ethereum, Polygon, Arbitrum, Base, Optimism, Avalanche, Bitcoin, Litecoin, Solana, Dogecoin and Monero. Test keys (`qk_test_…`) only reach testnets, whose coins have no value. A local development install runs simulated sandbox networks instead. See [Environments](#environments). ### How a payment works - Your server creates an invoice (`POST /payments`) priced in USD. Stablora locks the coin amount at the current market price and assigns a unique deposit address for that order. - You redirect the customer to the hosted checkout (`paymentUrl`) — or, recommended, create a checkout session (`POST /checkout-sessions`) and let the customer pick the coin and network themselves. - The customer sends coins. Stablora waits for confirmations, credits your balance minus the 0.5% processing fee and sends a signed `payment.completed` webhook. - You fulfil the order after verifying the webhook signature and, ideally, re-reading `GET /payments/{id}`. ### Quickstart Take a first payment in five minutes. Use a test key and a testnet while you build, then switch to a live key. ### 1. Create an API key Sign in, open **Developers → API keys** and create a key with the permissions you need. The secret is shown once. Keep it on your server only. ### 2. Create a checkout (customer chooses the coin) ```bash curl -X POST https://localhost:3000/api/v1/checkout-sessions \ -H "Authorization: Bearer $STABLORA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"reference":"order-1042","amount":"49.90","currency":"USD","successUrl":"https://shop.example/thanks"}' ``` Without `options` the customer sees every coin and network your key may use. To fix the coin yourself, create an invoice instead: `POST /payments` with `network` and `asset` (e.g. `tron` / `USDT`). ### 3. Send the customer to checkout Redirect to `url` from the response. The customer picks a coin, then a network; Stablora locks the amount, shows a deposit address just for this order, a QR code and live status. ### 4. Pay Live key: pay from any wallet. Test key: send faucet coins on a testnet (see [Testing guide](/docs/testing)). On a local sandbox install you can simulate the transfer: ```bash curl -X POST https://localhost:3000/api/v1/test/deposits \ -H "Authorization: Bearer $STABLORA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"paymentId":"pay_…","amount":"49.911414","transactionHash":"sim_order_1042"}' ``` ### 5. Receive the webhook Set your webhook URL in **Settings → Webhook destination**, verify the signature (see [Verify signatures](#verify-signatures)) and fulfil on `payment.completed`. ### Authentication Send your secret key as a bearer token on every request. Live keys start with `qk_live_`, test keys with `qk_test_`. Keys are stored hashed; a lost key cannot be recovered — revoke it and create a new one. ```http Authorization: Bearer qk_live_3f9c… ``` Key | Reaches | Use for --- | --- | --- `qk_live_…` | Live mainnets only — real funds | Your production store or app `qk_test_…` | Testnets only — coins without value | Development, staging, CI A key never reaches the other environment: a test key gets `403` on a live network and a live key on a testnet. Every webhook carries `mode` (`live`, `testnet` or `sandbox`), so one endpoint can serve both. ### Permissions Permission | Allows --- | --- `read` | All GET requests and `POST /payouts/quote`. `payments` | Create customers, wallets, invoices, checkout sessions, payment links, test deposits. `payouts` | Create, approve and cancel payouts and refunds. Give each system only what it needs: a storefront needs `read` + `payments`; only a trusted back office should hold `payouts`. ### IP allowlist In **Security → IP allowlist** you can restrict all API keys and dashboard sign-in to specific IPs or CIDR ranges. Each key can additionally have its own list (set when you create it) — for example your payout server’s IP for the key with the `payouts` permission. A request must pass both lists; an empty list allows any IP. Blocked attempts are recorded in the security log. ### Dashboard-only operations Creating API keys, revealing the webhook secret, team management, two-factor settings and automatic withdrawal rules require a signed-in dashboard session (and a fresh 2FA code when enabled). They cannot be done with an API key. > Never put a secret key in browser or mobile code. The hosted checkout pages need no key. ### Live, testnets & sandbox Build against testnets with a test key, then switch your integration to a live key. The API is identical; only the key and the network ids differ. | Live | Testnets | Sandbox (local install) --- | --- | --- | --- Key | `qk_live_…` | `qk_test_…` | `qk_test_…` Networks | `tron`, `bnb`, `ethereum`, `polygon`, `arbitrum`, `base`, `optimism`, `avalanche`, `bitcoin`, `litecoin`, `solana`, `dogecoin`, `monero` | `sepolia`, `base-sepolia`, `tron-nile`, `solana-devnet`, `bitcoin-testnet`… | The mainnet ids, simulated Addresses | A real deposit address per invoice | Real testnet addresses | Simulated `qtest_…` identifiers Deposits | Real transfers, credited after confirmations | Faucet coins, credited after confirmations | `POST /test/deposits`, instant Payouts | Real signed transactions | Real signed testnet transactions | Simulated, instant The public platform runs Live and Testnets. The sandbox exists only when you run Stablora locally for development. Faucets and a go-live checklist: [Testing guide](/docs/testing). ### Simulating edge cases (sandbox) - Underpaid / overpaid: send a smaller or larger `amount` to `POST /test/deposits`. - Late: pay after `expires_at`. - Duplicate delivery: repeat the same `transactionHash` — it is never credited twice. - Chain reorganization: `POST /test/deposits/{id}/reverse` sends `payment.reversed`. ### Amounts, ids & pagination ### Amounts Amounts are decimal strings in the asset (`"49.911414"`), never floats. Every object also carries exact integer `*_units` in the asset’s smallest unit (`decimals` tells you the scale). Balances are never merged across networks: USDT on TRON and USDT on Ethereum are different balances. ### Ids Prefix | Object --- | --- `pay_` | Payment (invoice) `cs_` | Checkout session `pl_` | Payment link `cus_` | Customer `wal_` | Customer wallet `mwl_` | Merchant top-up wallet `dep_` | Deposit `out_` | Payout or refund `swp_` | Swap `evt_` | Event ### Idempotency `POST /payments`, checkout sessions and customers are idempotent by `reference` / `externalId`. Payouts and refunds require an `Idempotency-Key` header: retry with the same key and you get the original payout back; the same key with different details returns 409. ### Pagination List endpoints return `{ "data": [...], "nextCursor": "…" }`, newest first. Pass `?cursor=` to get the next page and `?limit=` (1–200, default 50). `nextCursor` is `null` on the last page. ### Rate limits 300 requests per minute per merchant. Public checkout endpoints are limited per IP. Exceeding a limit returns 429. ## Payments ### Create a payment `POST /api/v1/payments` — permission: payments Creates an invoice with its own deposit address. With `currency: "USD"` the coin amount is calculated from the live market price and locked for the life of the invoice — later price moves never change what the customer owes. Body parameters: - `reference` (string, required): Your unique order id (max 120). Repeating it returns the same invoice. - `amount` (decimal string, required): Fiat amount when `currency` is set (USD, EUR or GBP), otherwise the amount in the asset. - `currency` ("USD" | "EUR" | "GBP"): Price the invoice in fiat; the coin amount is locked at the current rate. Omit to charge a fixed crypto amount. - `network` (string, required): Network id, e.g. `tron`, `ethereum`, `solana`, `sepolia`. See [Networks & assets](#networks). - `asset` (string, required): Asset on that network, e.g. `USDT`. - `description` (string): Shown on the checkout page (max 200). - `customerId` (string): Attach the invoice to a customer (`cus_…`). - `expirationMinutes` (integer): 5–1440, default 60. Funds after expiry are still credited as `late`. Example: ```bash curl -X POST "https://localhost:3000/api/v1/payments" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{"reference":"order-1042","amount":"49.90","currency":"USD","network":"tron","asset":"USDT","description":"Order #1042"}' ``` Response 201: ```json { "id": "pay_c18df35f8fe0c08ccd846672", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "reference": "order-1042", "description": "Order #1042", "customer_id": null, "network": "tron", "asset": "USDT", "decimals": 6, "amount_units": "49911414", "received_units": "0", "fee_bps": 50, "status": "pending", "created_at": "2026-09-24T11:03:41.697Z", "expires_at": "2026-09-24T12:03:41.697Z", "expiresAt": "2026-09-24T12:03:41.697Z", "paid_at": null, "underpaid_tolerance_bps": 0, "fee_payer": "merchant", "base_amount_units": "49911414", "amount": "49.911414", "received": "0", "baseAmount": "49.911414", "feePayer": "merchant", "underpaidTolerancePercent": 0, "paymentUrl": "/pay/pay_c18df35f8fe0c08ccd846672", "depositAddress": "qtest_tron_3e5f8507d9ffc1d52aaf921d1a8c3f155cd5fc54", "refunded": "0", "mode": "sandbox", "pricing": { "currency": "USD", "usdAmount": "49.9", "priceUsd": "0.999771324867", "priceUpdatedAt": "2026-09-24T11:02:10.000Z", "quotedAt": "2026-09-24T11:03:41.693Z", "lockedUntil": "2026-09-24T12:03:41.697Z", "locked": true, "source": "CoinGecko" } } ``` ### Retrieve a payment `GET /api/v1/payments/{id}` — permission: read The authoritative state of an invoice. Re-read it after a webhook before fulfilling. Path parameters: - `id` (string, required): Payment id `pay_…`. Example: ```bash curl "https://localhost:3000/api/v1/payments/pay_c18df35f8fe0c08ccd846672" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "id": "pay_c18df35f8fe0c08ccd846672", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "reference": "order-1042", "description": "Order #1042", "customer_id": null, "network": "tron", "asset": "USDT", "decimals": 6, "amount_units": "49911414", "received_units": "49911414", "fee_bps": 50, "status": "completed", "created_at": "2026-09-24T11:03:41.697Z", "expires_at": "2026-09-24T12:03:41.697Z", "expiresAt": "2026-09-24T12:03:41.697Z", "paid_at": "2026-09-24T11:03:41.744Z", "underpaid_tolerance_bps": 0, "fee_payer": "merchant", "base_amount_units": "49911414", "amount": "49.911414", "received": "49.911414", "baseAmount": "49.911414", "feePayer": "merchant", "underpaidTolerancePercent": 0, "paymentUrl": "/pay/pay_c18df35f8fe0c08ccd846672", "depositAddress": "qtest_tron_3e5f8507d9ffc1d52aaf921d1a8c3f155cd5fc54", "refunded": "0", "mode": "sandbox", "pricing": { "currency": "USD", "usdAmount": "49.9", "priceUsd": "0.999771324867", "priceUpdatedAt": "2026-09-24T11:02:10.000Z", "quotedAt": "2026-09-24T11:03:41.693Z", "lockedUntil": "2026-09-24T12:03:41.697Z", "locked": true, "source": "CoinGecko" } } ``` ### List payments `GET /api/v1/payments` — permission: read Invoices, newest first. Query parameters: - `status` (string): Filter by status. - `customerId` (string): Only this customer. - `limit` (integer): 1–200, default 50. - `cursor` (string): `nextCursor` from the previous page. Example: ```bash curl "https://localhost:3000/api/v1/payments?status=completed&limit=20" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "data": [ { "id": "pay_c18df35f8fe0c08ccd846672", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "reference": "order-1042", "description": "Order #1042", "customer_id": null, "network": "tron", "asset": "USDT", "decimals": 6, "amount_units": "49911414", "received_units": "49911414", "fee_bps": 50, "status": "completed", "created_at": "2026-09-24T11:03:41.697Z", "expires_at": "2026-09-24T12:03:41.697Z", "expiresAt": "2026-09-24T12:03:41.697Z", "paid_at": "2026-09-24T11:03:41.744Z", "underpaid_tolerance_bps": 0, "fee_payer": "merchant", "base_amount_units": "49911414", "amount": "49.911414", "received": "49.911414", "baseAmount": "49.911414", "feePayer": "merchant", "underpaidTolerancePercent": 0, "paymentUrl": "/pay/pay_c18df35f8fe0c08ccd846672", "depositAddress": "qtest_tron_3e5f8507d9ffc1d52aaf921d1a8c3f155cd5fc54", "refunded": "0", "mode": "sandbox", "pricing": { "currency": "USD", "usdAmount": "49.9", "priceUsd": "0.999771324867", "priceUpdatedAt": "2026-09-24T11:02:10.000Z", "quotedAt": "2026-09-24T11:03:41.693Z", "lockedUntil": "2026-09-24T12:03:41.697Z", "locked": true, "source": "CoinGecko" } } ], "nextCursor": "MjAyNi0wOS0yNFQxMTowMzo0MS43MTdafHBheV8xNjVj…" } ``` ### Payment statuses Status | Meaning | Fulfil? --- | --- | --- `pending` | Waiting for funds. | No `completed` | Full amount received (or within your underpayment tolerance). | **Yes** `underpaid` | Less than the amount arrived. More can still be sent until expiry. | Review `overpaid` | More than the amount arrived. The surplus is in your balance; refund it if needed. | Yes, then refund surplus `late` | Funds arrived after `expires_at`. They are credited but flagged. | Review `expired` | Nothing arrived before `expires_at`. | No `reversed` | A chain reorganization undid the credit. See [Reversals](#reversals). | No — hold the order `held` | Funds came from a sanctioned address and are frozen pending compliance review. See [Address screening](#screening). | No Only `completed` (and an `overpaid` you accept) should trigger fulfilment. Never trust a status coming from the customer’s browser or a redirect. ### Preview a USD quote `POST /api/v1/quotes` — permission: payments Shows how much of an asset a USD amount buys right now, without creating an invoice. Quotes are indicative; the invoice locks its own price at creation. Body parameters: - `network` (string, required): Network id. - `asset` (string, required): Asset. - `usdAmount` (decimal string, required): USD amount. Example: ```bash curl -X POST "https://localhost:3000/api/v1/quotes" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{"network":"bitcoin","asset":"BTC","usdAmount":"100"}' ``` Response 200: ```json { "network": "bitcoin", "asset": "BTC", "usdAmount": "100", "usdCents": "10000", "amount": "0.00119688", "amountUnits": "119688", "priceUsd": "83551.147838655772", "priceUpdatedAt": "2026-09-24T11:02:10.000Z", "quotedAt": "2026-09-24T11:03:41.731Z", "quoteExpiresAt": "2026-09-24T11:04:41.731Z", "source": "CoinGecko" } ``` ### Create a checkout session `POST /api/v1/checkout-sessions` — permission: payments A USD order where the customer chooses the coin on the hosted page. When they choose, a normal locked invoice is created for that option. Redirect the customer to `url`. Body parameters: - `reference` (string, required): Your order id (max 100). Idempotent. - `amount` (decimal string, required): Amount in `currency`. - `currency` ("USD" | "EUR" | "GBP"): Default USD. - `description` (string): Shown to the customer. - `options` (array): List of `{ network, asset }` to offer. Default: every coin and network your key may use (a live key: all live options; a test key: all testnets). - `successUrl` (string): Where the “Return to store” button leads after payment. - `customerId` (string): Attach resulting invoices to a customer. - `expirationMinutes` (integer): 5–1440, default 60. Example: ```bash curl -X POST "https://localhost:3000/api/v1/checkout-sessions" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{"reference":"cart-5531","amount":"19.99","successUrl":"https://shop.example/thanks","options":[{"network":"tron","asset":"USDT"},{"network":"bitcoin","asset":"BTC"},{"network":"solana","asset":"SOL"}]}' ``` Response 201: ```json { "id": "cs_cc25b6efa956b4911ea9920a", "merchant_name": "Orbit Commerce", "brand": { "color": null, "supportEmail": null }, "description": "cart-5531", "usdAmount": "19.99", "options": [ { "network": "tron", "asset": "USDT", "networkName": "TRON", "testnet": false }, { "network": "bitcoin", "asset": "BTC", "networkName": "Bitcoin", "testnet": false }, { "network": "solana", "asset": "SOL", "networkName": "Solana", "testnet": false } ], "expires_at": "2026-09-24T12:03:41.800Z", "expired": false, "payment": null, "url": "https://localhost:3000/checkout/cs_cc25b6efa956b4911ea9920a" } ``` ### Create a payment link `POST /api/v1/payment-links` — permission: payments A reusable public link (`/l/{slug}`) for donations, social media sales or a fixed-price product. Every visitor gets their own checkout session, invoice and deposit address. Payments carry the reference `link:{slug}:…`. Body parameters: - `title` (string, required): Shown on the link page (max 100). - `amount` (decimal string): Fixed USD price. Omit to let the buyer choose. - `minAmount` (decimal string): Lowest amount when the buyer chooses (default 1). - `maxAmount` (decimal string): Highest amount when the buyer chooses (default 10000, max 1,000,000). - `description` (string): Optional text (max 200). - `options` (array): Coins to offer, as for checkout sessions. - `successUrl` (string): Return URL after payment. Example: ```bash curl -X POST "https://localhost:3000/api/v1/payment-links" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{"title":"Support the stream","minAmount":"2","maxAmount":"500"}' ``` Response 201: ```json { "id": "pl_1e7aac13b02f2ca4635607cf", "slug": "xnt1_yKaKiAH", "url": "/l/xnt1_yKaKiAH", "title": "Support the stream", "description": null, "active": true, "amount": null, "minAmount": "2", "maxAmount": "500", "options": [ { "network": "tron", "asset": "USDT" }, "…" ], "successUrl": null, "sessions": 0, "paidCount": 0, "paidUsd": "0", "created_at": "2026-09-24T11:03:41.820Z" } ``` ### List payment links `GET /api/v1/payment-links` — permission: read All links with usage statistics: sessions started, paid count and paid USD. Example: ```bash curl "https://localhost:3000/api/v1/payment-links" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "data": [ { "id": "pl_1e7aac13b02f2ca4635607cf", "slug": "xnt1_yKaKiAH", "url": "/l/xnt1_yKaKiAH", "title": "Support the stream", "active": true, "amount": null, "minAmount": "2", "maxAmount": "500", "sessions": 14, "paidCount": 9, "paidUsd": "212.5" } ] } ``` ### Deactivate a payment link `POST /api/v1/payment-links/{id}/deactivate` — permission: payments Stops the link from accepting new buyers. Invoices already started are not affected. Path parameters: - `id` (string, required): Link id `pl_…`. Example: ```bash curl -X POST "https://localhost:3000/api/v1/payment-links/pl_1e7aac13b02f2ca4635607cf/deactivate" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{}' ``` Response 200: ```json { "id": "pl_1e7aac13b02f2ca4635607cf", "active": false, "…": "…" } ``` ### Activate a payment link `POST /api/v1/payment-links/{id}/activate` — permission: payments Turns a deactivated link back on. The URL stays the same. Path parameters: - `id` (string, required): Link id `pl_…`. Example: ```bash curl -X POST "https://localhost:3000/api/v1/payment-links/pl_1e7aac13b02f2ca4635607cf/activate" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{}' ``` Response 200: ```json { "id": "pl_1e7aac13b02f2ca4635607cf", "active": true, "…": "…" } ``` ### Simulate a deposit (sandbox) `POST /api/v1/test/deposits` — permission: payments Simulates an incoming transfer on a sandbox network. Refused on testnets — send real testnet coins there instead. Body parameters: - `paymentId | walletId | merchantWalletId` (string, required): What the transfer pays: an invoice, a permanent customer wallet or your top-up wallet. - `amount` (decimal string, required): Amount in the asset. - `asset` (string): Required for wallets; taken from the invoice otherwise. - `transactionHash` (string, required): Unique id such as `sim_order_1042`. Replays are ignored. - `fromAddresses` (array): Sender addresses, screened against sanctions lists — use a listed address to test `payment.held`. Example: ```bash curl -X POST "https://localhost:3000/api/v1/test/deposits" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{"paymentId":"pay_c18df35f8fe0c08ccd846672","amount":"49.911414","transactionHash":"sim_order_1042"}' ``` Response 201: ```json { "id": "dep_709ecb039bb5d29ece216883", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "wallet_id": null, "payment_id": "pay_c18df35f8fe0c08ccd846672", "customer_id": null, "network": "tron", "asset": "USDT", "decimals": 6, "amount_units": "49911414", "fee_units": "249558", "tx_hash": "sim_order_1042", "event_index": 0, "created_at": "2026-09-24T11:03:41.744Z", "merchant_wallet_id": null, "reversed_at": null, "reversal_reason": null, "amount": "49.911414", "fee": "0.249558", "net": "49.661856", "mode": "sandbox" } ``` ### Simulate a reorg (sandbox) `POST /api/v1/test/deposits/{id}/reverse` — permission: payments Undoes a sandbox deposit as if the block had been reorganized away, and sends `payment.reversed` (or `deposit.reversed`). Use it to test your handling. Path parameters: - `id` (string, required): Deposit id `dep_…`. Example: ```bash curl -X POST "https://localhost:3000/api/v1/test/deposits/dep_709ecb039bb5d29ece216883/reverse" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{}' ``` Response 200: ```json { "id": "dep_709ecb039bb5d29ece216883", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "wallet_id": null, "payment_id": "pay_c18df35f8fe0c08ccd846672", "customer_id": null, "network": "tron", "asset": "USDT", "decimals": 6, "amount_units": "49911414", "fee_units": "249558", "tx_hash": "sim_order_1042", "event_index": 0, "created_at": "2026-09-24T11:03:41.744Z", "merchant_wallet_id": null, "reversed_at": "2026-09-24T11:03:41.991Z", "reversal_reason": "Simulated chain reorganization (sandbox).", "amount": "49.911414", "fee": "0.249558", "net": "49.661856", "mode": "sandbox" } ``` ## Customers & wallets ### Permanent user wallets For casinos, games, exchanges and top-up flows each of your users gets one permanent deposit address per network. Anything sent to it — any amount, any time — is credited to that user and you receive `deposit.confirmed`. - Create the customer once with your own user id (`externalId`). - Assign a wallet per network. Repeating the call returns the same address. - Show the address in your app. Credit your user when you receive `deposit.confirmed`. Balance model (Settings → Withdrawals): **pooled** keeps one merchant balance you pay everyone from (typical for casinos); **per customer** keeps a separate ledger balance per user. ### Create a customer `POST /api/v1/customers` — permission: payments Idempotent by `externalId`: repeating it returns the existing customer. Different merchants can use the same `externalId` without sharing anything. Body parameters: - `externalId` (string, required): Your user id (max 120). - `name` (string): Display name (max 100). Example: ```bash curl -X POST "https://localhost:3000/api/v1/customers" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{"externalId":"player-8841","name":"Alex Morgan"}' ``` Response 201: ```json { "id": "cus_116f187959c5a838e125b339", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "external_id": "player-8841", "name": "Alex Morgan", "created_at": "2026-09-24T11:03:41.255Z" } ``` ### List customers `GET /api/v1/customers` — permission: read Customers, newest first. Query parameters: - `limit` (integer): 1–200, default 50. - `cursor` (string): Next page. Example: ```bash curl "https://localhost:3000/api/v1/customers" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "data": [ { "id": "cus_116f187959c5a838e125b339", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "external_id": "player-8841", "name": "Alex Morgan", "created_at": "2026-09-24T11:03:41.255Z" } ], "nextCursor": null } ``` ### Assign a wallet `POST /api/v1/wallets` — permission: payments Returns the customer’s permanent address on a network, creating it on first use. Body parameters: - `customerId` (string, required): Customer `cus_…`. - `network` (string, required): Network id. One address serves every asset on that network. Example: ```bash curl -X POST "https://localhost:3000/api/v1/wallets" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{"customerId":"cus_116f187959c5a838e125b339","network":"tron"}' ``` Response 201: ```json { "id": "wal_e3410ad818b1d6e3b45a8637", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "customer_id": "cus_116f187959c5a838e125b339", "network": "tron", "address": "qtest_tron_4295b8b9889b70e74451d4e501739c5b7c330163", "created_at": "2026-09-24T11:03:41.270Z" } ``` ### List wallets `GET /api/v1/wallets` — permission: read Wallets, optionally for one customer. Query parameters: - `customerId` (string): Only this customer. - `limit` (integer): 1–200, default 50. - `cursor` (string): Next page. Example: ```bash curl "https://localhost:3000/api/v1/wallets?customerId=cus_116f187959c5a838e125b339" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "data": [ { "id": "wal_e3410ad818b1d6e3b45a8637", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "customer_id": "cus_116f187959c5a838e125b339", "network": "tron", "address": "qtest_tron_4295b8b9889b70e74451d4e501739c5b7c330163", "created_at": "2026-09-24T11:03:41.270Z" } ], "nextCursor": null } ``` ### Customer balances `GET /api/v1/customers/{id}/balances` — permission: read A customer’s ledger balances per network and asset (per-customer balance model). Path parameters: - `id` (string, required): Customer `cus_…`. Example: ```bash curl "https://localhost:3000/api/v1/customers/cus_116f187959c5a838e125b339/balances" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "data": [ { "network": "tron", "asset": "USDT", "decimals": 6, "available": "119.4", "reserved": "0" } ] } ``` ## Balances & payouts ### Balances `GET /api/v1/balances` — permission: read Your total balances per network and asset. `available` can be paid out; `reserved` is held by pending payouts. Example: ```bash curl "https://localhost:3000/api/v1/balances" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "data": [ { "network": "tron", "asset": "USDT", "decimals": 6, "available": "1809.816856", "reserved": "50" }, { "network": "bitcoin", "asset": "BTC", "decimals": 8, "available": "0.0412", "reserved": "0" } ] } ``` ### Unallocated balances `GET /api/v1/balances/unallocated` — permission: read Only the balance that belongs to no customer: the pooled payout pool, top-ups and invoice income. With the per-customer model this is what you can withdraw for yourself. Example: ```bash curl "https://localhost:3000/api/v1/balances/unallocated" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "data": [ { "network": "tron", "asset": "USDT", "decimals": 6, "available": "1690.416856", "reserved": "0" } ] } ``` ### List deposits `GET /api/v1/deposits` — permission: read Every credited transfer with fee and net amount. `reversed_at` is set if a reorganization undid it. Query parameters: - `customerId` (string): Only this customer. - `limit` (integer): 1–200, default 50. - `cursor` (string): Next page. Example: ```bash curl "https://localhost:3000/api/v1/deposits" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "data": [ { "id": "dep_709ecb039bb5d29ece216883", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "wallet_id": null, "payment_id": "pay_c18df35f8fe0c08ccd846672", "customer_id": null, "network": "tron", "asset": "USDT", "decimals": 6, "amount_units": "49911414", "fee_units": "249558", "tx_hash": "sim_order_1042", "event_index": 0, "created_at": "2026-09-24T11:03:41.744Z", "merchant_wallet_id": null, "reversed_at": null, "reversal_reason": null, "amount": "49.911414", "fee": "0.249558", "net": "49.661856", "mode": "sandbox" } ], "nextCursor": null } ``` ### Get a top-up wallet `POST /api/v1/topup-wallets` — permission: payments Your own deposit address for funding the payout pool on a network (created on first use, then reused). Top-ups carry no processing fee and emit `topup.confirmed`. Body parameters: - `network` (string, required): Network id. Example: ```bash curl -X POST "https://localhost:3000/api/v1/topup-wallets" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{"network":"tron"}' ``` Response 201: ```json { "id": "mwl_59a8b99deaaebb5dafcf64c7", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "network": "tron", "address": "qtest_tron_9bef0c0f94c136b5dc51f3d492d49bf77c45cfde", "created_at": "2026-09-24T11:03:41.928Z" } ``` ### List top-up wallets `GET /api/v1/topup-wallets` — permission: read All your top-up addresses. Example: ```bash curl "https://localhost:3000/api/v1/topup-wallets" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "data": [ { "id": "mwl_59a8b99deaaebb5dafcf64c7", "network": "tron", "address": "qtest_tron_9bef0c0f94c136b5dc51f3d492d49bf77c45cfde", "created_at": "2026-09-24T11:03:41.928Z" } ] } ``` ### Payouts & withdrawals Pay your users (casino and game withdrawals, marketplace sellers) or yourself. The full requested amount leaves your balance; the **recipient pays** the network fee and your withdrawal commission, both deducted from what is sent. Quote first to show the user the net amount. Status | Meaning --- | --- `pending_approval` | Waiting for you to approve (manual mode, above your automatic limit, or held by a player-protection rule — see `hold_reason`). `pending_review` | Above the platform limit; the platform reviews it. `broadcasting` | Signed and sent to the network (testnets). Wait for confirmation. `completed` | Final. `tx_hash` is set. `cancelled` / `rejected` / `failed` | Nothing was sent; the reserved amount returned to your balance. ### Approval Settings → Withdrawals: **manual** (approve each in the dashboard or with `POST /payouts/{id}/approve`) or **automatic up to a USD limit** per withdrawal. Withdrawals without a fresh market price always wait for approval. ### Player protection Without KYC, limits matter. Send `customerId` with each user withdrawal and configure in Settings: a daily USD limit per player, a maximum number of withdrawals per player per day, and holding the first withdrawal to a new address. A withdrawal that trips a rule is never rejected — it waits for your approval with the reason in `hold_reason`. ### Quote a payout `POST /api/v1/payouts/quote` — permission: read Network fee, withdrawal commission and the net amount the recipient receives. Sends nothing. Body parameters: - `network` (string, required): Network id. - `asset` (string, required): Asset. - `amount` (decimal string, required): Amount leaving your balance. Example: ```bash curl -X POST "https://localhost:3000/api/v1/payouts/quote" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{"network":"tron","asset":"USDT","amount":"50"}' ``` Response 200: ```json { "network": "tron", "asset": "USDT", "amount": "50", "networkFee": "1", "withdrawalFee": "0.25", "withdrawalFeeBps": 50, "net": "48.75", "feeLabel": "SANDBOX FIXTURE: 1 USDT network fee", "mode": "sandbox" } ``` ### Create a payout `POST /api/v1/payouts` — permission: payouts — Idempotency-Key header required Reserves the amount and either sends it (automatic approval) or waits for approval. Always send an `Idempotency-Key` and reuse it when retrying. Body parameters: - `network` (string, required): Network id. - `asset` (string, required): Asset. - `amount` (decimal string, required): Amount leaving your balance (the recipient gets `net`). - `address` (string, required): Destination address, validated for the network. - `customerId` (string): The user being paid. Required for per-customer balances and player-protection rules. Example: ```bash curl -X POST "https://localhost:3000/api/v1/payouts" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{"network":"tron","asset":"USDT","amount":"50","address":"TXYZ…player-wallet","customerId":"cus_116f187959c5a838e125b339"}' ``` Response 201: ```json { "id": "out_32310a196885bf86be5926a4", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "customer_id": "cus_116f187959c5a838e125b339", "network": "tron", "asset": "USDT", "decimals": 6, "amount_units": "50000000", "address": "TXYZ…player-wallet", "status": "pending_approval", "idempotency_key": "withdrawal-8841", "created_at": "2026-09-24T11:03:41.867Z", "completed_at": null, "tx_hash": null, "network_fee_units": "1000000", "withdrawal_fee_units": "250000", "net_units": "48750000", "usd_cents": "4998", "approval": "manual", "fee_label": "SANDBOX FIXTURE: 1 USDT network fee", "kind": "payout", "payment_id": null, "reason": null, "hold_reason": null, "amount": "50", "networkFee": "1", "withdrawalFee": "0.25", "net": "48.75", "usdValue": "49.98", "mode": "sandbox" } ``` ### Retrieve a payout `GET /api/v1/payouts/{id}` — permission: read Current status, fees, `hold_reason` and `tx_hash`. Path parameters: - `id` (string, required): Payout `out_…`. Example: ```bash curl "https://localhost:3000/api/v1/payouts/out_32310a196885bf86be5926a4" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "id": "out_32310a196885bf86be5926a4", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "customer_id": "cus_116f187959c5a838e125b339", "network": "tron", "asset": "USDT", "decimals": 6, "amount_units": "50000000", "address": "TXYZ…player-wallet", "status": "completed", "idempotency_key": "withdrawal-8841", "created_at": "2026-09-24T11:03:41.867Z", "completed_at": "2026-09-24T11:05:02.114Z", "tx_hash": "sim_payout_out_32310a19", "network_fee_units": "1000000", "withdrawal_fee_units": "250000", "net_units": "48750000", "usd_cents": "4998", "approval": "manual", "fee_label": "SANDBOX FIXTURE: 1 USDT network fee", "kind": "payout", "payment_id": null, "reason": null, "hold_reason": null, "amount": "50", "networkFee": "1", "withdrawalFee": "0.25", "net": "48.75", "usdValue": "49.98", "mode": "sandbox" } ``` ### List payouts `GET /api/v1/payouts` — permission: read Payouts and refunds, newest first. Poll `status=pending_approval` to build an approval queue. Query parameters: - `status` (string): e.g. `pending_approval`, `completed`. - `customerId` (string): Only this player/customer. - `limit` (integer): 1–200, default 50. - `cursor` (string): Next page. Example: ```bash curl "https://localhost:3000/api/v1/payouts?status=pending_approval" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "data": [ { "id": "out_32310a196885bf86be5926a4", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "customer_id": "cus_116f187959c5a838e125b339", "network": "tron", "asset": "USDT", "decimals": 6, "amount_units": "50000000", "address": "TXYZ…player-wallet", "status": "pending_approval", "idempotency_key": "withdrawal-8841", "created_at": "2026-09-24T11:03:41.867Z", "completed_at": null, "tx_hash": null, "network_fee_units": "1000000", "withdrawal_fee_units": "250000", "net_units": "48750000", "usd_cents": "4998", "approval": "manual", "fee_label": "SANDBOX FIXTURE: 1 USDT network fee", "kind": "payout", "payment_id": null, "reason": null, "hold_reason": "Player daily limit of 500 USD exceeded", "amount": "50", "networkFee": "1", "withdrawalFee": "0.25", "net": "48.75", "usdValue": "49.98", "mode": "sandbox" } ], "nextCursor": null } ``` ### Approve a payout `POST /api/v1/payouts/{id}/approve` — permission: payouts Approves a `pending_approval` payout and sends it. Path parameters: - `id` (string, required): Payout `out_…`. Example: ```bash curl -X POST "https://localhost:3000/api/v1/payouts/out_32310a196885bf86be5926a4/approve" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{}' ``` Response 200: ```json { "id": "out_32310a196885bf86be5926a4", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "customer_id": "cus_116f187959c5a838e125b339", "network": "tron", "asset": "USDT", "decimals": 6, "amount_units": "50000000", "address": "TXYZ…player-wallet", "status": "completed", "idempotency_key": "withdrawal-8841", "created_at": "2026-09-24T11:03:41.867Z", "completed_at": null, "tx_hash": "sim_payout_out_32310a19", "network_fee_units": "1000000", "withdrawal_fee_units": "250000", "net_units": "48750000", "usd_cents": "4998", "approval": "manual", "fee_label": "SANDBOX FIXTURE: 1 USDT network fee", "kind": "payout", "payment_id": null, "reason": null, "hold_reason": null, "amount": "50", "networkFee": "1", "withdrawalFee": "0.25", "net": "48.75", "usdValue": "49.98", "mode": "sandbox" } ``` ### Cancel a payout `POST /api/v1/payouts/{id}/cancel` — permission: payouts Cancels a payout that has not been sent yet and returns the reserved amount to the balance. Sends `payout.cancelled`. Path parameters: - `id` (string, required): Payout `out_…`. Example: ```bash curl -X POST "https://localhost:3000/api/v1/payouts/out_32310a196885bf86be5926a4/cancel" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{}' ``` Response 200: ```json { "id": "out_32310a196885bf86be5926a4", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "customer_id": "cus_116f187959c5a838e125b339", "network": "tron", "asset": "USDT", "decimals": 6, "amount_units": "50000000", "address": "TXYZ…player-wallet", "status": "cancelled", "idempotency_key": "withdrawal-8841", "created_at": "2026-09-24T11:03:41.867Z", "completed_at": null, "tx_hash": null, "network_fee_units": "1000000", "withdrawal_fee_units": "250000", "net_units": "48750000", "usd_cents": "4998", "approval": "manual", "fee_label": "SANDBOX FIXTURE: 1 USDT network fee", "kind": "payout", "payment_id": null, "reason": null, "hold_reason": null, "amount": "50", "networkFee": "1", "withdrawalFee": "0.25", "net": "48.75", "usdValue": "49.98", "mode": "sandbox" } ``` ### Refund a payment `POST /api/v1/payments/{id}/refunds` — permission: payouts — Idempotency-Key header required Sends funds received on an invoice back to the customer, in the same asset and network. Refunds carry no withdrawal commission; the network fee is deducted. Several partial refunds are allowed up to the amount received. `GET /payments/{id}/refunds` lists them. Path parameters: - `id` (string, required): Payment `pay_…`. Body parameters: - `amount` (decimal string, required): Amount to refund. - `address` (string, required): Customer’s address. - `reason` (string): Shown in your records (max 200). Example: ```bash curl -X POST "https://localhost:3000/api/v1/payments/pay_c18df35f8fe0c08ccd846672/refunds" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{"amount":"10","address":"TCustomer…refund","reason":"Item out of stock"}' ``` Response 201: ```json { "id": "out_61de27325974e48bed220dbb", "merchant_id": "mch_4e425c9f49d92545a65c80ce", "customer_id": null, "network": "tron", "asset": "USDT", "decimals": 6, "amount_units": "10000000", "address": "TCustomer…refund", "status": "pending_approval", "idempotency_key": "refund-1042-1", "created_at": "2026-09-24T11:03:41.867Z", "completed_at": null, "tx_hash": null, "network_fee_units": "1000000", "withdrawal_fee_units": "0", "net_units": "9000000", "usd_cents": "999", "approval": "manual", "fee_label": "SANDBOX FIXTURE: 1 USDT network fee", "kind": "refund", "payment_id": "pay_c18df35f8fe0c08ccd846672", "reason": "Item out of stock", "hold_reason": null, "amount": "10", "networkFee": "1", "withdrawalFee": "0", "net": "9", "usdValue": "9.99", "mode": "sandbox" } ``` ### List refunds of a payment `GET /api/v1/payments/{id}/refunds` — permission: read Refunds of one invoice plus how much is still refundable. Path parameters: - `id` (string, required): Payment `pay_…`. Example: ```bash curl "https://localhost:3000/api/v1/payments/pay_c18df35f8fe0c08ccd846672/refunds" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "refundable": "39.911414", "refunded": "10", "data": [ { "id": "out_61de27325974e48bed220dbb", "kind": "refund", "status": "completed", "amount": "10", "net": "9", "reason": "Item out of stock", "…": "…" } ] } ``` ### Automatic withdrawals In **Balances → Automatic withdrawals** (dashboard only, 2FA required) you can sweep your own balance to your wallet: “when TRON USDT reaches 500, send everything above 50 to T…”. The worker checks every minute; platform limits still apply and you get an email whenever the destination changes. ### Account statement `GET /api/v1/statement` — permission: read Every balance movement with a running balance, from the double-entry ledger. `GET /statement.csv` with the same parameters downloads it as CSV for your accountant. Query parameters: - `from` (date): YYYY-MM-DD (inclusive). - `to` (date): YYYY-MM-DD (inclusive). Example: ```bash curl "https://localhost:3000/api/v1/statement?from=2026-09-01&to=2026-09-30" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "period": "2026-09-01 → 2026-09-30", "lines": [ { "date": "2026-09-24T11:03:41.744Z", "reference": "deposit:dep_709ecb039bb5d29ece216883", "description": "Deposit received (net of processing fee)", "account": "available", "customerId": null, "network": "tron", "asset": "USDT", "amount": "49.661856", "balance": "1859.478712" } ] } ``` ### Monthly fee invoice `GET /api/v1/invoices/{month}` — permission: read Processing, withdrawal and conversion fees charged in a month, per network and asset. The dashboard renders it as a printable invoice. Path parameters: - `month` (string, required): `YYYY-MM`. Example: ```bash curl "https://localhost:3000/api/v1/invoices/2026-09" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "number": "QC-202609-4E425C9F", "month": "2026-09", "periodStart": "2026-09-01T00:00:00.000Z", "periodEnd": "2026-10-01T00:00:00.000Z", "issuedAt": "2026-09-24T11:03:41.962Z", "customer": { "id": "mch_4e425c9f49d92545a65c80ce", "name": "Orbit Commerce", "email": "merchant@example.com" }, "lines": [ { "description": "Processing fee (0.5%)", "network": "tron", "asset": "USDT", "transactions": 9, "amount": "9.094558" }, "…" ] } ``` ## Webhooks ### Webhooks Stablora POSTs a JSON event to your endpoint whenever something happens. Set the URL in **Settings → Webhook destination** and copy the signing secret from **Developers**. ```json { "id": "evt_56706555c39438cb1106801f", "type": "payment.completed", "createdAt": "2026-09-24T11:03:41.745Z", "mode": "sandbox", "data": { "id": "pay_c18df35f8fe0c08ccd846672", "reference": "order-1042", "status": "completed", "amount": "49.911414", "received": "49.911414", "asset": "USDT", "network": "tron", "…": "…" } } ``` Header | Value --- | --- `Stablora-Signature` | `t=,v1=` `Stablora-Event-Id` | Same as `id` in the body `Content-Type` | `application/json` ### Delivery - Respond with any 2xx within 5 seconds. Do slow work after responding. - Failed deliveries are retried with exponential backoff (10 s, 20 s, 40 s … capped at 1 hour), 8 attempts in total, then marked failed. Replay failed events from the dashboard or with `POST /events/{id}/replay`. - Events can arrive more than once and out of order: store `id` and ignore repeats; always act on the current state from `GET /payments/{id}`. - Redirects are not followed. ### Event types Event | When | data --- | --- | --- `payment.completed` | The invoice received the full amount (or an amount within your underpayment tolerance). | Payment `payment.discrepancy` | Funds arrived but the invoice is underpaid, overpaid or late. Review before fulfilling. | Payment `payment.reversed` | A chain reorganization removed a credited transfer. The credit was undone; put the order back on hold. | Payment + reversedDeposit `payment.held` | The sender is on a sanctions list (OFAC SDN) or the platform blocklist. The funds are frozen for compliance review; do not fulfil. If released you get payment.completed. | Payment + reason `deposit.confirmed` | A transfer to an invoice or permanent customer wallet was credited. | Deposit `deposit.held` | A transfer to a customer wallet or top-up wallet came from a sanctioned sender and is frozen. | Deposit `deposit.reversed` | A credited transfer to a customer wallet or top-up wallet was reorganized away. | Deposit `topup.confirmed` | Your merchant top-up wallet received funds for the payout pool (no processing fee). | Deposit `payout.requested` | A payout or refund was created. Check status and hold_reason: it may wait for approval. | Payout `payout.broadcasting` | The payout was signed and sent to the network (testnets). Wait for payout.completed. | Payout `payout.completed` | The payout is final. tx_hash is set. | Payout `payout.failed` | The network rejected the payout. The reserved amount was returned to the balance. | Payout `payout.cancelled` | You cancelled a pending payout. The reserved amount was returned. | Payout `payout.rejected` | The platform rejected a payout held for review. The reserved amount was returned. | Payout `swap.queued` | Live: an incoming coin is reserved for an on-chain DEX conversion (Uniswap V3 / PancakeSwap V3) on the same network. | Swap `swap.completed` | An incoming asset was converted to your settlement stablecoin. Live swaps credit the amount actually received, minus the 0.25% markup. | Swap `swap.skipped` | Conversion was skipped (no route, no liquidity or no fresh price); funds stay in the received asset. | { reference, reason } or Swap `swap.failed` | Live: the DEX swap could not be executed; the reserved funds are available again in the received asset. | Swap `test.ping` | Sent by "Send test event" in the dashboard to check your endpoint. | { message, sentAt } ### Verify signatures Compute HMAC-SHA256 over `"{t}.{raw body}"` with your signing secret and compare it with `v1` in constant time. Reject timestamps older than 5 minutes. Use the **raw** request bytes — re-serialized JSON will not match. ```javascript import { createHmac, timingSafeEqual } from 'node:crypto'; export function verifyStablora(rawBody, header, secret) { const m = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header || ''); if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false; const expected = createHmac('sha256', secret).update(`${m[1]}.${rawBody}`).digest(); return timingSafeEqual(expected, Buffer.from(m[2], 'hex')); } // Express: app.post('/webhooks/stablora', express.raw({ type: 'application/json' }), (req, res) => { // if (!verifyStablora(req.body.toString('utf8'), req.get('Stablora-Signature'), process.env.STABLORA_WEBHOOK_SECRET)) return res.sendStatus(400); // const event = JSON.parse(req.body); /* dedupe event.id, then handle */ res.sendStatus(200); // }); ``` ```python import hmac, hashlib, re, time def verify_stablora(raw_body: bytes, header: str, secret: str) -> bool: m = re.fullmatch(r"t=(\d+),v1=([a-f0-9]{64})", header or "") if not m or abs(time.time() - int(m.group(1))) > 300: return False expected = hmac.new(secret.encode(), m.group(1).encode() + b"." + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, m.group(2)) ``` ```php function stablora_verify(string $rawBody, string $header, string $secret): bool { if (!preg_match('/\At=(\d+),v1=([a-f0-9]{64})\z/', $header, $m)) return false; if (abs(time() - (int) $m[1]) > 300) return false; $expected = hash_hmac('sha256', $m[1] . '.' . $rawBody, $secret); return hash_equals($expected, $m[2]); } // $raw = file_get_contents('php://input'); // stablora_verify($raw, $_SERVER['HTTP_STABLORA_SIGNATURE'] ?? '', getenv('STABLORA_WEBHOOK_SECRET')); ``` The Node SDK ships the same check: `stablora.webhooks.verify(rawBody, signature, secret)`. In the dashboard, **Developers → Send test event** delivers a signed `test.ping` to your endpoint. ### Reversals & discrepancies ### Discrepancies Underpaid, overpaid and late payments send `payment.discrepancy`. The funds are credited but the order should not be fulfilled automatically. Options: ask the customer to send the rest (until expiry), accept it, or refund. Set an **underpayment tolerance** (0–20 %) in Settings so tiny shortfalls caused by wallet fees still complete. ### Reversals (chain reorganizations) On chains without instant finality (EVM networks, Bitcoin) Stablora keeps re-checking a credited transfer until its block is final. If the transfer disappears from the canonical chain, the credit — including the fee and any automatic conversion — is undone and you receive `payment.reversed` with the updated payment and the `reversedDeposit`. If the same transfer is confirmed again, it is reinstated and `payment.completed` is sent again. - Put a fulfilled order back on hold; do not ship. - Never refund automatically — the customer’s funds did not arrive. - If you already paid the funds out, your balance can become negative; this is shown to you and the platform. Solana (finalized commitment) and TRON (solidified blocks) only credit final transfers, so reversals do not happen there. ### Address screening Every payout destination and every deposit sender is screened against the OFAC SDN list of sanctioned crypto addresses (refreshed daily) and the platform blocklist. - A payout or refund to a listed address is refused with `403` and code `address_blocked`. Nothing is reserved or sent. - A deposit from a listed sender is frozen: it appears in your balance as `frozen`, no fee is charged, no conversion runs, and you receive `payment.held` (or `deposit.held`) instead of `payment.completed`. Do not fulfil the order. - The platform reviews it. If released, the deposit is credited normally and `payment.completed` is sent; if kept frozen, the funds stay out of your balance. Sandbox: pass a listed address in `fromAddresses` to `POST /test/deposits` to see the whole flow. ### Inspect an event `GET /api/v1/events/{id}` — permission: read The webhook inspector: the exact body we sent, the headers, and every delivery attempt with the URL, the signature, your server’s HTTP status, the first 2 KB of its response, the duration and any error. The dashboard shows the same under **Developers → Inspect**. Path parameters: - `id` (string, required): Event `evt_…`. Example: ```bash curl "https://localhost:3000/api/v1/events/evt_56706555c39438cb1106801f" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "id": "evt_56706555c39438cb1106801f", "type": "payment.completed", "status": "delivered", "attempts": 2, "created_at": "2026-09-24T11:03:41.745Z", "delivered_at": "2026-09-24T11:03:52.310Z", "next_attempt_at": null, "last_error": null, "request": { "method": "POST", "headers": { "Content-Type": "application/json", "Stablora-Signature": "t=…,v1=… (recomputed per attempt)", "Stablora-Event-Id": "evt_56706555c39438cb1106801f" }, "body": { "id": "evt_56706555c39438cb1106801f", "type": "payment.completed", "…": "…" } }, "deliveries": [ { "attempt": 1, "url": "https://shop.example/webhooks/stablora", "signature": "t=1790249021,v1=4f1c…", "statusCode": 500, "responseBody": "{\"error\":\"db down\"}", "durationMs": 84, "error": "Receiver returned HTTP 500.", "at": "2026-09-24T11:03:42.100Z" }, { "attempt": 2, "url": "https://shop.example/webhooks/stablora", "signature": "t=1790249032,v1=9ab2…", "statusCode": 200, "responseBody": "{\"ok\":true}", "durationMs": 41, "error": null, "at": "2026-09-24T11:03:52.310Z" } ] } ``` ### Replay an event `POST /api/v1/events/{id}/replay` — permission: payments Re-queues a failed or retrying event for immediate delivery (for example after fixing your endpoint). Path parameters: - `id` (string, required): Event `evt_…`. Example: ```bash curl -X POST "https://localhost:3000/api/v1/events/evt_56706555c39438cb1106801f/replay" -H "Authorization: Bearer $STABLORA_API_KEY" -H "Content-Type: application/json" -d '{}' ``` Response 200: ```json { "ok": true } ``` ### List events `GET /api/v1/events` — permission: read Recent events with their delivery status and last error. Query parameters: - `status` (string): `pending`, `retry`, `delivered`, `failed`. - `limit` (integer): 1–200, default 50. - `cursor` (string): Next page. Example: ```bash curl "https://localhost:3000/api/v1/events" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "data": [ { "id": "evt_56706555c39438cb1106801f", "type": "payment.completed", "status": "delivered", "attempts": 1, "last_error": null, "created_at": "2026-09-24T11:03:41.745Z", "delivered_at": "2026-09-24T11:03:42.310Z" } ], "nextCursor": null } ``` ## Tools ### Node.js SDK `@stablora/node` — zero dependencies, Node 20+, TypeScript types, automatic retries with idempotency, auto-pagination and webhook verification. Source in `sdk/node`. ```bash npm install /path/to/stablora/sdk/node ``` ```javascript import Stablora from '@stablora/node'; const stablora = new Stablora({ apiKey: process.env.STABLORA_API_KEY, baseUrl: 'https://localhost:3000/api/v1' }); const payment = await stablora.payments.create({ reference: 'order-1042', amount: '49.90', currency: 'USD', network: 'tron', asset: 'USDT', }); // In your webhook handler (raw body!): const event = stablora.webhooks.verify(rawBody, req.headers['stablora-signature'], process.env.STABLORA_WEBHOOK_SECRET); ``` ### MCP server (AI agents) Let Claude and other MCP clients create invoices, share checkout links and answer “has order 1042 been paid?”. The server can never send, refund or approve payouts. Give it a key with only `read` + `payments`. Tool | Does --- | --- `create_payment` | USD invoice, returns `checkoutUrl` `create_checkout_session` | Link where the customer picks the coin `get_payment`, `list_payments` | Status and history `get_balances`, `list_deposits`, `list_payouts` | Read balances and movements `quote_payout` | Preview fees (sends nothing) `list_networks` | Supported networks and assets `verify_webhook` | Check a signature ```bash claude mcp add stablora --env STABLORA_API_KEY=qk_test_... -- node /path/to/stablora/sdk/mcp/bin/stablora-mcp.js ``` ```json { "mcpServers": { "stablora": { "command": "node", "args": ["/path/to/stablora/sdk/mcp/bin/stablora-mcp.js"], "env": { "STABLORA_API_KEY": "qk_test_...", "STABLORA_BASE_URL": "https://localhost:3000/api/v1" } } } } ``` ### E-commerce plugins Platform | Status | Where --- | --- | --- WooCommerce | Classic and block checkout; reversals handled | `plugins/woocommerce-stablora` (installable ZIP) Paid Memberships Pro | Membership checkout; level activated only after verified payment | `plugins/wordpress-more/pmpro-stablora` Easy Digital Downloads | Download purchases | `plugins/wordpress-more/edd-stablora` PrestaShop 8 | Payment module with “awaiting” and “reversed” order states | `plugins/php-carts` (ZIP via `tools/package.ps1`) OpenCart 4 | Payment extension, per-store settings | `plugins/php-carts` (`stablora.ocmod.zip`) Shopify, Ecwid, BigCommerce | Self-hosted bridge service (Node.js) | `plugins/saas-bridge` > All plugins pass their automated tests against mocked platforms. None has been installed in a live store yet — try them on a staging shop first. **Shopify:** adding a native crypto option to Shopify checkout requires Shopify’s payments-partner approval. Until then the bridge uses a manual payment method named “Crypto (Stablora)”: the customer gets a pay link on the order status page and in the order email, and the order is marked paid automatically when the payment completes. Every plugin follows the same rule: an order is marked paid only after a verified webhook **and** a server-side `GET /payments/{id}`. ## Reference ### Networks & assets `GET /api/v1/networks` — permission: read Supported networks, their assets and finality rules. The table below is generated from the live catalogue. Example: ```bash curl "https://localhost:3000/api/v1/networks" -H "Authorization: Bearer $STABLORA_API_KEY" ``` Response 200: ```json { "data": [ { "id": "ethereum", "name": "Ethereum", "symbol": "ETH", "family": "evm", "assets": [ "USDT", "USDC", "ETH", "SHIB", "PEPE" ], "mode": "sandbox", "liveReady": false, "finality": { "confirmations": 12, "note": "Wait for finalized/safe head; handle reorgs before crediting." } }, "…" ] } ``` ### Errors Errors return a non-2xx status and a JSON body with a human-readable message; some include a machine-readable `code` (for example `two_factor_required`). ```json { "error": { "message": "Amount must be a positive decimal string." } } ``` Status | Meaning --- | --- `400` | Validation failed: missing field, wrong format, too many decimals, unsupported network/asset. `401` | Missing or invalid API key or session; or a two-factor code is required. `403` | Key lacks the permission (read / payments / payouts), IP not allowed, blocked address, or dashboard-only route. `404` | Not found — including resources that belong to another merchant. `409` | Conflict: idempotency key reused with different details, insufficient balance, limit reached, already settled. `410` | The checkout expired. Create a new one. `413` | Request body larger than 16 KB. `415` | Send Content-Type: application/json. `429` | Rate limit exceeded (300 requests per minute per merchant; lower on public endpoints). Retry after a minute. `500` | Unexpected error. Safe to retry GET requests and requests with an Idempotency-Key. `503` | No fresh market price to lock a USD amount. Retry shortly.