SOFTBET DOCS
API reference Webhooks Errors
Sign in Dashboard →

Introduction

The SoftBet Wallet API is a crypto payment gateway built for iGaming. Create payment intents with a hosted checkout, assign each player a permanent deposit address, reconcile balances, pay out with operator approval, and swap between assets — all from one REST API over https://api.softbet.io.

Requests and responses are JSON. Authenticated calls use a Bearer secret key. A flat 0.4% fee applies where a fee is charged — it is always returned explicitly on the object (fee, fee_rate) so you never have to compute it.

AuthenticationKeys, errors, idempotency WebhooksSigned events, retries

Authentication

Authenticate every request with your secret API key in the Authorization header. Live keys start with sk_live_, test keys with sk_test_. Create keys in the dashboard (up to 10 active per account) — the secret is shown exactly once. Never expose a secret key in client-side code.

Authorization header
Authorization: Bearer sk_live_••••••••••••••••
cURL
curl https://api.softbet.io/v1/wallets \
  -H "Authorization: Bearer sk_live_••••"

Error envelope

Success responses return the resource directly (some read endpoints wrap it as { ok: true, data } — each response example below shows the exact shape). Every error uses one envelope:

Error response
{
  "ok": false,
  "error": {
    "code": "unknown_api_key",
    "message": "API key not recognized."
  }
}

Validation failures return 422 invalid_input with a details array of { path, message } entries pointing at the offending fields.

Idempotency

Money-moving POSTs take an Idempotency-Key header (any string, 8–120 chars). It is required on POST /v1/payouts and POST /v1/swaps, and optional (but honored) on POST /v1/payments. Retrying with the same key and the same body returns the original stored response — the operation never runs twice. Reusing a key with a different body is rejected with 409 idempotency_key_reuse.

cURL · safe retry
curl https://api.softbet.io/v1/payouts \
  -H "Authorization: Bearer sk_live_••••" \
  -H "Idempotency-Key: payout-8842-attempt-1" \
  -H "Content-Type: application/json" \
  -d '{ "amount": "4200.00", "currency": "usdttrc20", "address": "T••••" }'

Rate limits & IP allowlist

Each key has a per-second request limit (default 50 req/s, configurable 1–2000 when the key is created). Exceeding it returns 429 rate_limited — back off and retry. Optionally, restrict a key to an IP allowlist of IPv4 addresses or CIDR blocks (e.g. 203.0.113.0/24); requests from any other address are rejected with 403 ip_blocked.

Environments & testing

Every key is minted for exactly one environment — sk_live_ or sk_test_ — chosen at creation and not switchable. Test keys operate against a fully isolated test ledger: test money can never touch live balances, and vice versa. The same endpoints, request shapes and webhooks work in both.

Sandbox faucet

In the portal, switch to Test mode and use the faucet to credit your test balance instantly. A faucet grant books a synthetic deposit and fires a real deposit.confirmed webhook, so you can exercise the whole pipeline end-to-end without touching a chain. Limits: 10 grants per 24 hours, with per-asset caps per grant.

