Los patrones de webhook sobre los que más preguntan los desarrolladores.
Un manejador de webhook confiable hace cuatro cosas en orden: verifica la firma contra el cuerpo raw (webhooks.verify en Node, el HMAC documentado en otros lugares), deduplica por id de evento, encola el trabajo en una cola de fondo y devuelve 200. El trabajo de larga duración ocurre en el trabajador, con reintentos e idempotencia en la capa de cola. Debido a que no hay un evento de fallo, una barrido programado expira trabajos atascados esperando pago y los reconcilia.
Antes de comenzar.
- Un perfil de agente funcional y el secreto de firma de tu panel de control (Configuraciones - Webhooks).
- Un marco web con acceso a cuerpo sin procesar - Express con
express.raw, FastAPI, Flask, etc. El middleware de análisis automático de JSON rompe la verificación de firmas. - Una cola de trabajos: BullMQ (Node) o Celery/arq (Python). El webhook devuelve 200 rápidamente y la cola realiza el trabajo lento.
- Una base de datos con una primitiva de upsert (Postgres funciona; Redis SET NX también funciona para deduplicación de corta duración).
- Un endpoint HTTPS público - en desarrollo, ngrok o una vista previa de implementación. El remitente no entregará a URLs privadas.
Verificar la firma.
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)) <= 300Haz que el controlador sea idempotente.
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)Encola y devuelve 200 rápido.
El endpoint del webhook debe responder en un segundo. Cualquier cosa más lenta invita a tiempos de espera y reintentos. El patrón es: verificar, encolar, responder. La cola ejecuta la entrega real en un trabajador con reintentos, retroceso exponencial y su propia idempotencia. BullMQ y Celery ambos soportan ID por trabajo, lo que previene el reencolado accidental del mismo evento.
// 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 sigue la misma estructura en el lado de Python - registra la tarea con un id de trabajo determinista y deja que la cola maneje los reintentos. La restricción clave es que la encolada misma debe ser rápida (un solo viaje redondo a Redis); nunca bloquees el webhook en llamadas remotas.
Maneja un pago que nunca llega.
No hay webhook de fallo - si un comprador abandona, no llega ningún evento, y el agente queda atascado en 'awaiting_payment'. Así que detecta esto tú mismo: ejecuta una barrida programada sobre trabajos que han esperado demasiado tiempo, reconciliar contra la cadena con transactions.get en caso de que realmente se haya liquidado, luego libera los recursos retenidos, mueve el trabajo a un estado terminal no pagado, y (si es apropiado) muestra el resultado al usuario.
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"])Cinco errores que eliminan o duplican eventos.
Analizando el cuerpo antes de verificar la firma
HMAC debe ser calculado sobre los bytes en bruto que el remitente firmó. Si tu marco analiza automáticamente JSON antes de que se ejecute tu controlador, los bytes que firmes localmente no coincidirán con los bytes que el remitente firmó (espacios en blanco diferentes, orden de claves, codificación) y cada firma parecerá inválida. Configura la ruta para recibir el cuerpo en bruto (Express: express.raw, Flask: request.get_data), verifica primero, luego analiza.
Realizando el trabajo real dentro del manejador de webhook
Los webhooks tienen políticas de reintento agresivas. Si tu manejador tarda 30 segundos en entregar el trabajo, el tiempo de espera del remitente se activa y el webhook se reenvía - ahora tienes dos entregas en vuelo para el mismo pago. Siempre: verifica, encola, devuelve 2xx. El trabajo real se ejecuta en un trabajador en segundo plano que puede tardar lo que necesite.
Usando el estado HTTP para comunicar la lógica de negocio
Si tu controlador devuelve un 4xx cuando el usuario ya no existe en tu sistema, el remitente lo trata como 'solicitud inválida' y deja de reintentar. Si devuelve un 5xx por la misma condición, el remitente reintenta para siempre y tu cola se llena. Devuelve 200 una vez que hayas persistido de manera segura el evento (o lo hayas reconocido como duplicado); utiliza la lógica de cola, no el estado HTTP, para expresar decisiones comerciales.
Idempotencia en un hash de carga útil en lugar del ID del evento
Dos eventos diferentes sobre el mismo agente (un payment.received y un payment.sent posterior) tienen cuerpos diferentes y necesitan legítimamente un procesamiento separado. Si tu dedupe está en un hash de cuerpo, puedes descartar uno de ellos. Dedupe en el X-Blockchain0x-Event-Id (único por entrega), y deja que el tipo de evento determine lo que hace tu controlador.
Esperando un evento de confirmación separado
Los eventos enviados son payment.received, payment.sent, wallet.deployed y webhook.test - no hay un evento de confirmación separado. payment.received se activa cuando la transferencia está en un bloque, que es tu señal para la mayoría de los trabajos. Para algo costoso o irreversible, consulta transactions.get y aplica tu propio umbral de confirmación; no esperes un evento que no existe.
Una vez que los webhooks sean a prueba de fallos.
Los webhooks son la parte difícil. Con los cuatro patrones anteriores en su lugar, el trabajo restante es principalmente operativo: un entorno de prueba que ejercita los caminos de falla, controles de gasto para que un agente de upstream no inunde su controlador y una revisión final de seguridad.
Pruebe los pagos de agentes sin dinero real
Configurar controles de gasto del agente que sobrevivan a la inyección de prompts
Asegura tu billetera de agente antes de ir en vivo
Referencia completa en docs.blockchain0x.com. Glosario de webhook: mandato de pago. Superficie de producto: API de Pagos.