Torna al blog

2026-05-20

Webhook Stripe: idempotenza, duplicati e concorrenza

Schema minimo e handler transazionale per eventi duplicati, out-of-order e at-least-once delivery.

Stripe (e analoghi seri) consegna webhook at-least-once: lo stesso id evento può arrivare due volte, e due eventi diversi possono arrivare in ordine invertito rispetto alla timeline business. Il tuo handler deve essere idempotente e serializzabile rispetto alla chiave naturale dell’evento.

Documentazione di riferimento: Gestire eventi duplicati e verifica firma con signing secret (header Stripe-Signature). La versione API (Stripe-Version) va allineata al tuo backend.

Semantica at-least-once

Implica tre obblighi:

  1. Rispondere 2xx rapidamente dopo aver persistito l’evento o averlo accodato in modo transazionale—Stripe ritenta su timeout o 5xx.
  2. Non applicare side-effect duplicati se lo stesso event.id viene ritentato.
  3. Ordinare gli update di stato business con cautela: customer.subscription.updated può arrivare dopo …deleted in caso di retry—usa versione monotona o confronto timestamp Stripe (event.created) con locking.

Schema tabella minimo

CREATE TABLE stripe_events (
  id            text PRIMARY KEY,        -- evt_*
  type          text NOT NULL,
  received_at   timestamptz NOT NULL DEFAULT now(),
  processed_at  timestamptz,
  payload_hash  bytea
);

id è l’id Stripe dell’evento, non il tuo id interno. processed_at null = da processare o in flight.

Handler transazionale (pseudocodice)

BEGIN;
INSERT INTO stripe_events (id, type) VALUES ($eventId, $type)
  ON CONFLICT (id) DO NOTHING;
IF NOT FOUND (row inserted) THEN
  COMMIT; RETURN 200;
END IF;
INSERT INTO jobs (event_id, ...) VALUES ($eventId, ...);
COMMIT;

Se la logica di business è lunga, pattern sicuro: transazione breve che deduplica + accoda; worker separato che marca processed_at.

Concorrenza e unique constraint

Due istanze dello stesso handler possono ricevere lo stesso POST quasi in parallelo. UNIQUE(event.id) + INSERT … ON CONFLICT (Postgres) o equivalente in SQLite (INSERT OR IGNORE) evita doppie elaborazioni. Evita “SELECT then INSERT” senza lock—race garantita.

Sicurezza e replay

Prima di toccare DB:

  1. Verifica firma con secret del endpoint webhook (non la secret API generica).
  2. Controlla timestamp nel payload firma contro clock skew (Stripe documenta tolleranza default).
  3. Ignora eventi troppo vecchi se il dominio lo richiede (finestra anti-replay).

Test minimi

  • Doppio POST identico → una sola riga stripe_events con side-effect una tantum.
  • Due eventi sequenziali invertiti simulati (mock) → stato finale coerente con regola scelta (es. max created vince).
  • Timeout: handler lento > soglia → Stripe ritenta; verifica che il secondo passaggio non raddoppi addebiti interni.

Cosa dice Stripe (verificabile)

  • Retry: documentazione e prodotti webhook ritentano le consegne fallite per fino a ~3 giorni con backoff—tratta ogni event.id come idempotency key naturale. Approfondimento operativo: Process undelivered events e panorama Receive Stripe events.
  • constructEvent / verifica firma: le librerie ufficiali richiedono il corpo grezzo (raw body) del POST + header Stripe-Signature + webhook secret dell’endpoint—middleware che parsano JSON prima della verifica rompono la firma. Riferimento: Resolve signature errors.
  • CLI locale: stripe listen --forward-to localhost:3000/webhook resta il modo più rapido per catturare payload reali quando integri per la prima volta.

Limitazioni

Non copre Stripe Connect multi-account, thin events beta, né idempotency key sulle API di pagamento (argomento affine ma distinto).

Sintesi

Persisti l’id evento con vincolo unico prima di side-effect; rispondi 2xx dopo commit; sposta business lento in coda. Così sopravvivi a duplicati, retry e una dose di out-of-order senza perdita di denaro o stato.

Vuoi applicare idee come queste al tuo prodotto?

Raccontaci contesto, vincoli e obiettivi: ti diciamo se ha senso lavorare insieme e come impostare il primo passo.