stabltap/ gateway
v0.1.0 · BSC chain id 56

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.

Without BNB in your gas pocket, withdraws will fail with 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
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.

That's it. The architecture is intentionally small: signed POST, hosted checkout URL, signed webhook on confirmation, manual withdraw when you want the money. No custody account, no negotiated rates, no SDK.

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.

header
Authorization: Bearer sk_live_a1b2c3d4e5f6…

Create invoice

POST/v1/invoices· Bearer secret

Request body

FieldTypeDescription
amount_usdtstringRequired. Decimal string, up to 18 fractional digits. "0.25", "49.99".
descriptionstringOptional. Up to 500 characters. Surfaced on the hosted checkout page.
expires_in_secondsintegerOptional. Default 3600. Must be between 60 and 604800 (7 days).

Response — 201 Created

json
{
  "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

GET/v1/invoices/:id· Bearer secret

Returns the same shape as the create response. Returns 404 if the id does not belong to your merchant account.

Public checkout polling

GET/api/checkout/:id· no auth

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.

json
{
  "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.paid webhook fires.
  • expired — the invoice expired before reaching paid. invoice.expired webhook fires.

Errors

All errors return JSON of the form {"error":"<code>","message":"<human>"}. Common codes:

HTTPCodeWhen
401missing_bearerNo Authorization header.
401invalid_api_keyBearer token doesn't match any active key.
400invalid_amountamount_usdt is missing, non-positive, or wrong shape.
400description_too_longDescription exceeds 500 characters.
400invalid_expiryexpires_in_seconds outside [60, 604800].
503wallet_not_configuredServer is missing chain or wallet config (operator issue).
404not_foundInvoice 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

EventFires when
invoice.paidAn invoice reached the required confirmation depth (12 blocks on BSC).
invoice.expiredAn invoice's expires_at passed without enough confirmations.
withdrawal.completedA merchant-triggered withdraw finished — destination tx and operator-payout tx both broadcast successfully.

Delivery headers

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:

  1. Compute HMAC-SHA256(secret, "<t>.<raw-body>").
  2. Compare in constant time against v1.
  3. Reject if |now - t| > 300 seconds (replay protection).
Use the raw bytes you received, not a re-serialized JSON object. Frameworks that auto-parse the body and let you re-stringify will produce a different byte sequence — and the HMAC will not match.

Node.js

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
<?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
# 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

  1. You create an invoice and redirect the customer to checkout_url.
  2. The page shows the amount due, the destination address, and a QR code (EIP-681 deep-link for wallets that support it).
  3. The page polls GET /api/checkout/:id every few seconds and updates the live confirmation count.
  4. 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's expires_at passed 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.

Why "amount due" differs from "amount". The customer must send the gross amount — invoice + buyer fee snapshotted at invoice creation. The QR encodes that gross amount, so wallet UIs show the correct number.

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:

  1. Enter a destination address (BEP-20).
  2. Enter the amount in USDT — must be ≤ your available balance.
  3. 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.completed if you have webhooks configured.

Fee math

Two fees apply (default 50 bps each, editable by the operator):

  • Buyer fee — added to amount_due_usdt at 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

CodeWhat it means
GAS_POCKET_LOWGas pocket BNB balance is empty or below the withdraw's gas estimate. Top up and try again.
NO_BALANCENo invoice addresses currently hold a positive USDT balance.
AMOUNT_EXCEEDS_AVAILABLERequested 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.