Inbound

Deduplication

Webhooks are at-least-once. The dedup helper makes them once-effectively — atomically, across concurrent workers and crashes.

Every webhook delivery system worth using is at-least-once. The producer retries on failure; the network drops packets; the receiver acks but its acknowledgment doesn't make it back. Plan for the same message arriving twice. Plan for it arriving twice within the same second from two competing workers.

The fix is well-known: index each receipt on a stable identifier (the webhook-id header), and ignore the second one. The hard part is the atomically — across concurrent workers, across crashes, with no race where two workers both think they're the first.

Postel ships a dedup helper that gets this right. The contract is simple: hand it an ID and a TTL, get back { duplicate: boolean }. Concurrent calls with the same ID see exactly one duplicate: false and one or more duplicate: true.

The dedup contract

Configure a dedup adapter on the inbound source, then call postel.inbound.<source>.dedup(messageId, options?) from the verified-event branch:

import { Postel, Secret, InMemoryDedup } from "@postel/core";
import { config } from "./config.js";

const postel = Postel({
  inbound: {
    vendor: {
      verify: Secret(config.webhookSecret),
      dedup: InMemoryDedup(),
      dedupTtl: "24h",  // org-wide default; overridable per call
    },
  },
});

// In your route handler, after verify returns:
const { duplicate } = await postel.inbound.vendor.dedup(event.id);

if (duplicate) {
  return new Response("duplicate", { status: 200 });
}

// First receipt. Do the work.
await processOrder(event.data);

A few things to note:

  • TTL is your choice. Pick a window longer than the producer's retry budget — 24h to 7d is typical. The downside of a too-short TTL is accepting a duplicate after expiry; a too-long TTL costs more storage. Set dedupTtl once on the source or pass { ttl: "1h" } per call.
  • The first-receipt branch must succeed. If your work fails after dedup returns { duplicate: false }, the next retry will be marked duplicate and skipped. Either move dedup after a successful commit, or use the transactional pattern below.
  • Concurrent calls race exactly once. Two parallel postel.inbound.vendor.dedup('msg_X') calls return { duplicate: false } from exactly one of them. The other(s) return { duplicate: true }. The compliance suite tests this.
  • postel.inbound.<source>.dedup only exists when an adapter is configured. Omit dedup from the source config and the method is not on the instance type — TypeScript catches the mistake at compile time.

Storage adapters

dedup is wired through a DedupAdapter — one method, record(id, ttlSeconds, options?) → { duplicate }. Each database storage adapter ships a matching dedup helper alongside its outbound storage; configure it on the inbound source, reusing the same connection. The per-adapter details (options, table schema, SQL strategy) live on each adapter's page:

AdapterHelperStrategy
@postel/pgPgDedupINSERT … ON CONFLICT
@postel/sqliteSqliteDedupINSERT OR IGNORE
@postel/mysqlMysqlDedupINSERT … ON DUPLICATE KEY UPDATE

InMemoryDedup() (shown above) is built into @postel/core — per-instance and not shared, so it's fine for tests and single-process development but not for production with more than one process, where concurrent receivers can both accept the same message. For anything multi-process, use a database-backed adapter.

Implement DedupAdapter yourself to plug in another backend — e.g. Redis, for hosts that already run it. Postel does not require Redis as a runtime dependency — that's a deliberate design choice (ADR 0001). If you have Redis already, use it; if you don't, don't stand one up just for dedup.

Where to put the dedup call

The naive placement is "before the work":

const { duplicate } = await postel.inbound.vendor.dedup(event.id);
if (duplicate) return new Response("dup", { status: 200 });
await processOrder(event.data);

This is correct as long as processOrder is itself idempotent on its own writes. If it isn't — if you're charging a card, sending an email, calling an external API — you want the dedup row and the side effect to commit together.

The cleanest pattern is the transactional outbox in reverse: the dedup insert participates in the same DB transaction as the work. Every dedup call accepts an optional tx parameter that the adapter threads through to its storage.

await db.transaction(async (tx) => {
  const { duplicate } = await postel.inbound.vendor.dedup(event.id, { tx });
  if (duplicate) return; // tx commits with no work; safe.

  await processOrder(tx, event.data);  // joins the same tx
});

If processOrder throws, both the dedup row and the work roll back. The next retry sees the ID as unseen and tries again. If processOrder succeeds, both commit; the next retry is correctly skipped.

This is exactly the pattern the outbound sender uses for outbox inserts — the receiver's dedup row is the mirror image of the sender's outbox row.

What's next

  • Raw bytes — the silent failure mode that breaks signature verification.
  • Key rotation — rotating without dropped messages.

On this page