Integrate Stabltap in five minutes.
A multi-merchant USDT payment gateway. Hosted checkout, signed webhooks, manual withdrawals to any address you control. Bearer auth, JSON in, JSON out.
01 Quickstart
From signup to your first settled invoice in five minutes. Five steps.
1. Create a merchant account
Sign up at /signup. No KYC in testing mode — but don't reuse real-world credentials.
2. Fund your gas pocket
Stabltap is non-custodial. Each merchant has a dedicated gas pocket — a BSC address that pays the network fee when you withdraw. Find its address on your dashboard and send a small amount of BNB to it. Around 0.001 BNB covers many withdrawals.
GAS_POCKET_LOW. The gateway never advances gas on your behalf.3. Generate an API key
Go to /dashboard/api-keys and click Create new key. The secret is shown once. Save it somewhere durable — it is not retrievable.
4. Create your first invoice
curl https://stabltap.com/v1/invoices \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{"amount_usdt":"0.25","description":"smoke test","expires_in_seconds":3600}'
Redirect your customer to checkout_url from the response. They scan the QR or paste the address into a wallet that supports BEP-20 USDT.
5. Withdraw to an address you control
Once an invoice is paid, the funds sit at a per-invoice address derived from your merchant ID. Trigger a withdraw from /dashboard/withdraw: the gateway consolidates every positive-balance invoice address, splits the fees, and sends the net to the destination address you supply. You pay the BSC gas from your gas pocket.
02 API reference
Bearer auth with your secret API key. JSON request, JSON response. Public endpoints (hosted checkout polling) are unauthenticated.
Authentication
Every authenticated endpoint requires an Authorization: Bearer <secret> header. Secrets are scrypt-hashed at rest. Public-prefixed keys (pk_…) identify your account; secret keys (sk_…) authorize requests.
Authorization: Bearer sk_live_a1b2c3d4e5f6…
Create invoice
Request body
| Field | Type | Description |
|---|---|---|
| amount_usdt | string | Required. Decimal string, up to 18 fractional digits. "0.25", "49.99". |
| description | string | Optional. Up to 500 characters. Surfaced on the hosted checkout page. |
| expires_in_seconds | integer | Optional. Default 3600. Must be between 60 and 604800 (7 days). |
Response — 201 Created
{
"id": "inv_8f2a3c…",
"merchant_id": 1,
"amount_usdt": "0.25",
"amount_due_usdt": "0.25125",
"buyer_fee_usdt": "0.00125",
"buyer_fee_bps": 50,
"merchant_fee_bps": 50,
"coin": "USDT",
"description": "smoke test",
"address": "0x9c1d4e…",
"chain": "bsc",
"status": "waiting",
"created_at": 1746234567000,
"expires_at": 1746238167000,
"paid_at": null,
"checkout_url": "https://stabltap.com/checkout/inv_8f2a3c…"
}
amount_due_usdt is what the customer sends — the invoice amount plus the buyer fee snapshotted at creation. Show amount_usdt in your own UI; redirect the customer to checkout_url for payment.
Retrieve invoice
Returns the same shape as the create response. Returns 404 if the id does not belong to your merchant account.
Public checkout polling
Used by the hosted checkout page to poll status. Safe to expose to a customer — returns only the fields needed to render the live state.
{
"id": "inv_8f2a3c…",
"status": "confirming",
"amount_usdt": "0.25",
"amount_due_usdt": "0.25125",
"buyer_fee_usdt": "0.00125",
"address": "0x9c1d4e…",
"chain": "bsc",
"confirmations": 4,
"required_confirmations": 12,
"paid_at": null
}
Invoice statuses
waiting— created, no on-chain transfer detected yet.confirming— transfer detected; counting block confirmations.paid— required confirmations reached.invoice.paidwebhook fires.expired— the invoice expired before reachingpaid.invoice.expiredwebhook fires.
Errors
All errors return JSON of the form {"error":"<code>","message":"<human>"}. Common codes:
| HTTP | Code | When |
|---|---|---|
| 401 | missing_bearer | No Authorization header. |
| 401 | invalid_api_key | Bearer token doesn't match any active key. |
| 400 | invalid_amount | amount_usdt is missing, non-positive, or wrong shape. |
| 400 | description_too_long | Description exceeds 500 characters. |
| 400 | invalid_expiry | expires_in_seconds outside [60, 604800]. |
| 503 | wallet_not_configured | Server is missing chain or wallet config (operator issue). |
| 404 | not_found | Invoice id doesn't belong to your account. |
03 Webhooks
Signed POSTs delivered to the URL you set on /dashboard/settings. HMAC-SHA256, replay-safe, retried up to six times over ~7 hours.
Event types
| Event | Fires when |
|---|---|
| invoice.paid | An invoice reached the required confirmation depth (12 blocks on BSC). |
| invoice.expired | An invoice's expires_at passed without enough confirmations. |
| withdrawal.completed | A merchant-triggered withdraw finished — destination tx and operator-payout tx both broadcast successfully. |
Delivery headers
POST /your/webhook/url content-type: application/json x-stabltap-signature: t=1746234571,v1=8f2a3c… x-stabltap-event: invoice.paid x-stabltap-delivery: 42 user-agent: Stabltap-Webhook/1.0
Signature verification
The x-stabltap-signature header is comma-separated t=<unix>,v1=<hex>. To verify a delivery:
- Compute
HMAC-SHA256(secret, "<t>.<raw-body>"). - Compare in constant time against
v1. - Reject if
|now - t| > 300seconds (replay protection).
Node.js
// Node 20+
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyStabltap(rawBody, headerValue, secret) {
const map = Object.fromEntries(
headerValue.split(",").map((seg) => {
const eq = seg.indexOf("=");
return [seg.slice(0, eq).trim(), seg.slice(eq + 1).trim()];
})
);
const t = parseInt(map.t, 10);
const v1 = map.v1;
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;
const expected = createHmac("sha256", secret)
.update(t + "." + rawBody)
.digest("hex");
if (v1.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
// Express handler — note: use express.raw() so rawBody is the bytes
// Stabltap signed, not a re-serialized object.
app.post("/stabltap/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verifyStabltap(
req.body.toString("utf8"),
req.header("x-stabltap-signature"),
process.env.STABLTAP_WEBHOOK_SECRET
);
if (!ok) return res.status(400).end();
const event = JSON.parse(req.body.toString("utf8"));
// ... process event by req.header("x-stabltap-event")
res.status(200).end();
}
);
PHP
<?php
// Stabltap webhook verifier
function verify_stabltap(string $rawBody, string $headerValue, string $secret): bool {
$parts = [];
foreach (explode(",", $headerValue) as $seg) {
$eq = strpos($seg, "=");
if ($eq === false) continue;
$parts[trim(substr($seg, 0, $eq))] = trim(substr($seg, $eq + 1));
}
if (!isset($parts["t"], $parts["v1"])) return false;
$t = (int) $parts["t"];
if (abs(time() - $t) > 300) return false;
$expected = hash_hmac("sha256", $t . "." . $rawBody, $secret);
return hash_equals($expected, $parts["v1"]);
}
// Usage: read raw body BEFORE any framework re-parses it.
$raw = file_get_contents("php://input");
$header = $_SERVER["HTTP_X_STABLTAP_SIGNATURE"] ?? "";
$secret = getenv("STABLTAP_WEBHOOK_SECRET");
if (!verify_stabltap($raw, $header, $secret)) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
// ... process event using $_SERVER["HTTP_X_STABLTAP_EVENT"]
http_response_code(200);
Python
# Python 3.10+ (Flask example)
import hmac, hashlib, time, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["STABLTAP_WEBHOOK_SECRET"].encode()
def verify_stabltap(raw_body: bytes, header_value: str, secret: bytes) -> bool:
parts = {}
for seg in header_value.split(","):
if "=" not in seg:
continue
k, v = seg.split("=", 1)
parts[k.strip()] = v.strip()
try:
t = int(parts["t"]); v1 = parts["v1"]
except (KeyError, ValueError):
return False
if abs(int(time.time()) - t) > 300:
return False
expected = hmac.new(
secret,
f"{t}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/stabltap/webhook")
def webhook():
raw = request.get_data() # raw bytes, not request.json
header = request.headers.get("X-Stabltap-Signature", "")
if not verify_stabltap(raw, header, SECRET):
abort(400)
event_type = request.headers.get("X-Stabltap-Event")
# ... process request.json
return ("", 200)
Retry schedule
If your endpoint returns anything other than a 2xx status, or times out (10s), Stabltap retries with exponential backoff: 30s, 2m, 10m, 1h, 6h — up to six attempts total over roughly seven hours. After the sixth failure the event lands in failed state.
Replay from the dashboard
Failed and successful events are listed at /dashboard/webhooks. Click Replay to re-queue any event for delivery — useful when your endpoint was down, or while testing locally with a freshly tunneled URL.
Rotating the signing secret
Generate a new signing secret at /dashboard/settings. The new secret takes effect immediately on the next outgoing event; in-flight retries with the old secret will fail verification on your side, so coordinate the rotation with a deploy that updates STABLTAP_WEBHOOK_SECRET on your server first.
04 Hosted checkout
A server-rendered page your customer pays from. No JavaScript SDK to integrate, no version pin to maintain.
The flow
- You create an invoice and redirect the customer to
checkout_url. - The page shows the amount due, the destination address, and a QR code (EIP-681 deep-link for wallets that support it).
- The page polls
GET /api/checkout/:idevery few seconds and updates the live confirmation count. - On
paid, the page renders the success state and stops polling.
Terminal states the customer sees
paid— payment confirmed at 12 blocks (~36s on BSC).expired— invoice'sexpires_atpassed before payment confirmed.
EIP-681 deep-link
The QR encodes an EIP-681 URI, so wallets like MetaMask Mobile, Trust Wallet, Rainbow, etc. open with the transfer pre-filled (recipient address + USDT amount on chain id 56). Users on wallets that do not parse EIP-681 can still scan the address-only QR or copy-paste manually.
05 Withdrawals
You hold the keys to your money. Stabltap derives addresses, the watcher detects payments, you withdraw to any address you want, when you want.
Architecture
Each merchant has two HD-derived address scopes:
- Gas pocket at
m/44'/60'/0'/<merchant_id>/0— pays BSC gas during a withdraw. You fund it with BNB. - Invoice addresses at
m/44'/60'/0'/<merchant_id>/<index>for index ≥ 1 — one per invoice. Customers pay here.
There is no preset treasury address. The destination for each withdraw is supplied per-withdraw on the form.
Fund the gas pocket
Find the address at /dashboard and send BNB from any wallet or exchange. Around 0.001 BNB covers many withdrawals; the exact gas spend depends on how many invoice addresses are being consolidated and BSC's current basefee.
Trigger a withdraw
From /dashboard/withdraw:
- Enter a destination address (BEP-20).
- Enter the amount in USDT — must be ≤ your available balance.
- Submit. The gateway:
- Consolidates every positive-balance invoice address into your gas pocket.
- Splits to the destination + operator fee treasury.
- Marks the withdrawal complete and fires
withdrawal.completedif you have webhooks configured.
Fee math
Two fees apply (default 50 bps each, editable by the operator):
- Buyer fee — added to
amount_due_usdtat invoice creation. The customer pays it. - Merchant fee — deducted from your withdraw at the time of withdraw, computed off the consolidated total.
The operator share = buyer fee + merchant fee, sent in the same withdraw transaction to the operator treasury. Net to your destination = consolidated_total − operator_share.
Failure modes
| Code | What it means |
|---|---|
| GAS_POCKET_LOW | Gas pocket BNB balance is empty or below the withdraw's gas estimate. Top up and try again. |
| NO_BALANCE | No invoice addresses currently hold a positive USDT balance. |
| AMOUNT_EXCEEDS_AVAILABLE | Requested amount is larger than the consolidated total. |
Past withdrawals are listed at /dashboard/withdrawals with status, tx hashes (linked to BscScan), and timestamps.
06 Going live
A short pre-launch checklist when you stop pointing at a test merchant and start taking real money.
Rotate API keys
Revoke any keys you used during integration tests at /dashboard/api-keys. Generate fresh ones for production. Store the secret in your platform's secret manager — never check it into git.
Confirm your webhook URL serves HTTPS
Stabltap delivers over HTTPS only when your endpoint URL starts with https://. Self-signed certs fail; use a real CA-issued cert.
Switch from a public BSC RPC to a paid provider
The default BSC RPC works for development but is rate-limited and occasionally drops blocks. Before going live, point BSC_RPC_URL at a paid provider (QuickNode, Ankr, etc.). The watcher tolerates RPC hiccups but will lag behind a flaky endpoint.
Re-verify webhook signatures end-to-end
Send yourself a small invoice (0.25 USDT) at the production URL, let it confirm, and verify your handler validates the signature against your production secret. Replay it from /dashboard/webhooks a few times to confirm idempotency.
Keep at least one withdraw destination ready
You enter the destination address per withdraw — it's not stored on Stabltap. Before going live, decide where production funds go (treasury wallet, exchange deposit address, custodian) and verify you have access to the keys. Settle a test withdraw first.
Monitor
The dashboard surfaces gas pocket balance, available USDT, recent invoices, and recent webhook events. For higher signal: subscribe to withdrawal.completed and invoice.paid events to your own observability stack.
07 FAQ
Edge cases the API reference doesn't cover.
What happens if a customer overpays?
Overpayment lands at the same per-invoice address as the underlying invoice. The watcher marks the invoice paid as long as the received amount is ≥ amount_due_usdt. The full received balance is what gets consolidated on your next withdraw — overpayment is not refunded automatically.
If you want to refund the overage, send it from your destination wallet to the customer after withdraw. Stabltap doesn't broker refunds.
What happens if a customer underpays?
The invoice does not advance to paid — it stays in confirming until either (a) more USDT arrives at the same address bringing the total ≥ amount_due_usdt, or (b) the invoice expires.
Underpaid funds are still recoverable on withdraw. They get consolidated into your gas pocket and split out to your destination just like a fully-paid invoice.
An invoice expired but the customer paid late. Can I recover the funds?
Yes. Funds at any expired-invoice address are still consolidated when you trigger a withdraw. The expired status only signals to your application that the customer can no longer be considered to have completed the original purchase — it does not move or hide the on-chain balance.
What if I lose access to my gas pocket?
Stabltap holds the keys to your gas pocket — same as your invoice addresses. "Lose access" only matters if Stabltap loses access to its operator mnemonic, which the operator manages with offline backups. If you spot the gas pocket address showing up at one you don't recognize, contact the operator.
You don't need to back up the gas pocket private key on your end — you fund it by sending BNB to the address shown on your dashboard, the same way you'd fund any external address.
Can I retrieve a webhook secret I lost?
No. Secrets are stored as opaque strings; the dashboard cannot show them again. Regenerate at /dashboard/settings and update STABLTAP_WEBHOOK_SECRET on your server. In-flight retries against the old secret will fail verification on your side until they exhaust.
How do I test webhooks locally?
Tunnel your local server to a public URL (ngrok, Cloudflare tunnel, etc.) and set that URL on /dashboard/settings. Create a small invoice and pay it from a wallet you control. After the event delivers, use Replay on /dashboard/webhooks to re-fire it as many times as you need to debug.
What happens if the gateway is down when an invoice would've confirmed?
The watcher resumes from where it left off when the service comes back. Confirmations are derived from on-chain block numbers, not from the watcher's tick — so an outage delays detection but does not lose payments. invoice.paid will still fire (and start its retry sequence) once the watcher catches up.
Do you support partial withdrawals?
Yes — enter any amount up to your available balance on the withdraw form. The gateway consolidates everything available, sends the requested amount net of fees to your destination, and the leftover sits in your gas pocket for the next withdraw or future gas use.