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.

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

Push delivery means your system reacts in seconds, not minutes. No polling loop to maintain, no rate limits to worry about, and retries are handled automatically.

Setup

  1. In your Dashboard → Settings, set Webhook URL under Profile Details and save (this writes the URL on-chain).
  2. Scroll to the Webhook Delivery card and click Regenerate Secret. Copy the secret immediately — it's only shown once.
  3. Store the secret in your server environment (e.g. SBTCPAY_WEBHOOK_SECRET).
  4. Click Send Test — your endpoint should receive a test.ping event within a second or two.

Request format

Every webhook is a POST with a JSON body and three signature headers:

HeaderTypeDescription
X-SbtcPay-EventstringEvent type (e.g. payment-received, subscription-created)
X-SbtcPay-SignaturestringHMAC-SHA256 signature: t=<timestamp>,v1=<hex>. See verification below.
X-SbtcPay-DeliverystringDelivery ID — useful for correlation with the dashboard log
Content-TypestringAlways application/json
User-AgentstringsBTCPay-Webhook/1.0

Payload

POST body
{
  "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:

  1. Parse t and v1 from the header.
  2. Reject if |now - t| > 5 minutes (prevents replay).
  3. Compute HMAC-SHA256(secret, `${t}.${raw_body}`).
  4. Compare to v1 with a constant-time comparison.
verify.ts
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)

Most frameworks re-serialize parsed JSON, which changes whitespace and breaks the signature. Capture the raw body before it's parsed. In Express: 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 + type as 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-updated
  • invoice-created, invoice-updated, invoice-cancelled, invoice-expired
  • payment-received, direct-payment
  • refund-processed
  • subscription-created, subscription-payment
  • subscription-paused, subscription-resumed, subscription-cancelled
  • test.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.

poll-payments.ts
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

Every payment row has a unique 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.