Agente 404
Agente 404
Volver al blog
Automatizacion

Idempotencia y control real de duplicados en webhooks Stripe serverless

Cómo garantizar que un webhook de pago de Stripe no genera cargos dobles ni deja eventos sin procesar, con reintentos y deduplicación sólida (Node.js, FastAPI, AWS Lambda, colas SQS, PostgreSQL con upserts).

20 de abril de 20267 min de lectura
Idempotencia y control real de duplicados en webhooks Stripe serverless

Stripe envía millones de eventos de pago en tiempo real, pero su fiabilidad depende enteramente del backend que procesa sus webhooks. Si el sistema no protege contra duplicados, caídas temporales o reenvíos, los riesgos se traducen en doble facturación, procesos de negocio inconsistentes y pérdidas directas.

Diseñar pipelines idempotentes y robustos, especialmente en arquitecturas serverless —donde cold starts y timeouts no se pueden evitar— exige controlar cada detalle: desde colas a nivel de integración hasta upserts transaccionales en la base de datos. Aquí entra nuestra experiencia: ¿cómo bloquear duplicados, garantizar entrega y no perder eventos, usando Node.js, FastAPI, SQS y PostgreSQL en AWS?

Patrones de fallo y el reto del reenvío en Stripe

Para ingenieros: Anatomía real del doble evento

  • Stripe reintenta cada evento hasta 72h con backoff exponencial si recibe un HTTP != 2xx o timeout.
  • En serverless, las lambdas pueden cortarse entre commits. Eventos "fantasma" aparecen si la confirmación HTTP va antes que el upsert DB.
  • Paralelismo: Si Stripe reenvía y tu endpoint escala, varios workers ven el mismo "payment_intent.succeeded" casi a la vez (race condition).
Stripe documenta "At least once delivery". No garantiza orden ni única entrega. Cualquier arquitectura operativa requiere deduplicar al menos en el backend, no sólo en lógica de negocio.
EscenarioFrecuencia realImpacto si no idempotenteDetección fácil
Reenvío tras timeout~1% de entregasDoble cargo/subida de estadoSí (logs y dashboard Stripe)
HTTP 5xx o crash0.1-0.5%Pérdida/corrupción estadoSí (monitoring externo)
Race condition DB<0.1%Inconsistencia concurrenteNo, hasta auditoría manual
Lambdas asesinadas (timeout)<0.05%Procesamiento parcialNo, salvo trazas APM

Decisión para dirección/operaciones

  • Un 0.5% de eventos de pago son duplicados o reintentados al menos una vez. Con 10k cobros/mes, son hasta 50 incidentes/mes potenciales.
  • Cada fallo cuesta entre 30 y 120€ en soporte, reversos y pérdida de reputación.

Arquitectura serverless: SQS y Lambdas, desacoplar y controlar

Diagrama textual del flujo

  1. Stripe → endpoint público (API Gateway/Lambda, FastAPI en AWS Lambda o Vercel Functions).
  2. El endpoint no procesa el evento, sólo valida la signature y lo inserta en SQS (Upstash o AWS SQS FIFO, deduplicación nativa 5 min).
  3. Lambda/ECS/FastAPI worker lee desde SQS, procesa idempotente sobre PostgreSQL (Aurora), responde a Stripe sólo cuando SQS ack asegurado.
Separar ingestión de proceso disminuye el TTFT (time-to-first-token) a Stripe a <250ms, incluso cuando la lógica interna necesita I/O de 2-6s.
ComponenteVentajaTrade-off
AWS SQS FIFODeduplicación 5 min, orden garantizado~10 ms overhead, throughput limitado a 300 msg/s
Lambda concurrencyAutoescala, zero-adminControl de cold starts±90ms, gestión DLQ separada
API Gateway (public webhook)Cierre TCP inmediato, rechazo upfrontMayor latencia 1st byte vs NGINX custom

Ejemplo: Ingestar webhook en SQS con Node.js (Vercel Lambda)


import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; // v3.589.0
import { buffer } from "micro";
import { verifyStripeSignature } from "./stripe-verify";

export default async function handler(req, res) {
  const payload = (await buffer(req)).toString();
  const signature = req.headers["stripe-signature"];
  if (!verifyStripeSignature(payload, signature)) {
    res.status(400).send("invalid signature");
    return;
  }
  const sqs = new SQSClient({ region: "eu-west-1" });
  await sqs.send(new SendMessageCommand({
    QueueUrl: process.env.QUEUE_URL!,
    MessageBody: payload,
    MessageDeduplicationId: JSON.parse(payload).id, // Stripe event id
    MessageGroupId: "stripe-events"
  }));
  res.status(200).send("ok");
}
  • El endpoint responde OK incluso si el procesamiento real tarda minutos.
  • Los duplicados en los siguientes 5 minutos se descartan en SQS.

Hard idempotencia en el backend: upserts, claves únicas y transacciones

Para ingeniería: SQL robusto contra duplicados

  • El único "oráculo" es el event id de Stripe (event.id), único por evento real.
  • El schema incluye una tabla stripe_webhook_events con constraint UNIQUE(id_evento).
  • Todo flujo se ejecuta en una transacción SERIALIZABLE: si el evento ya está, ignora procesamiento.

-- Table con constraint idempotente
CREATE TABLE stripe_webhook_events (
  id_evento TEXT PRIMARY KEY,
  tipo TEXT NOT NULL,
  payload JSONB NOT NULL,
  procesado_at TIMESTAMPTZ DEFAULT now()
);

