cauril/Docs

Webhooks

Recibí eventos firmados, verificá la firma y manejá reintentos.

Cauril te entrega un feed normalizado de eventos por webhook. Cada entrega es un POST con un Event en el body y dos headers clave: Cauril-Signature y Cauril-Event-Id.

Suscribirse

BASH
curl https://api.cauril.com/v1/webhooks/endpoints \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://tuapp.com/webhooks/cauril",
    "enabled_events": ["payment.succeeded", "payment.failed", "refund.succeeded"]
  }'

La respuesta incluye secret una sola vez. Guardalo: lo necesitás para verificar la firma.

Verificar la firma

El header tiene la forma Cauril-Signature: t=<timestamp>,v1=<firma>, donde la firma es HMAC-SHA256 de la cadena "{t}.{cuerpo_crudo}" usando el secret del endpoint.

Verificá sobre el cuerpo crudo (los bytes exactos recibidos), antes de parsear el JSON. Re-serializar el body cambia los bytes y la firma no va a coincidir.
Node.js (Express)
import express from "express";
import crypto from "node:crypto";

const app = express();
const SECRET = process.env.CAURIL_WEBHOOK_SECRET;
const TOLERANCE_SEC = 300;

// Importante: capturá el cuerpo crudo.
app.post("/webhooks/cauril", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Cauril-Signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = Number(parts.t);
  const raw = req.body.toString("utf8");

  // Anti-replay: rechazá firmas viejas.
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SEC) {
    return res.sendStatus(400);
  }

  const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${raw}`).digest("hex");
  const ok =
    parts.v1 &&
    expected.length === parts.v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
  if (!ok) return res.sendStatus(400);

  const event = JSON.parse(raw);
  // ... manejá el evento (idempotente por event.id) ...
  res.sendStatus(200); // respondé 2xx rápido
});

Respondé rápido

Devolvé un 2xx apenas recibís y validás el evento. Hacé el trabajo pesado de forma asíncrona: si tardás, contás como fallo y disparás reintentos.

Reintentos y durabilidad

Si tu endpoint no responde 2xx, Cauril reintenta con backoff exponencial y jitter: aproximadamente a los 0s, 1m, 5m, 30m, 2h, 6h, 12h, 24h… por hasta ~72 horas. Agotados los intentos, la entrega queda exhausted y podés reenviarla manualmente desde el dashboard.

La durabilidad vive del lado de Cauril: aunque haya cortes, los reintentos pendientes se reanudan. Tu trabajo es responder 2xx cuando puedas procesar el evento.

Idempotencia del lado del receptor

Diseñá tu handler para tolerar entregas repetidas: ante un reintento, podés recibir el mismo evento más de una vez. Deduplicá por Cauril-Event-Id (o event.id) y procesá una sola vez.

Tipos de evento

EventoCuándo
payment.createdSe creó un pago (aún sin resolver).
payment.processingEl pago pasó a processing.
payment.requires_actionEl pago necesita acción del cliente (3DS).
payment.succeededEl pago se cobró con éxito.
payment.failedEl pago fue rechazado o falló.
payment.canceledEl pago se canceló.
refund.pendingSe inició un reembolso.
refund.succeededEl reembolso se completó.
refund.failedEl reembolso falló.
subscription.createdSe creó una suscripción.
subscription.updatedCambió el plan, el estado o el período de una suscripción.
subscription.canceledSe canceló una suscripción.
subscription.past_dueFalló el cobro del período y la suscripción quedó past_due.
subscription.unpaidSe agotaron los reintentos de cobro; la suscripción quedó unpaid.
invoice.createdSe generó una factura para un período de suscripción.
invoice.paidSe cobró una factura.
invoice.payment_failedFalló el cobro de una factura.
provider_connection.errorUna conexión de PSP pasó a estado de error.
Todos los eventos comparten la envoltura Event: id, type, api_version, created_at, livemode y data.object (el recurso afectado, ej. un Payment).