http://localhost:3000/api/v1Introduction
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 | http://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 · llms.txt |
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.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.completedwebhook. - 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)
curl -X POST http://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). On a local sandbox install you can simulate the transfer:
curl -X POST http://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) 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.
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.
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.
Simulating edge cases (sandbox)
- Underpaid / overpaid: send a smaller or larger
amounttoPOST /test/deposits. - Late: pay after
expires_at. - Duplicate delivery: repeat the same
transactionHash— it is never credited twice. - Chain reorganization:
POST /test/deposits/{id}/reversesendspayment.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.
Create a payment
/api/v1/paymentsCreates 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
referencerequiredstring | Your unique order id (max 120). Repeating it returns the same invoice. |
amountrequireddecimal string | 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. |
networkrequiredstring | Network id, e.g. tron, ethereum, solana, sepolia. See Networks & assets. |
assetrequiredstring | Asset on that network, e.g. USDT. |
descriptionstring | Shown on the checkout page (max 200). |
customerIdstring | Attach the invoice to a customer (cus_…). |
expirationMinutesinteger | 5–1440, default 60. Funds after expiry are still credited as late. |
paymentUrlis relative: combine it with the Stablora origin you trust.- If you set a processing-fee payer of
customerin Settings,amountis grossed up andbaseAmountis what you net.
Errors
400 | Unknown network/asset or invalid amount. |
409 | Reference already used with different details. |
503 | No fresh market price for a USD invoice — retry shortly. |
curl -X POST "http://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"}'{
"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",
"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
/api/v1/payments/{id}The authoritative state of an invoice. Re-read it after a webhook before fulfilling.
Path parameters
idrequiredstring | Payment id pay_…. |
Errors
404 | The id does not exist or belongs to another merchant. |
curl "http://localhost:3000/api/v1/payments/pay_c18df35f8fe0c08ccd846672" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"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",
"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
/api/v1/paymentsInvoices, newest first.
Query parameters
statusstring | Filter by status. |
customerIdstring | Only this customer. |
limitinteger | 1–200, default 50. |
cursorstring | nextCursor from the previous page. |
curl "http://localhost:3000/api/v1/payments?status=completed&limit=20" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"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",
"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. | No — hold the order |
held | Funds came from a sanctioned address and are frozen pending compliance review. See Address 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
/api/v1/quotesShows 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
networkrequiredstring | Network id. |
assetrequiredstring | Asset. |
usdAmountrequireddecimal string | USD amount. |
curl -X POST "http://localhost:3000/api/v1/quotes" \
-H "Authorization: Bearer $STABLORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"network":"bitcoin","asset":"BTC","usdAmount":"100"}'{
"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
/api/v1/checkout-sessionsA 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
referencerequiredstring | Your order id (max 100). Idempotent. |
amountrequireddecimal string | Amount in currency. |
currency"USD" | "EUR" | "GBP" | Default USD. |
descriptionstring | Shown to the customer. |
optionsarray | 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). |
successUrlstring | Where the “Return to store” button leads after payment. |
customerIdstring | Attach resulting invoices to a customer. |
expirationMinutesinteger | 5–1440, default 60. |
curl -X POST "http://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"}]}'{
"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": "http://localhost:3000/checkout/cs_cc25b6efa956b4911ea9920a"
}Create a payment link
/api/v1/payment-linksA 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
titlerequiredstring | Shown on the link page (max 100). |
amountdecimal string | Fixed USD price. Omit to let the buyer choose. |
minAmountdecimal string | Lowest amount when the buyer chooses (default 1). |
maxAmountdecimal string | Highest amount when the buyer chooses (default 10000, max 1,000,000). |
descriptionstring | Optional text (max 200). |
optionsarray | Coins to offer, as for checkout sessions. |
successUrlstring | Return URL after payment. |
curl -X POST "http://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"}'{
"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
/api/v1/payment-linksAll links with usage statistics: sessions started, paid count and paid USD.
curl "http://localhost:3000/api/v1/payment-links" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"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
/api/v1/payment-links/{id}/deactivateStops the link from accepting new buyers. Invoices already started are not affected.
Path parameters
idrequiredstring | Link id pl_…. |
Errors
404 | The id does not exist or belongs to another merchant. |
curl -X POST "http://localhost:3000/api/v1/payment-links/pl_1e7aac13b02f2ca4635607cf/deactivate" \
-H "Authorization: Bearer $STABLORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'{
"id": "pl_1e7aac13b02f2ca4635607cf",
"active": false,
"…": "…"
}Activate a payment link
/api/v1/payment-links/{id}/activateTurns a deactivated link back on. The URL stays the same.
Path parameters
idrequiredstring | Link id pl_…. |
Errors
404 | The id does not exist or belongs to another merchant. |
curl -X POST "http://localhost:3000/api/v1/payment-links/pl_1e7aac13b02f2ca4635607cf/activate" \
-H "Authorization: Bearer $STABLORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'{
"id": "pl_1e7aac13b02f2ca4635607cf",
"active": true,
"…": "…"
}Simulate a deposit (sandbox)
/api/v1/test/depositsSimulates an incoming transfer on a sandbox network. Refused on testnets — send real testnet coins there instead.
Body parameters
paymentId | walletId | merchantWalletIdrequiredstring | What the transfer pays: an invoice, a permanent customer wallet or your top-up wallet. |
amountrequireddecimal string | Amount in the asset. |
assetstring | Required for wallets; taken from the invoice otherwise. |
transactionHashrequiredstring | Unique id such as sim_order_1042. Replays are ignored. |
fromAddressesarray | Sender addresses, screened against sanctions lists — use a listed address to test payment.held. |
curl -X POST "http://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"}'{
"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)
/api/v1/test/deposits/{id}/reverseUndoes 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
idrequiredstring | Deposit id dep_…. |
curl -X POST "http://localhost:3000/api/v1/test/deposits/dep_709ecb039bb5d29ece216883/reverse" \
-H "Authorization: Bearer $STABLORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'{
"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"
}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
/api/v1/customersIdempotent by externalId: repeating it returns the existing customer. Different merchants can use the same externalId without sharing anything.
Body parameters
externalIdrequiredstring | Your user id (max 120). |
namestring | Display name (max 100). |
curl -X POST "http://localhost:3000/api/v1/customers" \
-H "Authorization: Bearer $STABLORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"externalId":"player-8841","name":"Alex Morgan"}'{
"id": "cus_116f187959c5a838e125b339",
"merchant_id": "mch_4e425c9f49d92545a65c80ce",
"external_id": "player-8841",
"name": "Alex Morgan",
"created_at": "2026-09-24T11:03:41.255Z"
}List customers
/api/v1/customersCustomers, newest first.
Query parameters
limitinteger | 1–200, default 50. |
cursorstring | Next page. |
curl "http://localhost:3000/api/v1/customers" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"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
/api/v1/walletsReturns the customer’s permanent address on a network, creating it on first use.
Body parameters
customerIdrequiredstring | Customer cus_…. |
networkrequiredstring | Network id. One address serves every asset on that network. |
curl -X POST "http://localhost:3000/api/v1/wallets" \
-H "Authorization: Bearer $STABLORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"customerId":"cus_116f187959c5a838e125b339","network":"tron"}'{
"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
/api/v1/walletsWallets, optionally for one customer.
Query parameters
customerIdstring | Only this customer. |
limitinteger | 1–200, default 50. |
cursorstring | Next page. |
curl "http://localhost:3000/api/v1/wallets?customerId=cus_116f187959c5a838e125b339" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"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
/api/v1/customers/{id}/balancesA customer’s ledger balances per network and asset (per-customer balance model).
Path parameters
idrequiredstring | Customer cus_…. |
Errors
404 | The id does not exist or belongs to another merchant. |
curl "http://localhost:3000/api/v1/customers/cus_116f187959c5a838e125b339/balances" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"data": [
{
"network": "tron",
"asset": "USDT",
"decimals": 6,
"available": "119.4",
"reserved": "0"
}
]
}Balances
/api/v1/balancesYour total balances per network and asset. available can be paid out; reserved is held by pending payouts.
curl "http://localhost:3000/api/v1/balances" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"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
/api/v1/balances/unallocatedOnly 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.
curl "http://localhost:3000/api/v1/balances/unallocated" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"data": [
{
"network": "tron",
"asset": "USDT",
"decimals": 6,
"available": "1690.416856",
"reserved": "0"
}
]
}List deposits
/api/v1/depositsEvery credited transfer with fee and net amount. reversed_at is set if a reorganization undid it.
Query parameters
customerIdstring | Only this customer. |
limitinteger | 1–200, default 50. |
cursorstring | Next page. |
curl "http://localhost:3000/api/v1/deposits" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"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
/api/v1/topup-walletsYour 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
networkrequiredstring | Network id. |
curl -X POST "http://localhost:3000/api/v1/topup-wallets" \
-H "Authorization: Bearer $STABLORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"network":"tron"}'{
"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
/api/v1/topup-walletsAll your top-up addresses.
curl "http://localhost:3000/api/v1/topup-wallets" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"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
/api/v1/payouts/quoteNetwork fee, withdrawal commission and the net amount the recipient receives. Sends nothing.
Body parameters
networkrequiredstring | Network id. |
assetrequiredstring | Asset. |
amountrequireddecimal string | Amount leaving your balance. |
Errors
400 | Amount does not cover the network fee and withdrawal commission. |
curl -X POST "http://localhost:3000/api/v1/payouts/quote" \
-H "Authorization: Bearer $STABLORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"network":"tron","asset":"USDT","amount":"50"}'{
"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
/api/v1/payoutsReserves the amount and either sends it (automatic approval) or waits for approval. Always send an Idempotency-Key and reuse it when retrying.
Body parameters
networkrequiredstring | Network id. |
assetrequiredstring | Asset. |
amountrequireddecimal string | Amount leaving your balance (the recipient gets net). |
addressrequiredstring | Destination address, validated for the network. |
customerIdstring | The user being paid. Required for per-customer balances and player-protection rules. |
Errors
403 | Destination is on the platform block list. |
409 | Insufficient balance, daily platform limit reached, or the key was reused with different details. |
curl -X POST "http://localhost:3000/api/v1/payouts" \
-H "Authorization: Bearer $STABLORA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: withdrawal-8841" \
-d '{"network":"tron","asset":"USDT","amount":"50","address":"TXYZ…player-wallet","customerId":"cus_116f187959c5a838e125b339"}'{
"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
/api/v1/payouts/{id}Current status, fees, hold_reason and tx_hash.
Path parameters
idrequiredstring | Payout out_…. |
Errors
404 | The id does not exist or belongs to another merchant. |
curl "http://localhost:3000/api/v1/payouts/out_32310a196885bf86be5926a4" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"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
/api/v1/payoutsPayouts and refunds, newest first. Poll status=pending_approval to build an approval queue.
Query parameters
statusstring | e.g. pending_approval, completed. |
customerIdstring | Only this player/customer. |
limitinteger | 1–200, default 50. |
cursorstring | Next page. |
curl "http://localhost:3000/api/v1/payouts?status=pending_approval" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"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
/api/v1/payouts/{id}/approveApproves a pending_approval payout and sends it.
Path parameters
idrequiredstring | Payout out_…. |
Errors
404 | The id does not exist or belongs to another merchant. |
409 | The payout is no longer pending. |
curl -X POST "http://localhost:3000/api/v1/payouts/out_32310a196885bf86be5926a4/approve" \
-H "Authorization: Bearer $STABLORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'{
"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
/api/v1/payouts/{id}/cancelCancels a payout that has not been sent yet and returns the reserved amount to the balance. Sends payout.cancelled.
Path parameters
idrequiredstring | Payout out_…. |
Errors
404 | The id does not exist or belongs to another merchant. |
409 | The payout was already sent. |
curl -X POST "http://localhost:3000/api/v1/payouts/out_32310a196885bf86be5926a4/cancel" \
-H "Authorization: Bearer $STABLORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'{
"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
/api/v1/payments/{id}/refundsSends 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
idrequiredstring | Payment pay_…. |
Body parameters
amountrequireddecimal string | Amount to refund. |
addressrequiredstring | Customer’s address. |
reasonstring | Shown in your records (max 200). |
Errors
409 | Nothing left to refund, or the refund exceeds the refundable amount. |
curl -X POST "http://localhost:3000/api/v1/payments/pay_c18df35f8fe0c08ccd846672/refunds" \
-H "Authorization: Bearer $STABLORA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: withdrawal-8841" \
-d '{"amount":"10","address":"TCustomer…refund","reason":"Item out of stock"}'{
"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
/api/v1/payments/{id}/refundsRefunds of one invoice plus how much is still refundable.
Path parameters
idrequiredstring | Payment pay_…. |
Errors
404 | The id does not exist or belongs to another merchant. |
curl "http://localhost:3000/api/v1/payments/pay_c18df35f8fe0c08ccd846672/refunds" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"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
/api/v1/statementEvery 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
fromdate | YYYY-MM-DD (inclusive). |
todate | YYYY-MM-DD (inclusive). |
curl "http://localhost:3000/api/v1/statement?from=2026-09-01&to=2026-09-30" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"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
/api/v1/invoices/{month}Processing, withdrawal and conversion fees charged in a month, per network and asset. The dashboard renders it as a printable invoice.
Path parameters
monthrequiredstring | YYYY-MM. |
curl "http://localhost:3000/api/v1/invoices/2026-09" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"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": "[email protected]"
},
"lines": [
{
"description": "Processing fee (0.5%)",
"network": "tron",
"asset": "USDT",
"transactions": 9,
"amount": "9.094558"
},
"…"
]
}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.
{
"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=<unix seconds>,v1=<hex HMAC-SHA256> |
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
idand ignore repeats; always act on the current state fromGET /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.
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);
// });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))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
403and codeaddress_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 receivepayment.held(ordeposit.held) instead ofpayment.completed. Do not fulfil the order. - The platform reviews it. If released, the deposit is credited normally and
payment.completedis 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
/api/v1/events/{id}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
idrequiredstring | Event evt_…. |
Errors
404 | The id does not exist or belongs to another merchant. |
curl "http://localhost:3000/api/v1/events/evt_56706555c39438cb1106801f" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"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
/api/v1/events/{id}/replayRe-queues a failed or retrying event for immediate delivery (for example after fixing your endpoint).
Path parameters
idrequiredstring | Event evt_…. |
Errors
409 | Only failed or retrying events can be replayed. |
curl -X POST "http://localhost:3000/api/v1/events/evt_56706555c39438cb1106801f/replay" \
-H "Authorization: Bearer $STABLORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'{
"ok": true
}List events
/api/v1/eventsRecent events with their delivery status and last error.
Query parameters
statusstring | pending, retry, delivered, failed. |
limitinteger | 1–200, default 50. |
cursorstring | Next page. |
curl "http://localhost:3000/api/v1/events" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"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
}Node.js SDK
@stablora/node — zero dependencies, Node 20+, TypeScript types, automatic retries with idempotency, auto-pagination and webhook verification. Source in sdk/node.
npm install /path/to/stablora/sdk/nodeimport Stablora from '@stablora/node';
const stablora = new Stablora({ apiKey: process.env.STABLORA_API_KEY, baseUrl: 'http://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 |
claude mcp add stablora --env STABLORA_API_KEY=qk_test_... -- node /path/to/stablora/sdk/mcp/bin/stablora-mcp.js{
"mcpServers": {
"stablora": {
"command": "node",
"args": ["/path/to/stablora/sdk/mcp/bin/stablora-mcp.js"],
"env": { "STABLORA_API_KEY": "qk_test_...", "STABLORA_BASE_URL": "http://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 |
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}.
Networks & assets
/api/v1/networksSupported networks, their assets and finality rules. The table below is generated from the live catalogue.
| Network id | Name | Assets | Family | Type |
|---|---|---|---|---|
ethereum | Ethereum | EVM | Mainnet · live key | |
base | Base | EVM | Mainnet · live key | |
arbitrum | Arbitrum | EVM | Mainnet · live key | |
polygon | Polygon | EVM | Mainnet · live key | |
bnb | BNB Chain | EVM | Mainnet · live key | |
optimism | Optimism | EVM | Mainnet · live key | |
avalanche | Avalanche | EVM | Mainnet · live key | |
tron | TRON | TRON | Mainnet · live key | |
solana | Solana | Solana | Mainnet · live key | |
bitcoin | Bitcoin | UTXO | Mainnet · live key | |
dogecoin | Dogecoin | UTXO | Mainnet · live key | |
monero | Monero | Monero | Mainnet · live key | |
litecoin | Litecoin | UTXO | Mainnet · live key | |
sepolia | Sepolia testnet | EVM | Testnet · test key (no value) | |
base-sepolia | Base Sepolia testnet | EVM | Testnet · test key (no value) | |
arbitrum-sepolia | Arbitrum Sepolia testnet | EVM | Testnet · test key (no value) | |
op-sepolia | OP Sepolia testnet | EVM | Testnet · test key (no value) | |
polygon-amoy | Polygon Amoy testnet | EVM | Testnet · test key (no value) | |
avalanche-fuji | Avalanche Fuji testnet | EVM | Testnet · test key (no value) | |
bnb-testnet | BNB Chain testnet | EVM | Testnet · test key (no value) | |
tron-nile | TRON Nile testnet | TRON | Testnet · test key (no value) | |
solana-devnet | Solana devnet | Solana | Testnet · test key (no value) | |
bitcoin-testnet | Bitcoin testnet | UTXO | Testnet · test key (no value) |
curl "http://localhost:3000/api/v1/networks" \
-H "Authorization: Bearer $STABLORA_API_KEY"{
"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).
{ "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. |