Webhooks sin perder ni duplicar

Recibe los eventos de Billete, guárdalos una sola vez y procésalos aparte, aunque lleguen repetidos, tarde o en otro orden.

Para cualquier integración que entrega compras, da acceso o actualiza su base según lo que pasa en Billete. Un webhook puede llegar dos veces, llegar después de otro más nuevo o no llegar mientras tu servidor está caído; esta receta hace que nada de eso cambie el resultado.

El modelo

En tu aplicaciónEn Billete
Una ruta que recibe eventosUn endpoint de webhook por entorno, con su propio secreto
Una tabla de eventos con id únicoEl id del evento (evt_…), el mismo en cada reintento
Un job que procesa cada eventoEl recurso consultado con el SDK, en su estado actual
Una tarea que revisa lo que faltóLa lista de eventos (events.list)
  • La ruta solo verifica y guarda. Responder rápido evita reintentos innecesarios: Billete espera unos 10 segundos la respuesta y no sigue redirecciones. El trabajo pesado va en un job.
  • El id del evento es la deduplicación. Una restricción única en tu tabla hace que una entrega repetida no inserte nada, aunque lleguen dos a la vez.
  • El evento avisa; el recurso decide. Las entregas no llegan en orden, así que el job consulta el recurso y actúa según su estado actual, no según lo que suponga el evento.
  • 2xx solo después de guardar. Si respondes antes y tu servidor se cae, ese evento se pierde. Si algo falla al guardar, responde un error y Billete lo reintenta.

1. Registra el endpoint

const endpoint = await billete.webhookEndpoints.create({
  url: "https://tuapp.com/api/webhooks/billete",
  events: ["checkout.completed", "subscription.updated", "subscription.renewed"],
}, { idempotencyKey: "webhook-endpoint-produccion" });

// `endpoint.secret` solo viene en esta respuesta: guárdalo como variable privada del servidor.

Suscribe solo los eventos que procesas (["*"] recibe todos). La URL debe ser HTTPS y pública: para probar desde tu computadora, expón tu servidor local con un túnel. Un endpoint creado con una clave sk_test_ solo recibe eventos de prueba; producción necesita el suyo, con otro secreto (de prueba a producción).

2. Verifica y guarda

import { verifyWebhook, WebhookVerificationError } from "@boelabs/billete-sdk/webhooks";

export async function POST(request: Request) {
  const event = await verifyWebhook({
    payload: await request.text(),
    headers: request.headers,
    secret: process.env.BILLETE_WEBHOOK_SECRET!,
  }).catch((error) => {
    if (error instanceof WebhookVerificationError) return null;
    throw error;
  });
  if (!event) return new Response("Firma inválida", { status: 400 });
  // Un evento de otro entorno es un error de configuración: no lo proceses.
  if (event.environment !== process.env.BILLETE_ENVIRONMENT) return new Response(null, { status: 200 });

  await db.transaction(async (tx) => {
    // `billete_events.id` es único: una entrega repetida no inserta nada.
    const inserted = await tx.billeteEvents.insertOrIgnore({ id: event.id, type: event.type, event });
    if (inserted) await tx.jobs.enqueue("billete-event", { eventId: event.id });
  });
  return new Response(null, { status: 200 });
}

Verifica con el cuerpo tal como llegó (request.text()): si lo conviertes a JSON y lo vuelves a serializar, la firma ya no coincide. Guardar el evento y encolar su job en la misma transacción evita que quede uno sin el otro. Mira verificar webhooks.

3. Procesa en un job

async function processBilleteEvent(eventId: string) {
  const { event } = await db.billeteEvents.get(eventId);

  switch (event.type) {
    case "checkout.completed": {
      const checkout = await billete.checkouts.retrieve(event.data.id);
      // Marcar el pedido como pagado solo si seguía pendiente hace que entregar sea idempotente.
      await db.orders.markPaidIfPending(checkout.metadata.orderId, checkout.id);
      break;
    }
    case "subscription.updated":
    case "subscription.renewed": {
      const subscription = await billete.subscriptions.retrieve(event.data.id);
      await db.users.updateBySubscription(subscription.id, {
        planId: subscription.planId,
        status: subscription.status,
      });
      break;
    }
  }
  await db.billeteEvents.markProcessed(eventId);
}

Si el job falla, tu cola lo reintenta: el evento ya está guardado. Que cada efecto sea idempotente por sí mismo (un pedido que ya se entregó no se entrega otra vez) protege también contra un job que corre dos veces. Para las llamadas que mueven dinero desde el job, usa una idempotencyKey estable, como refund-${order.id}.

4. Reintentos y eventos perdidos

Si tu ruta no responde 2xx, Billete reintenta durante unas 41 horas, cada vez más espaciado, y te avisa si un endpoint falla varias veces seguidas. En el dashboard, en Desarrolladores → Webhooks, ves cada entrega con su respuesta y puedes reenviarla.

Para no depender de eso, revisa de vez en cuando los eventos recientes y guarda los que falten con la misma deduplicación:

// Por ejemplo, cada hora: lo de las últimas 3 horas.
const since = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();

for await (const event of billete.events.iterate({ createdFrom: since })) {
  const inserted = await db.billeteEvents.insertOrIgnore({ id: event.id, type: event.type, event });
  if (inserted) await db.jobs.enqueue("billete-event", { eventId: event.id });
}

events.list admite filtrar por type y por fecha (eventos).

5. Fija la versión del endpoint

Cada endpoint tiene su apiVersion: los eventos le llegan con la forma de esa versión, por defecto la del SDK que lo creó. Los eventos de events.list llegan en la versión de tu SDK, así que mantén las dos iguales para que el mismo job sirva para ambos. Al actualizar el SDK, despliega primero el código que entiende la versión nueva y luego cambia el endpoint con webhookEndpoints.update(id, { apiVersion }): desde ahí, todas sus entregas, incluidos los reintentos pendientes, llegan con esa forma. Mira versiones.

6. Pruébalo

  • Desde Desarrolladores → Webhooks, envía al endpoint un evento de prueba (webhook_endpoint.test). Solo comprueba que recibes y verificas; tu job debe ignorarlo.
  • Reenvía una entrega ya procesada y comprueba que no duplica nada.
  • Apaga tu servidor durante un pago de prueba, vuelve a encenderlo y comprueba que el reintento o la revisión de eventos lo recuperan.
EventoPara qué sirve en esta receta
checkout.completedEntregar un pedido o activar una compra.
subscription.*Mantener el plan y el estado de cada usuario.
payment.refunded, refund.failedReflejar devoluciones.
webhook_endpoint.testProbar el endpoint. No describe nada de tu negocio.

Webhooks · Verificar webhooks · Endpoints de webhooks · Catálogo de eventos