Custody wallet API is live. The /v1/wallet/* endpoints (the per-merchant custody wallet, below) are environment-matched: an sk_live_ key mints/reads mainnet assets, an sk_test_ key testnet assets. A mismatch (a test key on a mainnet asset, or vice-versa) returns 403 environment_mismatch — test money can never touch a live address.

Payments & hosted checkout

A payment is an intent to collect a fiat-denominated amount in crypto. Creating one assigns (or reuses) the player’s permanent deposit address for the chosen asset and quotes the exact crypto amount. The credit happens when the on-chain deposit confirms — you’ll get a payment.confirmed webhook.

POST/v1/payments
BodyTypeDescription
amountstring, requiredFiat amount to collect as a decimal string, e.g. "250.00".
currencystring, optionalFiat currency. Only USD is supported in v1 (default USD).
pay_currencystring, requiredAsset the player pays in: btc, eth, bnb, sol, trx, xrp, ltc, doge, usdt, usdc, usdttrc20, usdtspl, usdcspl, usdcbase. The live list with availability is GET /v1/pay-currencies.
player_idstring, requiredYour player reference. Ties the payment to that player’s permanent deposit address.
order_idstring, optionalYour order/round reference, echoed back on the payment and its webhooks.
callback_urlstring, optionalRecorded on the payment for your reference. Events are delivered to your configured webhook endpoints.
cURL
curl https://api.softbet.io/v1/payments \
  -H "Authorization: Bearer sk_live_••••" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "250.00",
    "currency": "USD",
    "pay_currency": "usdttrc20",
    "player_id": "p_8a2f91",
    "order_id": "BET-44192"
  }'
Node · fetch
const res = await fetch("https://api.softbet.io/v1/payments", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.SOFTBET_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    amount: "250.00", currency: "USD",
    pay_currency: "usdttrc20", player_id: "p_8a2f91",
  }),
});
const payment = await res.json();
201 Created · payment object
{
  "id": "pay_1f0c9a4e-5b2d-4c8e-9f3a-7d6e5c4b3a21",
  "payment_id": "1f0c9a4e-5b2d-4c8e-9f3a-7d6e5c4b3a21",
  "status": "waiting",
  "amount": "250.00",
  "currency": "USD",
  "pay_currency": "usdttrc20",
  "pay_amount": "249.000000",
  "pay_address": "T•••••••••••••••••••••••••••••••••",
  "chain": "tron",
  "fee": "1.00",
  "fee_rate": "0.004",
  "player_id": "p_8a2f91",
  "order_id": "BET-44192",
  "environment": "live",
  "expires_at": "2026-07-02T12:40:00.000Z"
}

The 0.4% fee is deducted from the receivable: pay_amount = amount × (1 − 0.004) / rate — a $250 payment quotes 249 USDT. The quote (and its expires_at) is fixed at creation. destination_tag is included when the asset requires one (XRP).

waiting confirmed

A payment is created waiting and becomes confirmed when a deposit that fully covers pay_amount settles on-chain. Underpayments never confirm an intent — they still credit your balance as a plain deposit (deposit.confirmed).

GET/v1/payments/:id

Fetch a payment by its pay_… id. Returns the same object plus created_at and confirmed_at.

cURL
curl https://api.softbet.io/v1/payments/pay_1f0c9a4e-5b2d-4c8e-9f3a-7d6e5c4b3a21 \
  -H "Authorization: Bearer sk_live_••••"

Hosted checkout

Instead of building your own payment screen, send the player to the hosted checkout — it renders the address + QR, live status and countdown, and polls until the payment confirms:

Checkout URL
https://wallet.softbet.io/checkout.html?id=pay_1f0c9a4e-5b2d-4c8e-9f3a-7d6e5c4b3a21
GET/v1/checkout/:idPublic · no auth

The status endpoint behind that page. It requires no API key — the unguessable payment id is the capability — and returns only player-facing fields (id, status, amount, currency, pay_currency, pay_amount, pay_address, destination_tag?, chain, merchant_name, order_id, fee, tx_hash, expires_at, confirmed_at), so you can safely poll it from a browser.

Permanent deposit addresses

Each player gets one dedicated deposit address per asset that never changes. Anything sent to it is detected, confirmation-gated and credited to your balance, firing a deposit.confirmed webhook with the player reference — no rotation, no reconciliation spreadsheets.

POST/v1/addresses

Assign (or fetch the existing) permanent address for a player on a chain. Idempotent: repeat calls return the same address.

cURL
curl https://api.softbet.io/v1/addresses \
  -H "Authorization: Bearer sk_live_••••" \
  -H "Content-Type: application/json" \
  -d '{ "player_id": "p_8a2f91", "chain": "btc" }'
Response
{
  "address": "bc1q••••••••••••••••••••••••••••",
  "chain": "btc",
  "player_id": "p_8a2f91",
  "permanent": true
}

chain takes the same lowercase tokens as pay_currency (btc, eth, usdttrc20, …). Unsupported values return 422 unsupported_chain.

Custody wallet API

The custody wallet API is the newer per-merchant wallet, keyed by an explicit (asset, network) pair — USDT on ethereum-mainnet and USDT on tron-mainnet are distinct balances with distinct addresses. Discover the enabled pairs via GET /v1/currencies. Your wallet is auto-provisioned on the first address request.

All /v1/wallet/* endpoints are environment-matched: an sk_live_ key operates on mainnet assets, an sk_test_ key on testnet assets; a mismatch returns 403 environment_mismatch (see Environments).

Supported assets & networks

USDT and USDC are multi-chain — each (asset, network) pair is a distinct balance with its own deposit address and its own withdrawals. Always pass the explicit network. The live, authoritative matrix is GET /v1/currencies; the current mainnet set:

Networknetwork idAssets available
Ethereumethereum-mainnetETH · USDT · USDC · DAI, WBTC, LINK, UNI, AAVE, APE, CRV, MKR, LDO, SHIB, PEPE
Polygonpolygon-mainnetPOL · USDT · USDC
Arbitrumarbitrum-mainnetETH · USDT · USDC
BNB Smart Chainbsc-mainnetBNB · USDT · USDC — note: BSC tokens use 18 decimals, not 6
Basebase-mainnetETH · USDT · USDC
Trontron-mainnetTRX · USDT (TRC-20)
Solanasolana-mainnetSOL · USDT · USDC (SPL)
Bitcoinbitcoin-mainnetBTC
Litecoinlitecoin-mainnetLTC
Dogecoindogecoin-mainnetDOGE
XRP Ledgerxrpl-mainnetXRP (shared address + destination tag)

USDT is available on ethereum-mainnet, polygon-mainnet, arbitrum-mainnet, bsc-mainnet, base-mainnet, tron-mainnet and solana-mainnet. USDC on ethereum-mainnet, polygon-mainnet, arbitrum-mainnet, bsc-mainnet, base-mainnet and solana-mainnet. Same ticker, different chain → different balance, address and fee.

POST/v1/wallet/addressessk_live_ / sk_test_ (env-matched)
BodyTypeDescription
player_idstring, requiredYour player reference.
assetstring, requiredUppercase ticker: ETH, USDT, USDC, BNB, TRX, SOL, XRP, LTC, DOGE
networkstring, requiredNetwork id, e.g. ethereum-mainnet, tron-mainnet, solana-mainnet (lowercased).
cURL
curl https://api.softbet.io/v1/wallet/addresses \
  -H "Authorization: Bearer sk_test_••••" \
  -H "Content-Type: application/json" \
  -d '{ "player_id": "p_8a2f91", "asset": "USDT", "network": "tron-mainnet" }'
201 Created (200 when the existing address is returned)
{
  "address": "T•••••••••••••••••••••••••••••••••",
  "asset": "USDT",
  "network": "tron-mainnet",
  "player_id": "p_8a2f91",
  "permanent": true
}

Errors: 422 unsupported_asset (no enabled asset on that network), 429 address_quota_exceeded (per-account address limit), 503 signer_busy (address service busy — retry shortly).

GET/v1/wallet/balancessk_live_ / sk_test_ (env-matched)
Response
{
  "balances": [
    { "asset": "USDT", "network": "tron-mainnet", "balance": "100.000000" },
    { "asset": "ETH", "network": "ethereum-mainnet", "balance": "0.250000000000000000" }
  ],
  "environment": "test"
}
GET/v1/wallet/deposits?status=&limit=sk_live_ / sk_test_ (env-matched)

Inbound on-chain deposits, newest first. status filters on detected or credited; limit up to 200.

Response
{
  "deposits": [
    {
      "id": "8842",
      "asset": "USDT",
      "network": "tron-mainnet",
      "address": "T••••",
      "destination_tag": null,
      "tx_hash": "c2a9••••",
      "output_index": 0,
      "amount": "100.000000",
      "confirmations": 1,
      "required_confs": 1,
      "status": "credited",
      "block_number": 61234567,
      "created_at": "2026-07-02T10:14:02.000Z",
      "confirmed_at": "2026-07-02T10:15:00.000Z",
      "credited_at": "2026-07-02T10:15:00.000Z"
    }
  ]
}
XRP uses destination tags. On XRP your players share one address and are routed by destination_tag — the address response includes the tag, and a deposit sent without it cannot be attributed to the player automatically. Always display both the address and the tag, and warn players that the tag is mandatory.

Payouts

Send crypto from your balance to any address. Requesting a payout reserves the funds atomically (your available balance is debited immediately) and creates a payout in requested state. Every payout is then released by operator approval — a two-person rule where the approver must differ from the requester — before it is signed and broadcast. require_approval: false is accepted for forward compatibility but never auto-broadcasts to an arbitrary address.

POST/v1/payoutsIdempotency-Key required
BodyTypeDescription
amountstring, requiredCrypto amount as a decimal string, e.g. "4200.00". Must be > 0.
currencystring, requiredAsset token, e.g. usdttrc20, btc, eth.
addressstring, requiredDestination address.
destination_tagstring, optionalDestination tag/memo where the chain needs one (XRP).
require_approvalboolean, optionalAccepted and audited; approval is currently always enforced.
cURL
curl https://api.softbet.io/v1/payouts \
  -H "Authorization: Bearer sk_live_••••" \
  -H "Idempotency-Key: payout-8842-attempt-1" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "4200.00",
    "currency": "usdttrc20",
    "address": "T•••••••••••••••••••••••••••••••••"
  }'
201 Created
{
  "id": "7c3d1b2a-9e4f-4a6b-8c5d-2f1e0d9c8b7a",
  "status": "requested",
  "currency": "usdttrc20",
  "amount": "4200.00",
  "address": "T•••••••••••••••••••••••••••••••••",
  "created_at": "2026-07-02T10:20:00.000Z"
}

Insufficient balance returns 422 insufficient_funds and reserves nothing. A missing Idempotency-Key returns 400 idempotency_key_required.

requested approved submitted paid rejected failed

Rejected payouts release the reserved funds back to your balance. Once a payout is broadcast and confirms, its object carries the tx_hash and paid_at — poll GET /v1/payouts/:id after approval to track it (a payout.paid event is additionally delivered to webhook endpoints configured without an event filter).

GET/v1/payouts/:id
Response
{
  "id": "7c3d1b2a-9e4f-4a6b-8c5d-2f1e0d9c8b7a",
  "currency": "usdttrc20",
  "chain": "tron",
  "amount": "4200.00",
  "destination_address": "T••••",
  "destination_tag": null,
  "status": "paid",
  "tx_hash": "a91b••••",
  "created_at": "2026-07-02T10:20:00.000Z",
  "approved_at": "2026-07-02T10:26:41.000Z",
  "submitted_at": "2026-07-02T10:27:04.000Z",
  "paid_at": "2026-07-02T10:29:12.000Z"
}
GET/v1/payouts?status=

List your latest 100 payouts, newest first, optionally filtered by status (requested, approved, submitted, paid, rejected, failed). Returns { data: […] }.

Automated cashouts (iGaming)

Pay a player their winnings automatically, on the right chain, straight from your custodial hot wallet — with no operator step on our side. The flow: your casino/CMS approves a withdrawal on your side (staff approval, risk checks, whatever your process is) → your backend calls POST /v1/casino-payouts → we reserve, sign (isolated signer — source is your treasury, capped, chain-pinned), and broadcast. You track it via the withdrawal.broadcast / withdrawal.confirmed webhooks. If the player’s chosen currency balance is short, enable auto-swap and we convert from another crypto you hold (cross-chain if needed) to cover it.

🔑
Use a dedicated key with the casino:payout scope (create it in API & Webhooks → enable “casino payout”). Every request is HMAC-signed with that key’s bs_ secret — which is distinct from the sk_ bearer, so a leaked bearer alone can never move money. Caps + idempotency bound the rest.
POST/v1/casino-payoutscasino:payout · HMAC · Idempotency-Key

Headers: Authorization: Bearer sk_live_…, a unique Idempotency-Key, and x-casino-signature: t=<unix>,v1=<hmac> where v1 = HMAC-SHA256(bs_secret, "<t>.<raw request body>") (5-minute clock tolerance). Replaying the same Idempotency-Key returns the original payout — it never double-pays.

BodyTypeDescription
player_refstringYour player reference (echoed back; for your reconciliation).
addressstringThe player’s receiving address on the chosen network.
assetstringAsset code, e.g. USDT, ETH, BTC.
networkstringe.g. tron-mainnet, ethereum-mainnet, bitcoin-mainnet.
amountstringDecimal amount to send.
destination_tagstring, optionalFor memo/tag chains (XRP…).
Node example (signing)
const body = JSON.stringify({ player_ref:"p_8a2f", address:"T…", asset:"USDT", network:"tron-mainnet", amount:"25" });
const t = Math.floor(Date.now()/1000);
const v1 = crypto.createHmac("sha256", BS_SECRET).update(`${t}.${body}`).digest("hex");
await fetch("https://api.softbet.io/v1/casino-payouts", { method:"POST", headers:{
  "authorization":`Bearer ${SK_LIVE}`, "content-type":"application/json",
  "idempotency-key": withdrawalId, "x-casino-signature":`t=${t},v1=${v1}` }, body });

Card-to-crypto

Let players top up with a bank card (Visa / Mastercard / Maestro), Apple Pay, Google Pay, or local bank transfer (SEPA / ACH and regional rails) — settled instantly as USDC on Polygon to the player’s own permanent deposit address, with chargeback protection and no card KYC on your side. You create a card order, open the hosted checkout (popup or redirect), and the USDC payout lands on-chain where it is credited by the exact same pipeline as any deposit (deposit.confirmed webhook, unified transactions feed, custody ledger). The checkout auto-selects from 20+ integrated payment providers by the customer’s country, order amount and best rate.

Receive any crypto, not just USDC-Polygon. The card provider only outputs USDC on Polygon, but you are not limited to it: set target_asset/target_network on the order (e.g. USDT on tron-mainnet, ETH, SOL, BTC) and once the USDC is credited we convert it on-chain into your chosen asset via the swap engine — the merchant ends up holding exactly the crypto the flow was set to deliver, every leg verifiable by tx hash.

Live-only. Card orders settle real USDC on Polygon mainnet, so sk_test_ keys (and the portal in TEST mode) get 403 environment_unavailable. Present checkout as a popup (easiest — keeps players on your site) or by redirecting them to payment_url.

How it works

One order = one hosted checkout link for one player top-up. The payout wallet registered for the order is the player’s own permanent USDC (Polygon) custody address, so funds never route through a shared account:

StepWhoWhat happens
1 · createyou → APIPOST /v1/card-orders. We mint (or reuse) the player’s USDC/Polygon custody address, register it against a per-order callback, and return the payment_url.
2 · openplayer → checkoutOpen payment_url in a popup window (recommended) or redirect to it. The hosted checkout auto-selects the best payment method for the customer, or pin one with provider.
3 · payplayer → checkoutThe customer pays by card / wallet / bank. It converts to USDC and forwards it on-chain to the player’s Polygon address.
4 · settlecheckout → youThe order flips to paid and a card_payment.paid webhook fires. The balance credit itself arrives moments later as a normal on-chain deposit.confirmed once the USDC transfer confirms on Polygon.
Money-safety. The callback can only flip order metadata (it carries no signature — only a 24-byte random capability token unique to the order + an address match). The actual credit is authorised exclusively by the on-chain watcher when the payout transaction lands — a spoofed callback can never credit a balance.

Endpoints

Public API endpoints take a Bearer sk_live_ key; the portal endpoints (portal session) are the same operations for the logged-in dashboard.

POST/v1/card-orders30/min

Create a card order. Portal equivalent: POST /v1/gateway/dash/card-orders (session, 20/min).

BodyTypeDescription
player_refstring, requiredYour player reference (1–128 chars). The settlement address is the player’s permanent USDC (Polygon) custody address — minted automatically and reused on every future order for the same player.
amountstring, requiredFiat order total as a decimal string, max 2 dp, e.g. "25.00". Must meet the selected method’s minimum. Add your own markup here if you want to pass processing costs on.
currencystring, requiredFiat currency code (uppercased), e.g. USD, EUR, GBP, CAD. Some methods are single-currency — the checkout only offers those that fit your amount + currency.
emailstring, requiredThe paying customer’s email address (required for the receipt / fraud checks).
providerstring, optionalAdvanced: pin a specific provider id from the live registry (see below). Omit it (recommended) — the checkout auto-selects the best option per customer location, minimums and rate.
target_assetstring, optionalThe crypto you ultimately want to receive. The card provider only settles USDC on Polygon; if you name a different enabled asset here (any from Supported assets & networks — e.g. USDT, ETH, SOL, BTC), we auto-convert the received USDC on-chain into it as soon as it is credited. Default USDC (no swap).
target_networkstring, optionalNetwork for target_asset, e.g. tron-mainnet, ethereum-mainnet, solana-mainnet. Default polygon-mainnet. The order object echoes target_asset/target_network and, once the conversion is booked, swap_ccs_id (the on-chain swap you can track in the Swaps history / cross-chain-swap API).
cURL
curl https://api.softbet.io/v1/card-orders \
  -H "Authorization: Bearer sk_live_••••" \
  -H "Content-Type: application/json" \
  -d '{
    "player_ref": "player_123",
    "amount": "25.00",
    "currency": "EUR",
    "email": "[email protected]"
  }'
201 Created
{
  "order": {
    "id": "card_7c3d1b2a-9e4f-4a6b-8c5d-2f1e0d9c8b7a",
    "end_user_ref": "player_123",
    "email": "[email protected]",
    "fiat_currency": "EUR",
    "fiat_amount": "25.00",
    "provider": null,
    "deposit_address": "0x•••• (player USDC/Polygon)",
    "status": "pending",
    "value_coin": null,
    "coin": null,
    "txid_in": null,
    "txid_out": null,
    "created_at": "2026-07-02T10:20:00.000Z",
    "paid_at": null
  },
  "payment_url": "https://pay.••••/checkout?order=…&amount=25.00&currency=EUR"
}

Open payment_url in a popup or redirect the player to it. Treat the URL as opaque — hand it straight to the browser.

GET/v1/card-orders

List your card orders, newest first. Query limit (1–200, default 50). Returns { "items": […] } of order objects (see the field reference). Portal equivalent: GET /v1/gateway/dash/card-orders.

GET/v1/card-orders/:id

Fetch a single card order by its card_… id. Poll this to detect settlement (status: "paid"). 404 order_not_found if it isn’t yours; 400 invalid_order_id for a malformed id.

POST/v1/gateway/dash/card-orders/:id/refreshportal session6/min

Convenience status poll for a pending order (portal only). The callback is the source of truth — this just re-checks a stuck row and, if it now reports paid, flips the order and backfills value_coin / coin / txid_out. Returns the refreshed order.

GET/v1/gateway/dash/card-orders/providersportal session

The live registry of available payment providers and their minimums — use it if you want to pin a provider. Returns:

Response
{
  "providers": [
    { "id": "…", "provider_name": "…", "status": "active",
      "minimum_currency": "USD", "minimum_amount": 2 },
    …   // 20+ providers: cards, mobile wallets, pay-later, and 15+ local bank rails
  ]
}

The public API has no providers endpoint — either omit provider (auto-select, recommended) or pass an id obtained from this portal endpoint. Minimums range from about 2 to 30 in the provider’s minimum_currency; providers your amount/currency can’t meet are simply not offered.

Open the checkout in a popup

The simplest embed: keep the player on your page and open the hosted checkout in a centered popup window — no extra setup, works with the plain payment_url. Create the order on your server (never expose an sk_live_ key to the browser), hand the page the payment_url + order.id, then:

JS — popup + poll
function openTopUp(paymentUrl, orderId) {
  // Must be called from a click/tap or the browser blocks the popup.
  const w = 460, h = 760;
  const x = screen.left + (screen.width  - w) / 2;
  const y = screen.top  + (screen.height - h) / 2;
  const pop = window.open(paymentUrl, "topup",
    `popup=yes,width=${w},height=${h},left=${x},top=${y}`);
  if (!pop) { location.href = paymentUrl; return; }   // blocked → redirect fallback

  // Detect settlement: poll your backend (which proxies GET /v1/card-orders/:id).
  const tick = async () => {
    const o = await (await fetch(`/your-backend/card-orders/${orderId}`)).json();
    if (o.status === "paid") { pop.close(); showSuccess(o); return; }
    if (pop.closed) { showClosed(); return; }   // player closed it — offer retry
    setTimeout(tick, 4000);
  };
  setTimeout(tick, 4000);
}
Open the popup from a user gesture (button click) or it’s blocked. On mobile, popups often open as a new tab — the same code works, or fall back to redirecting to payment_url. For an authoritative signal instead of polling, act on the card_payment.paid webhook server-side and push it to the open page (WebSocket / SSE).
Reconcile balances off the on-chain deposit.confirmed webhook, not the popup. card_payment.paid / status:"paid" confirm the order; the USDC hits the player’s balance moments later when the Polygon transfer confirms.

The order object

FieldTypeDescription
idstringcard_<uuid>. Also the order_id in the webhook and the id in the unified transactions feed (type card).
end_user_refstringYour player_ref.
emailstringCustomer email supplied at creation.
fiat_currency / fiat_amountstringThe requested fiat currency + order total.
providerstring | nullPinned provider id, or null for auto-select.
deposit_addressstringThe player’s USDC/Polygon custody address the payout lands on.
statusstringpendingpaid. An unpaid order simply stays pending.
value_coinstring | nullUSDC amount the payment converted to (set on paid).
value_forwarded_coinstring | nullUSDC actually forwarded on-chain to the player (net of any fee).
coinstring | nullSettlement coin, e.g. polygon_usdc.
txid_in / txid_outstring | nullPayment receipt id / the on-chain payout transaction to the player’s address.
created_at / paid_atstring | nullISO timestamps; paid_at is set when the order settles.

Webhook: card_payment.paid

Fired to your configured webhook endpoints the moment an order settles (signed with the usual Softbet-Signature HMAC). It confirms the order is paid; the balance credit is a separate on-chain deposit.confirmed once the USDC transfer confirms on Polygon — key your accounting off the deposit, use this for order UX.

card_payment.paid
{
  "event": "card_payment.paid",
  "order_id": "card_7c3d1b2a-…",
  "player_ref": "player_123",
  "fiat_currency": "EUR",
  "fiat_amount": "25.00",
  "value_coin": "26.40",
  "coin": "polygon_usdc",
  "txid_out": "0x…",
  "deposit_address": "0x…",
  "environment": "live"
}

Errors

CodeHTTPWhen
environment_unavailable403An sk_test_ key, or the portal in TEST mode, hit a card route. Card money is live-only.
wallet_not_provisioned400Your custodial wallet isn’t provisioned yet (Custody → Provision) — there’s no player USDC address to settle to.
bad_amount400amount is not a positive decimal (max 2 dp).
invalid_order_id400Malformed :id (must be a card_<uuid>).
order_not_found404No such order for your account.
signer_busy503The custody address signer is momentarily saturated — retry shortly.
checkout_unreachable / checkout_error503The upstream checkout service timed out or returned an unexpected response — retry shortly.
pending paid

Balances & deposits

GET/v1/wallets

Balances per asset with a USD valuation. hot is your spendable on-book balance; cold reports "0.00" in v1 (funds swept to your own cold wallet leave our books).

Response
{
  "wallets": {
    "btc":       { "currency": "BTC", "hot": "0.42000000", "cold": "0.00", "usd": "45890.12" },
    "usdttrc20": { "currency": "USDT_TRON", "hot": "12480.000000", "cold": "0.00", "usd": "12481.75" }
  },
  "total_usd": "58371.87",
  "environment": "live"
}
GET/v1/gateway/balances

Raw per-currency available balances (registry codes like USDT_TRON, not lowercase tokens). Served with Cache-Control: no-store — never cache balances.

Response
{
  "ok": true,
  "data": [
    { "currency": "BTC", "available": "0.42000000", "updated_at": "2026-07-02T10:15:00.000Z" },
    { "currency": "USDT_TRON", "available": "12480.000000", "updated_at": "2026-07-02T09:58:31.000Z" }
  ]
}
GET/v1/gateway/deposits?status=&limit=&cursor=

Deposit history with keyset pagination. status filters on detected, credited or reversed; limit 1–200 (default 50). Pass the returned next_cursor (an ISO timestamp) as cursor to fetch the next page; null means you’ve reached the end. GET /v1/gateway/deposits/:id fetches a single deposit including end_user_ref.

cURL
curl "https://api.softbet.io/v1/gateway/deposits?status=credited&limit=50" \
  -H "Authorization: Bearer sk_live_••••"
Response
{
  "ok": true,
  "data": [
    {
      "id": "6f0e2d4c-8b7a-4e5f-9c1d-3a2b1c0d9e8f",
      "end_user_id": "9d8c7b6a-5e4f-4d3c-2b1a-0f9e8d7c6b5a",
      "currency": "USDT_TRON",
      "chain": "tron",
      "amount": "100.000000",
      "tx_hash": "c2a9••••",
      "block_number": 61234567,
      "status": "credited",
      "created_at": "2026-07-02T10:14:02.000Z",
      "credited_at": "2026-07-02T10:15:00.000Z"
    }
  ],
  "next_cursor": null
}

Swaps

Swap between assets you hold, instantly. This API swap is an internal book transfer at the oracle mid-rate minus the flat 0.4% — no external exchange, no on-chain transaction, both balances updated atomically. Quote first, then execute.

Real on-chain swaps in the dashboard. Alongside this instant book swap, your dashboard offers two on-chain swap modes that each produce a real transaction hash: same-chain via the 0x aggregator (e.g. USDC → ETH on Ethereum), and cross-chain via SideShift (e.g. ETH → BTC, USDC-Ethereum → USDC-Arbitrum, SOL → ETH). You pick the pay/receive asset + chain, get a live quote (a labeled estimate that’s reconciled to the exact on-chain-settled amount), and the bought asset lands in your treasury at finality — fully block-explorer trackable. The same cross-chain engine also powers auto-swap on payouts.

GET/v1/swaps/quote?from=&to=&amount=
cURL
curl "https://api.softbet.io/v1/swaps/quote?from=eth&to=usdt&amount=0.5" \
  -H "Authorization: Bearer sk_live_••••"
Response
{
  "from_currency": "eth",
  "to_currency": "usdt",
  "from_amount": "0.5",
  "to_amount": "1246.563720",
  "rate": "2503.14",
  "fee_rate": "0.004",
  "fee_usd": "5.0063"
}
POST/v1/swapsIdempotency-Key required
cURL
curl https://api.softbet.io/v1/swaps \
  -H "Authorization: Bearer sk_live_••••" \
  -H "Idempotency-Key: swap-2026-07-02-001" \
  -H "Content-Type: application/json" \
  -d '{ "from": "eth", "to": "usdt", "amount": "0.5" }'
201 Created
{
  "id": "swp_4b9e8d7c-6a5f-4e3d-2c1b-0a9f8e7d6c5b",
  "from_currency": "eth",
  "to_currency": "usdt",
  "from_amount": "0.5",
  "to_amount": "1246.563720",
  "rate": "2503.14",
  "fee_rate": "0.004",
  "fee_usd": "5.0063",
  "status": "done",
  "created_at": "2026-07-02T10:31:00.000Z"
}

The rate is fetched at execution time, so the executed to_amount can differ slightly from an earlier quote. Errors: 422 unsupported_currency, 422 same_currency (from = to), 422 bad_amount, 422 insufficient_funds, 422 zero_output (result rounds to zero).

Auto-swap on payouts

Turn on auto-swap in your dashboard settings and you never have to pre-fund every currency your players cash out in. When a payout (either POST /v1/casino-payouts or a dashboard payout) is short in the target currency, we automatically convert from another crypto you already hold to cover it — same-chain via the on-chain 0x aggregator when possible, or cross-chain via a real SideShift shift (e.g. you hold USDT on Tron, the player wants ETH). No pre-funding, no manual swap.

The payout is accepted immediately with status pending_topup (HTTP 202) and an auto_swap block describing the conversion. The moment the swap settles on-chain, the payout is released, signed, and broadcast — you track it through the usual withdrawal.pending_topupwithdrawal.broadcastwithdrawal.confirmed webhooks. If the conversion can’t be sourced (no other balance large enough), the call returns 422 no_topup_source and nothing is sent. Every leg is real and on-chain-trackable — no estimated or off-book value.

How the amount is sized. We convert enough of your source asset (plus a small slippage buffer) to cover the payout amount + fee in the target currency, sized off the live rate and reconciled to the exact on-chain-settled amount. Enable it per-account, and optionally pin a preferred settlement_currency we draw from first.

Utility endpoints

Four public helpers — no API key needed, rate-limited to 60 requests/minute per IP. All wrap their result as { ok: true, data: … }.

GET/v1/currenciesPublic

The enabled asset/network registry — the source of truth for valid (asset, network) pairs.

cURL + response (truncated)
curl https://api.softbet.io/v1/currencies

{
  "ok": true,
  "data": {
    "currencies": [
      { "asset": "ETH", "network": "ethereum-mainnet", "chain_class": "evm",
        "is_token": false, "decimals": 18, "min_confirmations": 12,
        "shared_address": false, "is_testnet": false },
      { "asset": "USDT", "network": "tron-mainnet", "chain_class": "tron",
        "is_token": true, "decimals": 6,  }
      
    ]
  }
}
GET/v1/pay-currenciesPublic

The pay-in currencies for POST /v1/payments and POST /v1/addresses (and the hosted checkout), each with an available flag reflecting whether the rail is live right now. What this lists is always mintable — use it to build your payment currency picker.

cURL + response (truncated)
curl https://api.softbet.io/v1/pay-currencies

{
  "ok": true,
  "data": {
    "currencies": [
      { "pay_currency": "btc", "code": "BTC", "chain": "BTC",
        "decimals": 8, "available": true },
      { "pay_currency": "bnb", "code": "BNB", "chain": "BSC",
        "decimals": 18, "available": true }
      
    ]
  }
}
GET/v1/min-amount?currency=&network=Public

Minimum processable amount for a currency ($1 equivalent, converted at the current rate). network is optional.

cURL + response
curl "https://api.softbet.io/v1/min-amount?currency=btc"

{ "ok": true, "data": {
  "currency": "BTC", "network": null,
  "min_amount": "0.00000917", "min_amount_usd": "1" } }
GET/v1/estimate?amount=&from=&to=Public

Convert between fiat and crypto (or crypto and crypto) at the current rate. from/to accept currency codes or USD. Unknown currencies return 400 unknown_currency.

cURL + response
curl "https://api.softbet.io/v1/estimate?amount=250&from=usd&to=sol"

{ "ok": true, "data": {
  "from": "USD", "to": "SOL", "amount": "250",
  "amount_usd": "250.0000", "estimated_amount": "1.666667" } }
GET/v1/validate-address?currency=&address=Public

Format-only address validation (no network calls, no checksum) — use it as a first-line check before requesting a payout. Provide currency and/or network. Returns { valid }, with a reason when invalid.

cURL + response
curl "https://api.softbet.io/v1/validate-address?currency=trx&address=TABCD..."

{ "ok": true, "data": { "valid": false,
  "reason": "expected base58 T… (34 chars) (format check only)" } }

Currency logos

Self-hosted cryptocurrency logos, served from our API so you can drop crisp coin icons into your cashier, price tables and wallet UI — no third-party keys, no attribution, hotlinkable and CDN-cached. Every logo comes in three shapes (original, round, rounded-square), light & dark themes, PNG & WebP, at any size up to 512 px. Any cryptocurrency ticker resolves on demand; our settlement coins are pre-generated for instant first paint.

GET/v1/crypto-logos/:tickerpublic · no auth

Returns the image bytes for a ticker (e.g. BTC, ETH, USDT, SOL). Append .png / .webp or use ?format=. Cached 24h and Access-Control-Allow-Origin: * — hotlink it straight from an <img> tag.

QueryValuesDescription
themelight · darkDefault light. dark returns the dark-mode variant.
shapeoriginal · round · squareDefault original (full-bleed badge). round = circular coin, square = rounded square. All with transparent corners.
size16 – 512Pixel width/height (square). Default 128.
formatpng · webpDefault png. Or set it via the extension, e.g. /v1/crypto-logos/ETH.webp.
HTML
<img src="https://api.softbet.io/v1/crypto-logos/BTC.png" width="48" alt="BTC">
<img src="https://api.softbet.io/v1/crypto-logos/ETH.png?shape=round&size=64" width="32">
<img src="https://api.softbet.io/v1/crypto-logos/USDT.webp?shape=square&theme=dark">

Live examples (BTC · ETH · USDT · SOL · XRP):

BTC
original
ETH round
round
USDT square
square
SOL dark
round · dark
XRP dark
square · dark
GET/v1/crypto-logospublic · no auth

The catalogue: the URL template, the allowed parameter values, the pre-synced tickers, and a code_ticker map from our settlement codes (incl. chain-suffixed stablecoins like USDT_TRON, USDC_BASE) to their logo ticker.

Response
{
  "ok": true,
  "data": {
    "url_template": "https://api.softbet.io/v1/crypto-logos/{ticker}.{format}?theme={theme}&shape={shape}&size={size}",
    "params": { "theme": {…}, "shape": {…}, "format": {…}, "size": {…} },
    "assets": [ { "ticker": "BTC", "url": "https://api.softbet.io/v1/crypto-logos/BTC.png" }, … ],
    "code_ticker": { "USDT_TRON": "USDT", "USDC_BASE": "USDC", … }
  }
}
Unknown / unsupported tickers return 404 logo_not_found; a malformed ticker returns 400 bad_ticker. Rate-limited to 300 requests/min per IP, but responses are long-cached, so a normal page of icons costs almost nothing.

Webhooks

We POST a signed JSON event to your endpoint(s) when money moves. Configure endpoints in the portal (Dashboard → API → Webhooks): up to 10 endpoints per account, each with an optional event filter (default: all events) and its own whsec_ signing secret, revealed once at creation. The portal can send a test ping to any endpoint and redeliver failed deliveries.

Events
deposit.confirmedAn on-chain deposit reached its required confirmations and was credited to your balance.
deposit.reversedA previously credited deposit was reversed after a chain reorg — deduct the funds from the player.
payment.confirmedA deposit settled a payment intent created via POST /v1/payments.
card_payment.paidA card order was paid by the customer — carries order_id, player_ref, value_coin and txid_out. The balance credit follows as deposit.confirmed once the USDC payout confirms on Polygon.

Signature

Every delivery carries a softbet-signature header in the form t=<unix>,v1=<hex>, where v1 is HMAC-SHA256(secret, t + "." + rawBody) using your endpoint’s whsec_ secret. Verify against the raw request body (before any JSON parsing), reject timestamps older than 5 minutes, and compare in constant time.

Delivery headers
content-type: application/json
softbet-signature: t=1751450100,v1=5f8a••••64-hex-chars••••
Verify · Node
import crypto from "node:crypto";

function verifySoftbetSignature(secret, rawBody, header, toleranceSec = 300) {
  const parts = Object.fromEntries(
    String(header || "").split(",").map((kv) => kv.split("=").map((s) => s.trim())));
  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(`${t}.${rawBody}`, "utf8").digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(String(parts.v1 || ""));
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: mount with a raw body parser, verify, then parse.
app.post("/hooks/softbet", express.raw({ type: "*/*" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verifySoftbetSignature(process.env.WHSEC, raw, req.headers["softbet-signature"]))
    return res.status(400).end();
  const event = JSON.parse(raw);
  // handle event ... idempotently (deliveries can repeat)
  res.status(200).end();
});

