---
title: "El webhook de un pago llegó dos veces: cómo no acreditarlo de más"
description: "Reintentos, entregas en paralelo, replays y reembolsos que se adelantan. Cuatro controles para el receptor, probados en un laboratorio local con un proveedor ficticio."
canonical: "https://vtsecurity.com.ar/articulo/webhook-de-pago-duplicado"
author: "Valentín Torassa"
lang: "es-AR"
datePublished: "2026-10-19T12:00:03.445Z"
dateModified: "2026-10-19T17:29:02.870Z"
---

# 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.

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](https://docs.stripe.com/webhooks#handle-duplicate-events) que un endpoint puede recibir el mismo evento más de una vez y [no garantiza](https://docs.stripe.com/webhooks#event-ordering) 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**.

*Tiempo comprimido: en el laboratorio el reintento llegó a los 30 segundos.*

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:

1. **Reintento:** el proveedor no vio el 200 y manda el mismo evento.
2. **Reintento concurrente:** las dos entregas llegan juntas, a dos workers distintos.
3. **Replay:** alguien reenvía la misma request firmada tres días después.
4. **Callback falso:** un «aprobado» que el proveedor nunca mandó, firmado con otra clave.
5. **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:

```text
✗ 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.500
```

Ninguna respuesta dio error. El problema estaba en la cuenta.

*Montos y estados de la conciliación del laboratorio.*

## 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](https://docs.stripe.com/webhooks#replay-attacks). La salida del receptor endurecido, recortada:

```text
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álida
```

Dos 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`](https://docs.python.org/3/library/hmac.html#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](https://docs.stripe.com/webhooks#replay-attacks).

## 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:

```text
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 aplicado
```

Las dos leyeron «no visto» antes de que ninguna escribiera. Resultado: una diferencia de $ 18.500.

*Los pasos y los montos son los del laboratorio; el ancho de cada paso es ilustrativo.*

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:

```python
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")
    raise
```

En 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](https://docs.stripe.com/webhooks#handle-duplicate-events) 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ó.

*Los eventos, los códigos y los tiempos son los del laboratorio.*

La versión endurecida tiene las transiciones escritas, y no hay otras:

```python
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:

```text
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 → refunded
```

El `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](https://docs.stripe.com/webhooks#automatic-retries) 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](https://docs.stripe.com/webhooks) 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:

1. ¿Verificás la firma sobre el cuerpo crudo, en tiempo constante, y rechazás timestamps viejos?
2. ¿El id del evento tiene `UNIQUE` en la misma transacción que el efecto? Si llegan eventos distintos para el mismo pago, ¿la acreditación también tiene una clave única?
3. ¿Las transiciones de estado están escritas, o gana el último evento que llega?
4. ¿Respondés 2xx solo si aplicaste el evento, ya lo tenías o lo guardaste para procesarlo?
5. ¿Conciliás contra el proveedor todos los días?
6. ¿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](https://valentorassa.com/charlas/ekoparty-2026-cyberfinance?ref=blog).
