Skip to content

Webhooks

A webhook is how your app learns that money arrived. It's the only trustworthy signal: the browser callback can be closed, blocked or faked, and polling your own database tells you nothing the payer did.

Set your endpoint and signing secret from the dashboard (Operate → Integrate → Webhook), or with the API:

bash
curl -X PUT $BASE/v1/merchant/webhook -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://your.app/billing/webhook","secret":"whsec_…"}'

Events

Fired when a payment reaches a terminal state — one event per payment, ever.

TypeWhen
payment.confirmedFunds settled into your treasury. Grant here.
payment.expiredThe quote lapsed with no deposit.
payment.failedThe swap failed; funds went to the payer's refund address.
payment.refundedReturned to the payer.

Merchant-initiated refunds fire their own events, carrying a refund object instead of a payment (with customerId and paymentId on it, so reversal needs no lookups):

TypeWhen
refund.createdA refund was quoted and awaits treasury funding.
refund.completedThe customer received the funds. Reverse entitlements here.
refund.failedThe swap failed; the treasury got its money back.
refund.expiredThe quote lapsed unfunded. Nothing moved.
jsonc
{
  "id": "evt_9c1f…",
  "type": "payment.confirmed",
  "createdAt": "2026-08-04T10:31:22.401Z",
  "payment": {
    "id": "pay_4d2a…",
    "customerId": "cus_1029",            // yours, from checkout
    "amountUsd": 160,
    "status": "confirmed",
    "origin": { "assetId": "…", "chain": "btc", "symbol": "BTC",
                "amount": "0.00142", "depositAddress": "bc1q…" },
    "treasury": { "chain": "sol", "asset": "USDC", "address": "…" },
    "priceId": "price_yearly",
    "quantity": 1,
    "promoCode": "LAUNCH20",
    "metadata": { "grantInterval": "month", "grantCount": "12", "orderId": "…" },
    "txId": "…",
    "createdAt": "…", "expiresAt": "…", "confirmedAt": "…"
  }
}

metadata merges what you passed at checkout with the grant the price implies (grantInterval, grantCount), so your handler doesn't have to look the price back up. payment.confirmed events also carry receiptUrl — the customer's receipt — when the deployment knows its public origin.

Headers

HeaderValue
0billing-signaturet=<unix>,v1=<hex hmac>
0billing-eventThe event type, for routing before parsing

Verifying

Signed the way Stripe does it: HMAC-SHA256 over <timestamp>.<raw body>. The timestamp is inside the signed payload, so a captured delivery can't be replayed later.

ts
import { createHmac, timingSafeEqual } from "node:crypto";

function verifySignature(raw, header, secret, toleranceSec = 300) {
  const t = header?.match(/t=(\d+)/)?.[1];
  const v1 = header?.match(/v1=([0-9a-f]+)/)?.[1];
  if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const mac = createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex");
  return mac.length === v1.length && timingSafeEqual(Buffer.from(mac), Buffer.from(v1));
}

app.post("/billing/webhook", async (req, reply) => {
  const raw = JSON.stringify(req.body);
  if (!verifySignature(raw, req.headers["0billing-signature"], process.env.WEBHOOK_SECRET)) {
    return reply.code(401).send({ error: "bad signature" });
  }
  const { type, payment } = req.body;
  if (type === "payment.confirmed") await grantAccess(payment);
  return { received: true };          // 2xx = delivered
});

Compare in constant time and reject stale timestamps, as above. Not on Node? It's ~6 lines in any language:

python
mac = hmac.new(secret.encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
hmac.compare_digest(mac, v1)

Body parsing

Compute the HMAC over the exact bytes you received. JSON.stringify(req.body) works when the sender is 0billing (both sides use JSON.stringify on the same object), but if you sit behind a proxy that reformats JSON, capture the raw body instead — Fastify's addContentTypeParser, Express's express.raw().

Delivery

Fire-and-forget with retries: up to 3 attempts, backing off 1s then 5s, and settlement never waits on your endpoint.

  • Any 2xx means delivered.
  • 5xx and 429 are retried.
  • Other 4xx stops immediately — a rejected delivery won't succeed on retry.

If all attempts fail, the payment is still confirmed on our side; the event is simply lost. Reconcile with GET /v1/payments?customerId=… if that matters to you.

Idempotency

One event per payment per terminal state is the design, and it's enforced in SQL: the update that stamps confirmation only matches rows still pending or detected, so concurrent pollers can't both win and double-fire.

That said, handlers should still be idempotent — a retry after your endpoint timed out having already committed is indistinguishable from a first delivery. Key on payment.id:

ts
const already = await db.grants.findUnique({ where: { paymentId: payment.id } });
if (already) return { received: true };

Testing without spending

Create a 100%-off code and check out with it. The payment confirms immediately with no on-chain leg and fires a real payment.confirmed, signature and all — the fastest way to exercise your handler end to end.

bash
curl -X POST $BASE/v1/codes -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"code":"TESTONLY","percentOff":100,"maxRedemptions":50}'

To hand-roll a delivery against a local endpoint, sign the same way the service does:

ts
import { createHmac } from "node:crypto";
const body = JSON.stringify(event);
const t = Math.floor(Date.now() / 1000);
const v1 = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
await fetch(url, { method: "POST", body,
  headers: { "content-type": "application/json",
             "0billing-signature": `t=${t},v1=${v1}` } });

Delivery log

Every event 0billing tries to send is recorded — visible in the dashboard under Operate → Integrate → Webhook deliveries, or over the API:

  • GET /v1/webhook-deliveries — recent deliveries, newest first (event type, status, attempt count, last HTTP status, last error). Capped per merchant.
  • POST /v1/webhook-deliveries/:id/redeliver — replay a failed one's exact payload once your endpoint is back (admin).
  • POST /v1/webhook-deliveries/test — fire a signed webhook.test event at your endpoint to prove it's reachable before money moves (admin).

An endpoint that was down doesn't lose the event: it shows as failed and redelivers on demand.

Local development

A webhook needs a reachable URL. Point it at a tunnel (cloudflared tunnel --url http://localhost:3000, ngrok) and use the same signing secret on both sides.

0billing — crypto billing for your app · pay from any chain, settle in one