Appearance
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.
| Type | When |
|---|---|
payment.confirmed | Funds settled into your treasury. Grant here. |
payment.expired | The quote lapsed with no deposit. |
payment.failed | The swap failed; funds went to the payer's refund address. |
payment.refunded | Returned 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):
| Type | When |
|---|---|
refund.created | A refund was quoted and awaits treasury funding. |
refund.completed | The customer received the funds. Reverse entitlements here. |
refund.failed | The swap failed; the treasury got its money back. |
refund.expired | The 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
| Header | Value |
|---|---|
0billing-signature | t=<unix>,v1=<hex hmac> |
0billing-event | The 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 signedwebhook.testevent 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.