Receive signed events when payments land.
BlockPay posts every state change to your endpoint as a signed JSON event. Verify the HMAC signature, then trust the payload.
Overview
Configure a webhook URL per merchant in the BlockPay dashboard. Every event hits that URL as an HTTP POST with a JSON body and a signature header. Respond with any 2xx within five seconds. BlockPay retries on non-2xx responses with exponential backoff for up to 24 hours.
Example payload
A typical invoice.paid event. Note the X-BlockPay-Signature header contains a timestamp and a v1 HMAC over `${timestamp}.${rawBody}`.
POST https://your-app.example/blockpay/webhookContent-Type: application/jsonX-BlockPay-Event: invoice.paidX-BlockPay-Delivery: del_01HE2K9F8MX-BlockPay-Timestamp: 1747350522X-BlockPay-Signature: t=1747350522,v1=8d2c4f...{ "id": "evt_01HE2K9F8M", "type": "invoice.paid", "createdAt": 1747350522, "data": { "invoice": { "id": "inv_01HE2K6BX9C0", "merchantId": "merchant_acme", "amount": "4900000", "currency": "USDC", "chainKey": "arc-testnet", "status": "paid", "settledTxHash": "0x4f1c...92a0", "settledAt": 1747350522 } }}Verify the signature
Always verify before doing anything with the payload. Use the raw request body — re-serialised JSON will not match. Use Node's crypto.timingSafeEqual to compare digests; never use a plain ===.
import crypto from "node:crypto";const WEBHOOK_SECRET = process.env.BLOCKPAY_WEBHOOK_SECRET!;const TOLERANCE_SECONDS = 5 * 60;export function verifyBlockPaySignature(opts: { rawBody: string; header: string | null;}): { ok: true } | { ok: false; reason: string } { if (!opts.header) return { ok: false, reason: "missing_header" }; // header looks like: "t=1747350522,v1=8d2c4f..." const parts = Object.fromEntries( opts.header.split(",").map((p) => p.split("=", 2) as [string, string]), ); const ts = Number(parts.t); const sig = parts.v1; if (!Number.isFinite(ts) || typeof sig !== "string") { return { ok: false, reason: "malformed_header" }; } const ageSeconds = Math.floor(Date.now() / 1000) - ts; if (Math.abs(ageSeconds) > TOLERANCE_SECONDS) { return { ok: false, reason: "stale_timestamp" }; } const signedPayload = `${ts}.${opts.rawBody}`; const expected = crypto .createHmac("sha256", WEBHOOK_SECRET) .update(signedPayload) .digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(sig, "hex"); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return { ok: false, reason: "bad_signature" }; } return { ok: true };}Wire it into your route
import { verifyBlockPaySignature } from "./verify";export async function POST(req: Request) { // IMPORTANT: read the raw body, not parsed JSON. Re-serialised // JSON will not match the signature. const raw = await req.text(); const result = verifyBlockPaySignature({ rawBody: raw, header: req.headers.get("x-blockpay-signature"), }); if (!result.ok) { return new Response("invalid signature", { status: 401 }); } const event = JSON.parse(raw) as { type: string; data: unknown }; switch (event.type) { case "invoice.paid": // fulfill the order, send the receipt email, etc. break; case "payment.refunded": // mark the order refunded break; } // Return 2xx within 5 seconds. BlockPay retries with backoff for // up to 24 hours on anything other than 2xx. return new Response("ok");}Event types
Five event types cover the full lifecycle. All events share the same envelope; only the data shape changes per type.
| Event | Description |
|---|---|
| invoice.created | A new invoice has been created. The customer has not yet paid; the invoice is in open state. |
| invoice.paid | An on-chain transfer satisfying the invoice has confirmed. Fulfil the order. |
| invoice.expired | The invoice passed its expiresAt without being paid. The on-chain commitment is closed. |
| payment.received | An on-chain transfer was observed at one of your settlement addresses, attributed to a BlockPay invoice. |
| payment.refunded | A refund transfer initiated from your settlement wallet has confirmed back to the original payer. |
Retries and idempotency
BlockPay considers the delivery successful only on a 2xx response within five seconds. Other responses, timeouts and connection errors trigger retries with exponential backoff for up to 24 hours. Use the X-BlockPay-Delivery header as an idempotency key — the same delivery id will be reused across retries, but each delivery represents a single underlying event.