Documentación

Webhooks

Cómo te avisa el plano de control de que un cobro ha salido bien, de que ha fallado o de que alguien ha abierto una disputa.

Diseñado, sin construir

El contrato está cerrado en el documento de arquitectura; la entrega todavía no está construida.

1. Por qué tu instancia no habla con Stripe

Tu instancia no tiene endpoint de Stripe y no lo va a tener. Los eventos de Stripe Connect llegan a un único punto del plano de control, que resuelve a qué cuenta conectada pertenecen por el campo account del evento, los traduce a eventos de dominio de Payezz y te los reenvía firmados con tu propio secreto.

Esto compra tres cosas:

  • Un solo secreto de webhook de Stripe en todo el sistema, en lugar de uno por cliente.
  • Instancias que no necesitan ser alcanzables desde internet por un tercero.
  • Una política de reintento que es nuestra, y que por tanto podemos corregir sin depender de nadie.

2. Catálogo de eventos

EventoCuándo se emite
payment_intent.succeededEl cobro se ha completado. Es el que activa o renueva el servicio.
payment_intent.failedEl cobro ha fallado de forma definitiva. Entra la política de dunning.
charge.refundedSe ha reembolsado total o parcialmente, con el ajuste de comisión ya aplicado.
charge.dispute.createdEl comprador ha abierto una disputa. Conviene suspender antes de perderla.
account.updatedHa cambiado el estado de tu cuenta conectada, por ejemplo si Stripe deja de permitir cobros.
plan.changedHa cambiado tu plan en Payezz, y con él la comisión aplicable y los límites.

3. Formato de la entrega

POST /webhooks/payezz HTTP/1.1
Host: tienda.acme.com
Content-Type: application/json
Payezz-Event-Id: evt_01J8Z3M4K7...
Payezz-Timestamp: 1789459260
Payezz-Signature: v1=9c40...11bd
Payezz-Api-Version: 2026-09-01

{
  "id": "evt_01J8Z3M4K7...",
  "type": "payment_intent.succeeded",
  "created_at": "2026-10-03T09:14:20Z",
  "data": {
    "payment_intent_id": "pi_3Q...",
    "amount_minor": 1499,
    "currency": "eur",
    "reference": { "kind": "service", "id": "svc_4821" },
    "fee": { "total_minor": 48, "currency": "eur" },
    "ledger_entry_id": "cle_01J..."
  }
}

4. Verificar la firma

El esquema es el mismo que el de las peticiones salientes, en la dirección contraria: HMAC-SHA256(secret, timestamp + "." + cuerpo_crudo).

  • Firma el cuerpo crudo. Si tu framework te da el JSON ya interpretado y vuelves a serializarlo, la firma no cuadrará.
  • Compara en tiempo constante, nunca con una igualdad normal.
  • Rechaza lo que venga con un Payezz-Timestamp a más de 300 segundos de tu reloj.
  • Si la firma no cuadra, responde 401 y no proceses nada.

5. Qué debe contestar tu instancia

Responde 200 en cuanto hayas persistido el evento en tu cola local, y procesa después. No hagas el trabajo dentro del manejador.

El motivo es concreto: si procesas dentro y algo tarda, la entrega se agota, se reintenta, y acabas con dos ejecuciones del mismo trabajo o con un evento perdido, según cómo hayas resuelto la idempotencia. La cola separa recibir de procesar, que son dos problemas distintos.

6. Idempotencia por máquina de estados

Aquí va el error que más caro sale, y lo contamos porque lo hemos cometido: registrar el evento como procesado antes de procesarlo. Si el procesamiento falla y devuelves un error, el reintento ve el identificador ya registrado, responde «duplicado» y no hace nada. El evento se pierde para siempre, y un pago cobrado puede no activar nunca el servicio.

La idempotencia no se resuelve con la presencia de una fila, sino con una máquina de estados:

recibido → INSERT eventos(id, status='received')  con UNIQUE(id)
   conflicto y status='done'       → 200, no hacer nada
   conflicto y status='processing' → 200, ya hay otro trabajando
   conflicto y status='failed'     → reintentar

200 inmediato ANTES de procesar

cola → tomar con SKIP LOCKED → status='processing', lease de 60 s
       procesar en transacción
       éxito → status='done', processed_at
       fallo → status='failed', attempts++, run_after creciente
               tras 8 intentos → cola de fallos y alerta

Y una advertencia sobre el agravante: la comprobación de idempotencia no puede fallar en abierto. Si tu base de datos no responde y tratas ese fallo como «no lo había visto», el reintento provoca una doble activación. Si no puedes comprobar, devuelve error y deja que se reintente.

7. Reintentos

Si tu instancia no responde 2xx, el plano de control reintenta la entrega con esta secuencia:

0 s → 30 s → 2 min → 10 min → 1 h → 6 h → 24 h

Todas las entregas del mismo evento llevan el mismo Payezz-Event-Id. Esa es la clave sobre la que aplicar el apartado anterior. Agotados los reintentos, el evento queda en la cola de fallos del plano de control y se avisa por correo: no se descarta en silencio.

8. Caducidad de los eventos

La tabla de eventos recibidos crece sin parar si nadie la limpia, y en un sistema de facturación con años de vida eso acaba siendo un problema de rendimiento real. Purga los eventos en estado done con más de noventa días. Los que estén en failed no se purgan: se revisan.