The webhook patterns developers ask about most.
A dependable webhook handler does four things in order: verify the signature against the raw body (webhooks.verify in Node, the documented HMAC elsewhere), dedupe by event id, enqueue the work to a background queue, and return 200. Long-running work happens in the worker, with retries and idempotency at the queue layer. Because there is no failure event, a scheduled sweep times out jobs stuck awaiting payment and reconciles them.
Before you start.
- A working agent profile and the signing secret from your dashboard (Settings - Webhooks).
- A web framework with raw-body access - Express with
express.raw, FastAPI, Flask, etc. Auto-parsing JSON middleware breaks signature verification. - A job queue: BullMQ (Node) or Celery/arq (Python). The webhook returns 200 fast and the queue does the slow work.
- A database with an upsert primitive (Postgres works; Redis SET NX also works for short-lived dedupe).
- A public HTTPS endpoint - in development, ngrok or a deploy preview. The sender will not deliver to private URLs.
Verify the signature.
The signature is HMAC-SHA256 over {t}.{rawBody} with your webhook secret, hex-encoded, in the X-Blockchain0x-Signature header (t=<unix>,v1=<hex>), inside a 5-minute replay window. In Node, webhooks.verify from @blockchain0x/node does it and returns a discriminated union; in other languages compute the same HMAC and compare in constant time. Raw-body access matters: if the bytes you sign locally differ from the bytes that arrived, it fails.
import express from "express";
import { webhooks } from "@blockchain0x/node";
const app = express();
// Raw body so the HMAC matches the exact bytes on the wire.
app.use(express.raw({ type: "application/json" }));
app.post("/webhooks/payment", (req, res) => {
const result = webhooks.verify({
headers: req.headers,
rawBody: req.body, // Buffer, raw bytes
secret: process.env.BLOCKCHAIN0X_WEBHOOK_SECRET!,
});
// Discriminated union: branch on ok, no try/catch.
if (!result.ok) return res.status(400).json({ code: result.code });
// result.eventType / result.eventId are now set.
handleEvent(result);
res.status(200).send("ok");
});import hmac, hashlib, os, time
from flask import request
SECRET = os.environ["BLOCKCHAIN0X_WEBHOOK_SECRET"].encode()
# In Node, webhooks.verify does this. In Python, verify by hand against the
# documented algorithm: HMAC-SHA256 over "{t}.{rawBody}", 300s replay window.
def verify_signature(raw_body: bytes) -> bool:
sig = request.headers.get("X-Blockchain0x-Signature", "")
ts = request.headers.get("X-Blockchain0x-Timestamp", "")
parts = dict(p.split("=", 1) for p in sig.split(",") if "=" in p)
t, v1 = parts.get("t", ts), parts.get("v1", sig)
want = hmac.new(SECRET, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, v1) and abs(time.time() - int(t)) <= 300Make the handler idempotent.
Webhooks retry on any non-2xx response, and the same event will arrive multiple times under load even when nothing has gone wrong. Dedupe on the event's id using a database upsert. If the row already exists, skip; if it does not, insert and proceed. Postgres makes this a single statement.
// Pseudocode for a Postgres-backed dedupe table. Replace with your DB of choice.
async function processEventOnce(eventId: string, body: object) {
// INSERT ... ON CONFLICT DO NOTHING returns rowCount === 0 on duplicate.
const inserted = await db.query(
"INSERT INTO webhook_events(id) VALUES ($1) ON CONFLICT DO NOTHING",
[eventId],
);
if (inserted.rowCount === 0) return; // Already processed.
await handleEvent(body);
}async def process_event_once(event_id: str, body: dict):
# INSERT ... ON CONFLICT DO NOTHING returns 0 rows on duplicate.
inserted = await db.execute(
"INSERT INTO webhook_events (id) VALUES ($1) ON CONFLICT DO NOTHING",
event_id,
)
if inserted == "INSERT 0 0": # asyncpg-style status
return # Already processed.
await handle_event(body)Enqueue and return 200 fast.
The webhook endpoint should respond within a second. Anything slower invites timeouts and retries. The pattern is: verify, enqueue, respond. The queue runs the actual delivery in a worker with retries, exponential backoff, and its own idempotency. BullMQ and Celery both support per-job ID, which prevents accidental re-enqueuing of the same event.
// Express handler: verify, enqueue, return 200 fast.
import { Queue } from "bullmq";
import { webhooks } from "@blockchain0x/node";
const paymentQueue = new Queue("payments");
app.post(
"/webhooks/payment",
express.raw({ type: "application/json" }),
async (req, res) => {
const result = webhooks.verify({
headers: req.headers,
rawBody: req.body,
secret: process.env.BLOCKCHAIN0X_WEBHOOK_SECRET!,
});
if (!result.ok) return res.status(400).json({ code: result.code });
await paymentQueue.add(result.eventType, { raw: req.body.toString() }, {
jobId: result.eventId, // Idempotency key.
removeOnComplete: true,
attempts: 5,
backoff: { type: "exponential", delay: 1000 },
});
res.status(200).send("ok");
},
);
// Worker file:
import { Worker } from "bullmq";
new Worker("payments", async (job) => {
await handleEvent(job.data);
});# Flask handler enqueues to Celery (or arq) and returns 200 quickly.
from celery import Celery
from flask import request
celery = Celery("payments", broker=os.environ["REDIS_URL"])
@celery.task(bind=True, max_retries=5)
def handle_payment_event(self, event_type, raw):
try:
process_event_once(event_type, raw)
except Exception as exc:
raise self.retry(exc=exc, countdown=2 ** self.request.retries)
@app.post("/webhooks/payment")
def webhook():
raw = request.get_data()
if not verify_signature(raw):
abort(401)
event_id = request.headers.get("X-Blockchain0x-Event-Id", "")
event_type = request.headers.get("X-Blockchain0x-Event-Type", "")
handle_payment_event.apply_async(args=[event_type, raw.decode()], task_id=event_id)
return "ok", 200arq follows the same shape on the Python side - register the task with a deterministic job id and let the queue handle retries. The key constraint is that the enqueue itself must be fast (a single round trip to Redis); never block the webhook on remote calls.
Handle a payment that never lands.
There is no failure webhook - if a buyer abandons, no event arrives, and the agent is left stuck in 'awaiting_payment'. So detect it yourself: run a scheduled sweep over jobs that have waited too long, reconcile against the chain with transactions.get in case it actually settled, then release held resources, move the job to a terminal unpaid state, and (if appropriate) surface the outcome to the user.
async function sweepStaleAwaitingPayment() {
for (const job of await findJobsAwaitingPaymentOlderThan("1h")) {
// Reconcile against the chain before giving up.
const tx = job.txHash ? await client.transactions.get(job.txHash) : null;
if (tx) { markJobPaid(job.id); continue; } // It actually settled.
// 1. Release any held resources tied to the job.
releaseHeldResources(job.id);
// 2. Move it out of 'awaiting_payment' into a terminal 'unpaid' state.
markJobUnpaid(job.id);
// 3. (Optional) Notify the user, with a fresh payment link.
notifyUser(job.userId, { template: "agent_payment_unpaid", jobId: job.id });
}
}# Run on a schedule - there is no failure webhook to wait for.
def sweep_stale_awaiting_payment():
for job in find_jobs_awaiting_payment_older_than("1h"):
tx = client.transactions.get(job["tx_hash"]) if job.get("tx_hash") else None
if tx:
mark_job_paid(job["id"]) # It actually settled.
continue
release_held_resources(job["id"])
mark_job_unpaid(job["id"])
notify_user(job["user_id"], template="agent_payment_unpaid", job_id=job["id"])Five mistakes that drop or duplicate events.
Parsing the body before verifying the signature
HMAC must be computed over the raw bytes the sender signed. If your framework auto-parses JSON before your handler runs, the bytes you sign locally will not match the bytes the sender signed (different whitespace, key order, encoding) and every signature will look invalid. Configure the route to receive the raw body (Express: express.raw, Flask: request.get_data), verify first, then parse.
Doing the actual work inside the webhook handler
Webhooks have aggressive retry policies. If your handler takes 30 seconds to deliver the work, the sender's timeout fires and the webhook gets resent - now you have two deliveries in flight for the same payment. Always: verify, enqueue, return 2xx. The actual work runs in a background worker that can take as long as it needs.
Using HTTP status to communicate business logic
If your handler returns a 4xx when the user no longer exists in your system, the sender treats that as 'invalid request' and stops retrying. If it returns 5xx for the same condition, the sender retries forever and your queue fills up. Return 200 once you have safely persisted the event (or recognized it as a dup); use queue logic, not HTTP status, to express business decisions.
Idempotency on a payload hash instead of the event ID
Two different events about the same agent (a payment.received and a later payment.sent) have different bodies and legitimately need separate processing. If your dedupe is on a body hash you can drop one of them. Dedupe on the X-Blockchain0x-Event-Id (unique per delivery), and let the event type drive what your handler does.
Expecting a separate confirmation event
The shipped events are payment.received, payment.sent, wallet.deployed, and webhook.test - there is no separate confirmation event. payment.received fires when the transfer is in a block, which is your signal for most work. For something expensive or irreversible, poll transactions.get and apply your own confirmation threshold; do not wait for an event that does not exist.
Once webhooks are bulletproof.
Webhooks are the hard part. With the four patterns above in place, the remaining work is mostly operational: a test environment that exercises the failure paths, spend controls so an upstream agent does not flood your handler, and a final security review.
Test agent payments without real money
Set up agent spend controls that survive prompt injection
Secure your agent wallet before going live
Full reference at docs.blockchain0x.com. Webhook glossary: payment mandate. Product surface: Payment API.