Webhooks

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}`.

HTTPBlockPay webhook
POST https://your-app.example/blockpay/webhook
Content-Type: application/json
X-BlockPay-Event: invoice.paid
X-BlockPay-Delivery: del_01HE2K9F8M
X-BlockPay-Timestamp: 1747350522
X-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 ===.

verify.tsBlockPay webhook
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

route.tsBlockPay webhook
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.

EventDescription
invoice.createdA new invoice has been created. The customer has not yet paid; the invoice is in open state.
invoice.paidAn on-chain transfer satisfying the invoice has confirmed. Fulfil the order.
invoice.expiredThe invoice passed its expiresAt without being paid. The on-chain commitment is closed.
payment.receivedAn on-chain transfer was observed at one of your settlement addresses, attributed to a BlockPay invoice.
payment.refundedA 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.