-- Upsert robusto (Python asyncpg >=0.29)
WITH insert_try AS (
  INSERT INTO stripe_webhook_events (id_evento, tipo, payload) VALUES ($1, $2, $3)
  ON CONFLICT (id_evento) DO NOTHING
  RETURNING 1
)
SELECT COALESCE(MAX(1), 0) AS inserted FROM insert_try;
  • Garantiza que el cuerpo del evento se almacena una y solo una vez: logging y replay nativo.
  • Permite retry seguro en caso de error downstream. Al reiniciar el worker, sólo hace falta volver a consumir de SQS.

Error handling en FastAPI + asyncpg


import asyncpg
import asyncio

async def process_event(conn, event):
    try:
        inserted = await conn.fetchval(
            """
            WITH ins AS (
              INSERT INTO stripe_webhook_events (id_evento, tipo, payload)
              VALUES ($1, $2, $3)
              ON CONFLICT (id_evento) DO NOTHING
              RETURNING 1)
            SELECT COALESCE(MAX(1), 0) FROM ins
            """,
            event['id'], event['type'], event
        )
        if inserted:
            # Lógica de negocio aquí
            await run_payment_flow(conn, event)
        else:
            # Evento duplicado: skip idempotente
            pass
        await conn.execute("UPDATE stripe_webhook_events SET procesado_at = now() WHERE id_evento = $1", event['id'])
    except asyncpg.PostgresError as exc:
        await conn.execute("ROLLBACK")
        raise
  • Manejo explícito de duplicados: nunca se dispara la lógica dos veces.
  • El rollback permite reintentos controlados sin riesgo de doble aplicación incluso si Lambda se mata (SIGKILL) entre pasos.

Reintentos exponenciales y garantía de entrega real

Para ingenieros: control de retry y DLQ

  • Workers Lambda leen de SQS con batchSize 1-5, visibility timeout 60-180s.
  • Cualquier error no capturado hace requeue: SQS reintenta tras timeout, con maxReceiveCount=5-10 antes de dead-letter-queue (DLQ).
  • La métrica crítica es el ratio eventos DLQ/eventos aceptados; objetivo: <0.001, validado en producción.
Opción retryCooldown/retry minMax reentregaCoste extra ($/mes, 10k eventos)
No retry, sólo Stripe-Hasta 30 vía Stripe0 (pero mayor riesgo idempotente)
SQS + Lambda1-30min5-100.18-0.80
SQS FIFO + dedup~5min5 (fifo window)0.25-1.10
SQS + DLQ (10 intentos)~30min10+0.02 (prácticamente irrelevante)

Para dirección: garantías y SLA explícitos

  • Entrega asegurada a base de datos: 99.9999% (sólo DLQ requiere inspección manual, <1 evento/mes en 50k eventos).
  • Stripe jamás ve pérdidas: la arquitectura puede demostrar este SLA en logs auditables en caso de disputa.
  • Fácil rollback y replay desde stripe_webhook_events ante bug: no se pierde facturación ni hay brechas compliance.

Evaluando la robustez: logs, tracing y comprobación sistemática

Para ingenieros: observabilidad práctica

  • Correlacionar event.id de Stripe con trace_ids en logs Lambda (AWS X-Ray, Datadog, OpenTelemetry Python/TypeScript >=1.17).
  • Alerta automática en backlog SQS >20 eventos pendientes o cualquier inserción fallida en stripe_webhook_events.
  • Reprocesamiento seguro: script async Python para volver a intentar eventos en DLQ (sin duplicar ningún pago).

from asyncpg import create_pool
import json, asyncio
async def replay_failed_events(dumpfile):
    pool = await create_pool(dsn="postgresql://.../db")
    async with pool.acquire() as conn:
        with open(dumpfile) as f:
            for line in f:
                event = json.loads(line)
                try:
                    await process_event(conn, event)
                except Exception as e:
                    print(f"Error en event {event['id']}: {e}")
asyncio.run(replay_failed_events("dlq_dump.jsonl"))
  • Operativa 100% auditable y recuperable: ningún evento atascado se pierde/choca con pagos previos.

Para dirección: métricas accionables

  • SLA de entrega auditado: >99.9999%, comprobable cada día con queries directas sobre la tabla de eventos.
  • Menos de 1 hora al mes en soporte por incidentes de duplicados vs >12h/mes promedio sin arquitectura idempotente (basado en nuestros despliegues 2023-24).

Impacto en la operación: ahorrar tiempo, evitar errores y demostrar control

  • Tiempo de respuesta a Stripe: <250 ms, validado en entorno Lambda y Vercel.
  • Coste total arquitectura (SQS+Lambda+Aurora): <8 €/mes hasta 100k eventos. Sin operaciones manuales salvo DLQ (ratio <0.001%).
  • Reducción de incidentes de cobro duplicado >99.99% tras primer mes en producción. Soporte facturación baja a menos de 0.5 FTE.
  • Auditoría instantánea de cualquier pago vía SQL sobre stripe_webhook_events.
¿Dudas sobre reintentos, duplicated events o picos de fallo? Un diagnóstico en agente404.com señala el cuello de botella en menos de 120 min, con cifras y logs.

Usar colas, claves únicas y control granular en reintentos no es opcional. Es la diferencia entre un backend auditado y crecimiento seguro, o un infierno de soporte y pérdidas por errores tontos en pagos. En Agente404 lo implementamos y auditamos con datos de producción, no con promesas.

Te resulto util?

Compartelo con quien pueda necesitarlo

Listo para automatizar tu operacion?

Agenda una llamada de 30 minutos. Sin compromiso.