El webhook de un pago llegó dos veces: cómo no acreditarlo de más
Reintentos, entregas en paralelo, replays y reembolsos que se adelantan. Cuatro controles para el receptor, probados en un laboratorio local con un proveedor ficticio.
En este artículo
Tu tienda recibe un webhook: el pago de la orden 1041 está aprobado. Acreditás $ 25.000 y respondés 200. A los treinta segundos llega el mismo evento otra vez. Tu código lo procesa como nuevo y la orden queda con $ 50.000 acreditados.
Nadie atacó nada. El proveedor no vio tu 200 a tiempo y reintentó, que es lo que tiene que hacer. Stripe, por ejemplo, avisa que un endpoint puede recibir el mismo evento más de una vez y no garantiza el orden en que llegan. La pregunta de tu receptor no es si el evento es válido, sino si ya lo había anotado.
El proveedor manda el pago aprobado de la orden 1041: $ 25.000.
Para mi charla en la village de CyberFinance de Ekoparty armé un laboratorio para verlo pasar: una tienda con cinco órdenes, un proveedor ficticio que firma cada webhook con HMAC y reintenta si no recibe un 2xx, y un receptor en tres versiones. Corre local, con Python y SQLite, y los montos son inventados. Las salidas de abajo son de ahí.
Cinco entregas que salen mal #
Ninguna de estas es un exploit raro. Pasan en producción un martes cualquiera:
- Reintento: el proveedor no vio el 200 y manda el mismo evento.
- Reintento concurrente: las dos entregas llegan juntas, a dos workers distintos.
- Replay: alguien reenvía la misma request firmada tres días después.
- Callback falso: un «aprobado» que el proveedor nunca mandó, firmado con otra clave.
- Orden invertido: el reembolso llega antes que la aprobación.
El receptor ingenuo respondió 200 a las nueve entregas. Al conciliar contra la liquidación del proveedor, las cinco órdenes quedaron mal:
✗ ord_1041: proveedor «approved», esperado paid $ 25.000; hay paid $ 50.000
✗ ord_1042: proveedor «approved», esperado paid $ 18.500; hay paid $ 37.000
✗ ord_1043: proveedor «approved», esperado paid $ 42.000; hay paid $ 84.000
✗ ord_1044: proveedor «sin pago», esperado created $ 0; hay paid $ 30.000
✗ ord_1045: proveedor «refunded», esperado refunded $ 0; hay paid $ 0
5 diferencias · acreditado de más: $ 115.500Ninguna respuesta dio error. El problema estaba en la cuenta.
Conciliación contra el proveedor
Respondió 200 a las nueve entregas. Las cinco órdenes quedaron mal.
1. Firma y ventana de tiempo #
La firma HMAC confirma que quien envió el mensaje conoce la clave compartida; el timestamp firmado limita cuánto tiempo puede reutilizarse esa misma request. El laboratorio firma timestamp.cuerpo y acepta hasta cinco minutos de diferencia, la tolerancia que usan por defecto las librerías de Stripe. La salida del receptor endurecido, recortada:
11/10 11:15:30 evt_0003 misma request, +3 días → 401 timestamp fuera de ventana: 259200 s (máx 300)
11/10 11:15:30 evt_9999 firmado con otra clave → 401 firma inválidaDos detalles rompen esto en la práctica. La firma se calcula sobre el cuerpo crudo: si tu framework parsea el JSON y lo vuelve a serializar antes de verificar, ya no coincide. Y la comparación tiene que ser en tiempo constante, con algo como hmac.compare_digest y no con ==.
Lo que la firma no frena es el reintento legítimo: en Stripe, cada entrega tiene su propia firma y timestamp.
2. Idempotencia: anotar antes de aplicar #
La segunda versión del receptor parece correcta. Busca el event_id; si no lo vio, acredita y recién al final lo anota. Con el reintento secuencial funciona. Con dos entregas en paralelo, no:
08/10 11:15:30 evt_0002 entrega A (en paralelo) → 200 ok: payment.approved aplicado
08/10 11:15:30 evt_0002 entrega B (en paralelo) → 200 ok: payment.approved aplicadoLas dos leyeron «no visto» antes de que ninguna escribiera. Resultado: una diferencia de $ 18.500.
1 de 4Las dos entregas de evt_0002 llegan juntas, una a cada worker.
La versión endurecida invierte el orden. Inserta el id del evento primero, en una tabla donde no se puede repetir, y en la misma transacción que acredita. La segunda entrega choca contra el índice, el receptor contesta 200 como duplicado y no toca nada. Simplificado:
con.execute("BEGIN IMMEDIATE")
try:
marca = con.execute(
"INSERT INTO processed_events (event_id, processed_at) VALUES (?, ?) "
"ON CONFLICT(event_id) DO NOTHING",
(evt["id"], now),
)
if marca.rowcount == 0:
con.execute("ROLLBACK")
return 200, "duplicado: ya procesado"
aplicar(con, evt)
con.execute("COMMIT")
return 200, "aplicado"
except Exception:
con.execute("ROLLBACK")
raiseEn Postgres o MySQL el principio es el mismo, aunque cambie la sintaxis: una restricción UNIQUE sobre el id del evento y el insert dentro de la transacción que acredita. Una caché como Redis descarta duplicados rápido, pero si la marca vive afuera de esa transacción, el hueco vuelve.
Esto cubre las reentregas del mismo evento, que es lo que prueba el laboratorio. Stripe advierte que también puede generar dos objetos Event distintos para el mismo objeto y tipo. En ese caso, los ids de evento son diferentes: la acreditación necesita además su propia clave única de negocio, por ejemplo el id del pago, para no aplicarse dos veces.
3. Estados que no pueden existir #
El receptor ingenuo deja que gane el último evento. Con el reembolso adelantado, la orden 1045 terminó «pagada» y con $ 0 acreditados, mientras el proveedor dice que la devolvió.
1 de 3El reembolso evt_0005 llega primero. Gana el último: la orden pasa de created a refunded y descuenta $ 12.750 que nunca acreditó.
La versión endurecida tiene las transiciones escritas, y no hay otras:
TRANSITIONS = {("created", "payment.approved"): "paid",
("paid", "payment.refunded"): "refunded"}created más payment.refunded no está en la tabla. El receptor contesta 409, guarda el evento en una cola de dead-letter con el motivo y no toca la orden. Como no recibió un 2xx, el proveedor reintenta más tarde, y para entonces el pago ya está aprobado:
11/10 11:16:30 evt_0005 llega primero → 409 transición inválida: created → payment.refunded · a dead-letter
11/10 11:16:35 evt_0004 llega después → 200 ok: created → paid
11/10 11:17:35 evt_0005 reintento del proveedor → 200 ok: paid → refundedEl rollback del 409 también deshace la marca de procesado; si no, el reintento se descartaría como duplicado. Y todo esto se apoya en que el proveedor reintenta: antes de copiarlo, fijate qué hace el tuyo con un 409 y por cuánto tiempo. Stripe, por ejemplo, reintenta hasta tres días en producción.
4. Dead-letter, conciliación y un registro que sirva #
Lo que no se pudo aplicar queda en dead-letter con su motivo: no se pierde ni se fuerza. Y todos los días se concilia contra la liquidación del proveedor. Con los cuatro controles, el laboratorio termina así:
| Receptor | Qué hace | Diferencias | Acreditado de más |
|---|---|---|---|
| Ingenuo | Aplica todo | 5 de 5 | $ 115.500 |
| Busca y después anota | Solo deduplica (probado con los dos reintentos) | 1 | $ 18.500 |
| Endurecido | Firma, UNIQUE en la transacción, estados y dead-letter | 0 | $ 0 |
Además registra cada decisión con el id del evento, la orden y el motivo, nunca con el secreto ni la firma. Con eso, alguien puede contestar qué pasó con la orden 1045 sin adivinar.
Si tu proveedor pide devolver el 2xx antes de la lógica que tarda, como Stripe, guardá el evento crudo y aplicalo en un worker. El 2xx pasa a significar «lo tengo guardado», y el UNIQUE, los estados y la dead-letter se mudan al worker, donde se acredita.
Qué revisar en el receptor que ya tenés #
No hace falta reescribirlo. Alcanza con contestar estas seis preguntas:
- ¿Verificás la firma sobre el cuerpo crudo, en tiempo constante, y rechazás timestamps viejos?
- ¿El id del evento tiene
UNIQUEen la misma transacción que el efecto? Si llegan eventos distintos para el mismo pago, ¿la acreditación también tiene una clave única? - ¿Las transiciones de estado están escritas, o gana el último evento que llega?
- ¿Respondés 2xx solo si aplicaste el evento, ya lo tenías o lo guardaste para procesarlo?
- ¿Conciliás contra el proveedor todos los días?
- ¿Cada decisión queda registrada con el id de la orden, y ningún secreto en el log?
Si alguna respuesta es «no sé», empezá por la conciliación: es la que te dice si esto ya te pasó. La checklist de la charla, con los diagramas de estado, está junto a las slides en valentorassa.com/charlas.
Lo próximo llega también por mail. Cada dos semanas, en Apuntes.
Comentarios
Pasan por moderación antes de aparecer.