Legacy alias headers x-bc-signature, x-bc-timestamp and x-bc-event are also sent with every delivery for older integrations — they carry the same signature.

Delivery & retries

A delivery succeeds on any 2xx response within 5 seconds. Anything else is retried on a fixed backoff schedule, then abandoned:

30 s2 m10 m1 h6 h24 h

Retries mean your handler can see the same event more than once — process idempotently, keying on deposit_id / payment_id.

Payloads

deposit.confirmed
{
  "event": "deposit.confirmed",
  "deposit_id": "6f0e2d4c-8b7a-4e5f-9c1d-3a2b1c0d9e8f",
  "end_user_ref": "p_8a2f91",
  "currency": "USDT_TRON",
  "chain": "tron",
  "amount": "100.000000",
  "address": "T••••",
  "environment": "live",
  "tx_hash": "c2a9••••",
  "at": "2026-07-02T10:15:00.000Z"
}
payment.confirmed
{
  "event": "payment.confirmed",
  "id": "pay_1f0c9a4e-5b2d-4c8e-9f3a-7d6e5c4b3a21",
  "payment_id": "1f0c9a4e-5b2d-4c8e-9f3a-7d6e5c4b3a21",
  "status": "confirmed",
  "order_id": "BET-44192",
  "player_id": "p_8a2f91",
  "pay_currency": "USDT_TRON",
  "pay_amount": "249.000000",
  "received_amount": "249.000000",
  "received_usd": 249.11,
  "environment": "live",
  "amount": "250.00",
  "fee": "1.00",
  "deposit_id": "6f0e2d4c-8b7a-4e5f-9c1d-3a2b1c0d9e8f",
  "tx_hash": "c2a9••••",
  "address": "T••••",
  "at": "2026-07-02T10:15:00.000Z"
}
Credit on received_amount. pay_amount is the quote; received_amount / received_usd are what actually settled on-chain. A payment only confirms when the deposit fully covers the quote, but overpayments are possible — always credit the player from the received values.
deposit.reversed
{
  "event": "deposit.reversed",
  "deposit_id": "6f0e2d4c-8b7a-4e5f-9c1d-3a2b1c0d9e8f",
  "currency": "USDT_TRON",
  "chain": "tron",
  "amount": "100.000000",
  "tx_hash": "c2a9••••",
  "at": "2026-07-02T10:47:00.000Z"
}

