API Reference
The StablePay merchant API lets you create payment orders and receive signed webhooks when payments confirm on-chain. All requests are authenticated with HMAC-SHA256 signatures.
Authentication
Every request must include these headers:
| Header | Description |
|---|---|
SP-Key-ID | Your public key identifier (pk_live_...) |
SP-Timestamp | Unix seconds. Requests older than 5 minutes are rejected. |
SP-Nonce | A unique random string per request (replay protection) |
SP-Signature | sha256=<hex> — HMAC-SHA256 of the canonical string |
Idempotency-Key | Optional. Safe retries for order creation (24h) |
The canonical string (newline-joined) that you sign:
METHOD
/path
sorted_query_string
sha256_hex(raw_body)
timestamp
nonceconst crypto = require("crypto");
function sign({ method, path, query, body, timestamp, nonce, secret }) {
const bodyHash = crypto.createHash("sha256").update(body || "").digest("hex");
const canonical = [method, path, query, bodyHash, timestamp, nonce].join("\n");
return crypto.createHmac("sha256", secret).update(canonical).digest("hex");
}
// headers: SP-Key-ID, SP-Timestamp, SP-Nonce, SP-Signature: sha256=<hex>Chains & Tokens
Use the chain and token_id values from the table below when creating an order. Settlement is judged once confirmations are reached (per-chain). Available chains and tokens ultimately depend on your plan.
| Chain | Family | Tokens (token_id : symbol) | Confirmations |
|---|---|---|---|
tron | Tron | tron-trxTRXtron-usdtUSDT | 19 |
ethereum | EVM | ethereum-ethETHethereum-usdtUSDTethereum-usdcUSDC | 20 |
bsc | EVM | bsc-bnbBNBbsc-usdtUSDTbsc-usdcUSDC | 15 |
polygon | EVM | polygon-usdcUSDCpolygon-usdt0USDT0 | 128 |
solana | Solana | solana-usdcUSDCsolana-usdtUSDT | 32 |
Native coins (TRX / BNB / ETH) can also be received, though merchants mostly collect stablecoins. token_id is the exact value used at order creation.
Create an order
https://your-domain.com/api/v1/ordersRequest body:
{
"chain": "tron", // tron | ethereum | bsc | polygon
"token_id": "tron-usdt", // namespaced token id
"fiat_currency": "USD",
"fiat_amount": "20.00",
"merchant_order_id": "order-1001", // your reference (optional)
"metadata": { "cart": "abc" }, // optional
"success_url": "https://shop.example.com/ok",
"cancel_url": "https://shop.example.com/cancel"
}Response 201:
{
"order_id": "do_9f2...",
"chain": "tron",
"token_id": "tron-usdt",
"fiat_currency": "USD",
"fiat_amount": "20.00",
"payable_amount": "20.004217", // unique suffix identifies this order
"pay_address": "TQmZ8...",
"status": "pending",
"expires_at": "2026-08-16T15:30:00.000Z",
"checkout_token": "ct_...",
"checkout_url": "https://your-domain.com/checkout/ct_..."
}Redirect your customer to checkout_url. The payable_amount includes a tiny unique suffix so concurrent orders of the same amount can be told apart on-chain. Orders expire after 30 minutes.
Retrieve an order
https://your-domain.com/api/v1/orders/{order_id}Returns the current order status, received amount, transaction hash and confirmation count. Poll this or (better) use webhooks.
Webhooks
Configure your endpoint in Dashboard → Developers. We send signed POST requests for order.detected, order.paid and order.expired events.
| Header | Description |
|---|---|
SP-Webhook-ID | Event id (evt_...) — use for deduplication |
SP-Webhook-Timestamp | Unix seconds |
SP-Webhook-Signature | sha256=<hex> — HMAC over "<timestamp>.<raw_body>" |
Example order.paid payload:
{
"id": "evt_8a1...",
"event_version": "v1",
"type": "order.paid",
"created_at": "2026-08-16T15:02:00.000Z",
"data": {
"order_id": "do_9f2...",
"merchant_order_id": "order-1001",
"status": "paid",
"chain": "tron",
"token_id": "tron-usdt",
"payable_amount": "20.004217",
"received_amount": "20.004217",
"fiat_currency": "USD",
"fiat_amount": "20.00",
"tx_hash": "8b3d...",
"confirmations": 19
}
}Verify against the raw body bytes; do not re-serialize the JSON.
// Verify against the RAW body bytes; do not re-serialize the JSON.
const expected = crypto
.createHmac("sha256", webhookSecret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const provided = signature.replace("sha256=", "");
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));Errors
All errors follow the same envelope:
{
"error": {
"code": "auth.bad_signature",
"message": "Signature mismatch",
"request_id": "req_..."
}
}