Use Postgres unique constraints on provider event IDs to deduplicate webhook deliveries reliably at the database layer.

Why Webhook Duplicates Break Real Systems

Webhook providers guarantee at-least-once delivery, not exactly-once. Retries, network partitions, and load-balancer failover routinely send the same event multiple times. Without idempotency, duplicate processing creates duplicate orders, double charges, or inconsistent state.

Application-level deduplication with Redis or in-memory sets fails under restarts and across worker instances. A unique constraint on the database row is the simplest, most reliable mechanism because Postgres enforces it transactionally and survives restarts.

The same pattern appears when syncing Square POS orders to Postgres or syncing Toast POS orders to Supabase.

Schema Design for Event Deduplication

Create a dedicated table that stores the minimal identifying data plus a unique constraint on the provider-supplied event ID. Include the raw payload and processing status so you can replay or inspect failures later.

CREATE TABLE webhook_events (
  id BIGSERIAL PRIMARY KEY,
  provider TEXT NOT NULL,
  event_id TEXT NOT NULL,
  event_type TEXT NOT NULL,
  payload JSONB NOT NULL,
  processed_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT now(),
  UNIQUE (provider, event_id)
);

The composite unique index on (provider, event_id) prevents the same logical event from being inserted twice regardless of which worker receives it.

Inserting Events with ON CONFLICT

In your webhook handler, attempt an INSERT … ON CONFLICT DO NOTHING. If the row already exists, the database silently ignores the duplicate and you return 200 immediately. This is faster and safer than a separate SELECT first.

const result = await db.query(
  `INSERT INTO webhook_events (provider, event_id, event_type, payload)
   VALUES ($1, $2, $3, $4)
   ON CONFLICT (provider, event_id) DO NOTHING
   RETURNING id`,
  ['square', event.id, event.type, payload]
);
if (!result.rows.length) {
  return new Response('duplicate', { status: 200 });
}

Only rows that were actually inserted proceed to business logic.

Processing Inside a Transaction

Wrap the business mutation and the update of processed_at in a single transaction. If the downstream work fails, the entire transaction rolls back and the event remains unprocessed for later retry.

await db.query('BEGIN');
try {
  await applyBusinessLogic(event);
  await db.query(
    'UPDATE webhook_events SET processed_at = now() WHERE id = $1',
    [insertedId]
  );
  await db.query('COMMIT');
} catch (err) {
  await db.query('ROLLBACK');
  throw err;
}

This guarantees that an event is either fully applied or not applied at all.

Handling Retries, Timeouts, and Poison Messages

Providers may retry for hours. Store the last attempt timestamp and implement exponential backoff in a background worker rather than blocking the webhook response. After N failures, move the event to a dead-letter table so it does not block the queue.

Never delete rows on success; keep them for audit and replay. The unique constraint still protects against reprocessing even if you later re-ingest historical events.

Production Gotchas and Cost Trade-offs

Unique indexes add write amplification; monitor index bloat on high-volume endpoints. Use partial indexes if you only need to deduplicate unprocessed events. Keep payloads compressed or offloaded to object storage once processed if JSONB size becomes expensive.

The approach works identically with Stripe webhooks, Square, or any provider that supplies a stable event identifier. The same pattern appears in Stripe dunning workflows where duplicate invoice events must be ignored.