Payment Notifications
Three ways to know when a payment lands: webhooks (push), polling the indexer (pull), or running your own Chainhook.
When a payment hits your wallet, you'll want to react — fulfill an order, grant access to a product, update your own database. This page covers all three mechanisms, with real code.
Option 1 — Outbound webhooks (recommended)
Set a Webhook URL in your Settings. sBTC Pay sends a signed HTTPS POST to that URL every time an on-chain event happens for your account — payment received, subscription created, refund processed, and so on.
Why this is the recommended path
Setup
- In your Dashboard → Settings, set Webhook URL under Profile Details and save (this writes the URL on-chain).
- Scroll to the Webhook Delivery card and click Regenerate Secret. Copy the secret immediately — it's only shown once.
- Store the secret in your server environment (e.g.
SBTCPAY_WEBHOOK_SECRET). - Click Send Test — your endpoint should receive a
test.pingevent within a second or two.
Request format
Every webhook is a POST with a JSON body and three signature headers:
| Header | Type | Description |
|---|---|---|
X-SbtcPay-Event | string | Event type (e.g. payment-received, subscription-created) |
X-SbtcPay-Signature | string | HMAC-SHA256 signature: t=<timestamp>,v1=<hex>. See verification below. |
X-SbtcPay-Delivery | string | Delivery ID — useful for correlation with the dashboard log |
Content-Type | string | Always application/json |
User-Agent | string | sBTCPay-Webhook/1.0 |
Payload
{
"id": "evt_12345",
"type": "payment-received",
"tx_id": "0xabc...",
"block_height": 812345,
"merchant": "SP1234...",
"created": 1714680000,
"data": {
"event": "payment-received",
"invoice-id": 42,
"payer": "SP5678...",
"merchant": "SP1234...",
"amount": 100000,
"fee": 500,
"merchant-received": 99500,
"total-paid": 100000,
"status": 2,
"block-height": 812345,
"token-type": 0
}
}data contains the decoded Clarity event exactly as it was emitted by the contract. See Smart Contract Reference for the full event catalog.
Verifying the signature
Every request includes X-SbtcPay-Signature: t=<timestamp>,v1=<hex>. To verify:
- Parse
tandv1from the header. - Reject if
|now - t|> 5 minutes (prevents replay). - Compute
HMAC-SHA256(secret, `${t}.${raw_body}`). - Compare to
v1with a constant-time comparison.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySbtcPaySignature(
rawBody: string, // Exactly the raw request body — don't re-stringify!
signatureHeader: string, // Value of X-SbtcPay-Signature
secret: string, // Your SBTCPAY_WEBHOOK_SECRET
): boolean {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=") as [string, string])
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return false;
// Reject stale (> 5 min skew) — prevents replay
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
// Constant-time compare
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && timingSafeEqual(a, b);
}Use the raw body, not JSON.stringify(req.body)
express.raw({ type: "application/json" }). In Next.js: read req.body as a stream in the API route.Your endpoint should...
- Respond 2xx within 10 seconds to acknowledge delivery.
- Treat every event as potentially duplicate — use
tx_id + typeas an idempotency key. - Respond 4xx if you permanently reject the payload (we stop retrying).
- Respond 5xx or timeout if transient — we'll retry.
Retry behavior
If your endpoint fails or times out, we retry up to 5 times with this schedule:
- Attempt 1: immediately on event
- Attempt 2: + 1 minute
- Attempt 3: + 5 minutes
- Attempt 4: + 30 minutes
- Attempt 5: + 2 hours
After 5 failed attempts, the delivery is marked Failed permanently. You can see the full delivery log in Dashboard → Settings → Webhook Delivery, with HTTP status codes and error messages for each attempt.
Event types
Every on-chain event that touches your merchant record fires a webhook:
merchant-registered,merchant-updatedinvoice-created,invoice-updated,invoice-cancelled,invoice-expiredpayment-received,direct-paymentrefund-processedsubscription-created,subscription-paymentsubscription-paused,subscription-resumed,subscription-cancelledtest.ping(only when you hit Send Test)
Option 2 — Poll the indexer
If you'd rather not run an HTTPS endpoint, you can query the Supabase Postgres database directly with the Supabase JS client (or any Postgres client), using your merchant wallet as the filter.
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
"https://kkkvlbdcgupesyzmmpqv.supabase.co", // mainnet
process.env.SUPABASE_ANON_KEY!
);
async function fetchNewPayments(merchantAddress: string, sinceBlock: number) {
const { data, error } = await supabase
.from("payments")
.select("id, invoice_id, payer, amount, block_height, tx_id, token_type")
.eq("merchant_principal", merchantAddress)
.gt("block_height", sinceBlock)
.order("block_height", { ascending: true });
if (error) throw error;
return data;
}Run this on a cron (every 30–60 seconds). Store the highest block_height you've seen and pass it on the next call.
Which URL?
- Mainnet:
kkkvlbdcgupesyzmmpqv.supabase.co - Testnet:
oggvlwdptcpwipxahhjn.supabase.co
The anon key is in the published frontend build (safe to expose — it only gives read access subject to row-level security).
Use tx_id as your idempotency key
tx_id. Store it and skip rows you've already processed — this makes your sync safe against retries and duplicate polls.Option 3 — Run your own Chainhook
The lowest-latency option: subscribe your own server to Stacks contract events directly via Hiro Chainhook. You get raw events the moment a block confirms.
Benefits:
- Sub-second latency
- Full event stream (not filtered to your merchant)
- Handles rollbacks natively
See Hiro's Chainhook documentation for setup. The predicate is the same shape sBTC Pay uses, pointed at SPR54P37AA27XHMMTCDEW4YZFPFJX69162JR5CT4.sbtc-pay.
Which should I pick?
- Just trying it out? Watch the dashboard.
- Building any real integration? Outbound webhooks (Option 1).
- Already running infrastructure for blockchain events? Self-hosted Chainhook (Option 3).
- Can't accept incoming HTTPS? Polling (Option 2).
See Architecture for how the indexing layer works, and Smart Contract Reference for the full event catalog.