Deposits detected by the custody wallet API (/v1/wallet/*) fire deposit.confirmed with asset + network fields instead of currency + chain, and a deposit_id prefixed oct_. Faucet grants in test mode include "faucet": true.

Errors & rate limits

Conventional HTTP status codes: 2xx success, 4xx a problem with the request, 5xx a problem on our side. Every error body carries the envelope from Authentication with a stable machine-readable error.code:

422 · validation error
{
  "ok": false,
  "error": {
    "code": "invalid_input",
    "message": "Request validation failed.",
    "details": [ { "path": "amount", "message": "amount must be a positive decimal string" } ]
  }
}
StatusCodeMeaning
400idempotency_key_requiredA required Idempotency-Key header (8–120 chars) is missing.
400unknown_currencyNo market rate available for that currency (estimate / min-amount).
400missing_currencyvalidate-address needs currency and/or network.
401missing_bearerNo Authorization: Bearer header on an authenticated endpoint.
401bad_api_key_format · unknown_api_key · bad_api_keyThe presented key is malformed, unknown, or fails verification.
403api_key_revoked · merchant_suspendedThe key or the account is no longer active.
403ip_blockedRequest IP is not in the key’s IP allowlist.
403environment_mismatchEnvironment mismatch on the custody wallet API: a test key on a mainnet asset (or vice-versa).
404payment_not_found · payout_not_found · deposit_not_foundNo such resource for your account.
409idempotency_key_reuseIdempotency-Key already used with a different request body.
422invalid_inputRequest failed validation; see details.
422unsupported_chain · unsupported_currency · unsupported_pay_currency · unsupported_asset · unsupported_fiatThe asset / chain / fiat isn’t supported (v1 fiat is USD only).
422bad_amount · same_currency · zero_outputAmount must be > 0; swap legs must differ; swap output rounds to zero.
422insufficient_fundsBalance doesn’t cover the payout or swap. Nothing was reserved.
429rate_limitedPer-key request limit exceeded — back off and retry.
429address_quota_exceededDeposit-address limit reached for this account.
500internalSomething failed on our side. Safe to retry idempotent calls.
503signer_busyThe address-derivation service is saturated — retry shortly.

Rate-limit summary

SurfaceLimitOn exceed
Authenticated /v1/*per key · default 50 req/s429 rate_limited (1-second window; limit configurable 1–2000 per key)
Public utility endpoints60 req/min per IP429 — back off and retry
Address mintingper-account quota429 address_quota_exceeded
Need a hand?
Our engineers answer fast on Telegram.
@providebet