Advanced (Actualizado: 3/6/2026)

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.

Implementar Webhooks con Claude Code: firmas, idempotencia y reintentos

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

ElementoEjemplo GitHubEjemplo StripePunto de implementación
EndpointPOST /webhooks/githubPOST /webhooks/stripeRutas separadas
ID del eventoX-GitHub-Deliveryevent.idClave de idempotencia
Tipo de eventoX-GitHub-Eventevent.typeDespacho de handler
Header de firmaX-Hub-Signature-256Stripe-SignatureVerificación
Entrada firmadaraw bodyraw bodyOrden del body parser
Respuesta correcta2xx rápido2xx rápidoGuardar 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 devuelve 400; evento aceptado devuelve 202.
  • 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.

#Claude Code #Webhook #diseño API #seguridad #procesamiento asíncrono
Gratis

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.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.