Implementar Webhooks con Claude Code: firmas, idempotencia y reintentos
Implementa webhooks de producción con Claude Code: raw body, firmas, idempotencia, reintentos, pruebas y runbook.
Un webhook permite que un servicio externo avise a tu aplicación por HTTP cuando ocurre un evento. Pagos completados, pushes de GitHub, formularios, cambios de suscripción, actualizaciones de CRM y cambios de estado en un SaaS suelen resolverse con este patrón.
El error habitual es pensar que un webhook de producción es solo un POST que lee JSON. También necesitas conservar el raw body, verificar la firma del proveedor, usar una clave de idempotencia, guardar el evento antes de procesarlo, enviar el trabajo pesado a una cola de reintentos y preparar una herramienta de replay. Si pides a Claude Code “agrega un webhook”, puede generar un demo que funciona localmente pero falla cuando el proveedor reintenta o cuando el parser de JSON modifica los bytes firmados.
Para el contexto de arquitectura, conecta esta guía con desarrollo de APIs con Claude Code, gestión de secrets, buenas prácticas de seguridad y sistemas de colas.
Contrato del proveedor
| Elemento | Ejemplo GitHub | Ejemplo Stripe | Punto de implementación |
|---|---|---|---|
| Endpoint | POST /webhooks/github | POST /webhooks/stripe | Rutas separadas |
| ID del evento | X-GitHub-Delivery | event.id | Clave de idempotencia |
| Tipo de evento | X-GitHub-Event | event.type | Despacho de handler |
| Header de firma | X-Hub-Signature-256 | Stripe-Signature | Verificación |
| Entrada firmada | raw body | raw body | Orden del body parser |
| Respuesta correcta | 2xx rápido | 2xx rápido | Guardar y encolar |
Trabaja siempre con fuentes oficiales: GitHub Webhooks, validación de entregas de GitHub, Stripe Webhooks, Stripe webhook signatures, Express express.raw y Claude Code best practices.
flowchart LR
A["Provider<br/>GitHub / Stripe"] --> B["Webhook endpoint<br/>raw body"]
B --> C["Signature verification"]
C --> D["Event store"]
D --> E["Idempotency check"]
E --> F["Retry queue"]
F --> G["Domain handler"]
D --> H["Replay tool"]
Prompt recomendado
Implementa un receptor de GitHub Webhook en Express + TypeScript.
Requisitos:
- Agregar POST /webhooks/github
- Preservar raw body con express.raw({ type: "*/*" }) solo en rutas webhook
- Parsear JSON después de verificar la firma
- Verificar X-Hub-Signature-256 con HMAC SHA-256
- Usar X-GitHub-Delivery como clave de idempotencia
- Guardar cada evento aceptado antes de procesarlo
- No procesar dos veces el mismo delivery id
- Responder 202 rápido y procesar mediante retry queue
- Cubrir firma válida, firma inválida y duplicados con node:test
- Agregar replay script para entregas guardadas
- Leer el secreto desde WEBHOOK_SECRET
Receptor ejecutable
npm init -y
npm install express
npm install -D typescript tsx @types/node @types/express
Crea src/server.ts:
import crypto from "node:crypto";
import express from "express";
type EventStatus = "queued" | "processing" | "processed" | "failed";
type WebhookEvent = {
id: string;
provider: "github";
type: string;
headers: Record<string, string>;
rawBody: Buffer;
payload: unknown;
receivedAt: string;
status: EventStatus;
attempts: number;
lastError?: string;
};
export const app = express();
export const eventStore = new Map<string, WebhookEvent>();
export const processedEvents = new Set<string>();
export const retryQueue: string[] = [];
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? "dev-secret-change-me";
app.use("/webhooks", express.raw({ type: "*/*", limit: "1mb" }));
app.use(express.json());
function firstHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function safeCompare(leftValue: string, rightValue: string): boolean {
const left = Buffer.from(leftValue);
const right = Buffer.from(rightValue);
return left.length === right.length && crypto.timingSafeEqual(left, right);
}
export function signGitHubBody(
rawBody: Buffer | string,
secret = WEBHOOK_SECRET
): string {
return (
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex")
);
}
export function verifyGitHubSignature(
rawBody: Buffer,
signatureHeader: string | undefined,
secret = WEBHOOK_SECRET
): boolean {
if (!signatureHeader?.startsWith("sha256=")) return false;
return safeCompare(signGitHubBody(rawBody, secret), signatureHeader);
}
function headersForStorage(req: express.Request): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {
if (typeof value === "string") result[key] = value;
}
return result;
}
app.post("/webhooks/github", (req, res) => {
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from("");
const signature = firstHeader(req.headers["x-hub-signature-256"]);
const deliveryId = firstHeader(req.headers["x-github-delivery"]);
const eventType = firstHeader(req.headers["x-github-event"]) ?? "unknown";
if (!verifyGitHubSignature(rawBody, signature)) {
return res.status(401).json({ error: "invalid_signature" });
}
if (!deliveryId) {
return res.status(400).json({ error: "missing_delivery_id" });
}
const id = `github:${deliveryId}`;
if (processedEvents.has(id) || eventStore.has(id)) {
return res.status(202).json({ id, status: "duplicate" });
}
let payload: unknown;
try {
payload = JSON.parse(rawBody.toString("utf8"));
} catch {
return res.status(400).json({ error: "invalid_json" });
}
eventStore.set(id, {
id,
provider: "github",
type: eventType,
headers: headersForStorage(req),
rawBody,
payload,
receivedAt: new Date().toISOString(),
status: "queued",
attempts: 0,
});
retryQueue.push(id);
void processNextEvent();
return res.status(202).json({ id, status: "queued" });
});
export async function processNextEvent(): Promise<void> {
const id = retryQueue.shift();
if (!id) return;
const event = eventStore.get(id);
if (!event || event.status === "processed") return;
event.status = "processing";
event.attempts += 1;
try {
await handleWebhookEvent(event);
event.status = "processed";
processedEvents.add(id);
} catch (error) {
event.status = "failed";
event.lastError = error instanceof Error ? error.message : String(error);
if (event.attempts < 5) {
const delayMs = Math.min(30_000, 1_000 * 2 ** event.attempts);
setTimeout(() => {
event.status = "queued";
retryQueue.push(id);
void processNextEvent();
}, delayMs);
}
}
}
async function handleWebhookEvent(event: WebhookEvent): Promise<void> {
if (event.type === "push") console.log("GitHub push received", event.id);
}
if (process.env.NODE_ENV !== "test") {
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => {
console.log(`Webhook server listening on http://127.0.0.1:${port}`);
});
}
WEBHOOK_SECRET=dev-secret-change-me npx tsx src/server.ts
Envío local, pruebas y replay
scripts/send-local-webhook.ts:
import crypto from "node:crypto";
const secret = process.env.WEBHOOK_SECRET ?? "dev-secret-change-me";
const url =
process.env.WEBHOOK_URL ?? "http://127.0.0.1:3000/webhooks/github";
const body = JSON.stringify({ ref: "refs/heads/main", after: "local-test" });
const signature =
"sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");
const response = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-github-event": "push",
"x-github-delivery": `local-${Date.now()}`,
"x-hub-signature-256": signature,
},
body,
});
console.log(response.status, await response.text());
WEBHOOK_SECRET=dev-secret-change-me npx tsx scripts/send-local-webhook.ts
NODE_ENV=test npx tsx --test test/webhook.test.ts
Para replay, guarda url, headers y body sin reformatear el body. La firma se calcula sobre bytes exactos; un salto de línea añadido puede romper la verificación.
Casos de uso
En pagos, eventos de Stripe como checkout.session.completed confirman pedidos y invoice.payment_failed activan avisos o pausas de acceso. En desarrollo, push y pull_request de GitHub pueden crear previews, documentación y notificaciones internas. En formularios y CRM, el ID externo evita tickets duplicados cuando el proveedor reintenta. En webhooks salientes de tu propio SaaS, también necesitas payload firmado, logs de entrega, timeouts, reintentos y reenvío manual.
Errores comunes
El fallo más frecuente es parsear JSON antes de verificar la firma. El segundo es ejecutar trabajo pesado antes de responder 2xx, provocando reintentos del proveedor. El tercero es generar una clave de idempotencia nueva en cada request. El cuarto es guardar solo logs y no la entrega original, lo que complica la recuperación. También revisa que los secretos no estén en código ni en logs con payload completo.
Checklist de salida
- El raw body se preserva solo en rutas webhook.
- Firma inválida devuelve
401; JSON inválido devuelve400; evento aceptado devuelve202. - El delivery ID del proveedor es la clave de idempotencia.
- El event store guarda raw body, headers, estado, intentos y último error.
- La retry queue tiene límite, backoff y alerta de fallo final.
- El replay script funciona con entregas guardadas.
- Los secretos vienen de variables de entorno o secret manager.
Resumen
Un webhook de producción concentra seguridad, fiabilidad y operación en un endpoint pequeño. Claude Code ayuda si el prompt incluye contrato del proveedor, raw body, firma, idempotencia, cola, pruebas, replay y runbook desde el inicio.
ClaudeCodeLab ofrece plantillas en Products y formación para equipos en Training. En pruebas reales, fijar primero raw body e idempotencia redujo mucho los cambios posteriores que Claude Code necesitaba corregir.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Permission receipt para Claude Code: alcance, prueba y rollback
Patrón de permission receipt para Claude Code: acciones permitidas, aprobación, pruebas, rollback y CTA de ingresos.
Agent Harness seguro para Claude Code y Codex: permisos, verificacion y rollback
Diseña un Agent Harness seguro para Claude Code y Codex con permisos, plan, verificaciones y rollback.
Subagentes de Claude Code: guía práctica para delegar trabajo de forma segura
Guía práctica de subagentes en Claude Code para dividir artículos y código: reglas, prompts, riesgos y checklist.