Recipe

Inbox pattern for exactly-once

When a webhook fires twice, your billing logic should not charge twice. The inbox pattern gives you exactly-once processing on top of at-least-once delivery by recording every inbound message id in a dedupe table before any side effect runs.

1.Capture the message id

Pull a stable id from every inbound event — the provider event id, the message id from your queue, or a hash of the payload if neither exists. This becomes the primary key in your inbox table.

CREATE TABLE inbox (
  message_id   TEXT PRIMARY KEY,
  source       TEXT NOT NULL,
  received_at  TIMESTAMPTZ DEFAULT NOW(),
  processed_at TIMESTAMPTZ
);

2.Insert before you act

Wrap the handler in a transaction. Attempt the insert first — if it raises a unique violation, the message is a duplicate and you return early. Only if the insert succeeds do you run the side effect (charge a card, send an email, write a row).

BEGIN;
INSERT INTO inbox (message_id, source)
VALUES ($1, 'stripe')
ON CONFLICT (message_id) DO NOTHING
RETURNING message_id;
-- if no row returned, this was a duplicate: ROLLBACK and return 200
-- else: run side effect, then UPDATE processed_at, COMMIT

3.Keep the inbox bounded

The inbox grows forever if you let it. Pick a retention window longer than your upstream provider's retry horizon — 30 days covers Stripe, SQS, and most webhook senders. Run a nightly job that deletes rows where received_at is older than the window. Index received_at so the sweep stays cheap.

Pair this with the outbox pattern on the producer side and your end-to-end pipeline is exactly-once across both hops.