Webhook idempotency without Redis

By Abinashi Singh · ~10 min read · Practical Node + Postgres

Every SaaS eventually grows a webhook endpoint. Stripe, GitHub, Clerk, your billing vendor — they all retry. Retries are good: they protect you from brief outages. They are also how you charge a customer twice, enqueue the same email four times, or race two workers into conflicting side effects. Idempotency is not optional; it is the contract between “we received this” and “we processed this exactly once from the business’s point of view.”

You do not need Redis, Kafka, or a dedicated queue product to get there for most early and mid-stage products. PostgreSQL already gives you unique constraints, row locks, and FOR UPDATE SKIP LOCKED. Combined with HMAC signature checks and a small worker loop, that is enough for durable, inspectable ingest.

What “idempotent” means at the HTTP edge

Providers almost always send an event identifier. Treat that id as authoritative. Your ingest path should: (1) verify the request is from the provider, (2) parse a stable external id, (3) insert a row keyed by (provider, externalId), and (4) return success whether this is the first delivery or a retry. First delivery enqueues work; retries acknowledge without re-enqueueing.

A useful HTTP shape: 202 with duplicate: false on first accept, 200 with duplicate: true on a known id. Both tell the provider to stop retrying for that delivery. Fail signature checks with 401/403 before you touch the database. Fail schema validation with 400 only when the payload is unambiguously wrong — otherwise you invite endless retries of garbage you cannot store.

HMAC verification, done carefully

Most providers sign the raw body with HMAC-SHA256 (or similar) and put the digest in a header. Three details matter more than the algorithm name:

A common header form is sha256=<hex>. Strip the prefix, decode, compare. Reject early if the header is missing or malformed — that is an auth failure, not a business event.

The unique constraint is the idempotency key

Schema sketch:

WebhookEvent
  id            uuid PK
  provider      text
  externalId    text
  payload       jsonb
  status        pending | processing | done | failed
  createdAt     timestamptz
  UNIQUE (provider, externalId)

On insert, catch the unique violation. That race — two identical deliveries arriving in the same millisecond — is exactly what you want the database to serialize. Application-level “check then insert” without a unique constraint will lose that race under load.

Store the full payload you need for later processing. Do not rely on re-fetching from the provider unless their API is designed for it; many webhook payloads are the only copy you get.

Process out of band with SKIP LOCKED

Returning quickly from the HTTP handler is more important than finishing business logic inline. Providers time out. Your deploy windows happen. Put work in a worker:

  1. Select a batch of pending rows FOR UPDATE SKIP LOCKED.
  2. Mark them processing (or rely on the lock for the duration of a short transaction).
  3. Run the provider-specific handler.
  4. Mark done or failed with an error message you can grep later.

SKIP LOCKED means a second worker does not wait on the first worker’s rows — it takes the next free pending events. That scales horizontally without a separate broker for modest throughput. At very high volume you may still introduce a queue; start with Postgres and measure.

Handlers should themselves be careful: prefer upserts and status machines over “always insert a new invoice.” Idempotency at ingest reduces duplicates; idempotency in handlers finishes the job.

Operational habits that save weekends

When Redis (or a queue) still helps

Redis shines as a hot cache, rate limiter, or ephemeral lock. A dedicated queue helps when you need delayed jobs, complex routing, or multi-million events per day. None of that replaces the durable event row with a unique key. Even with Sidekiq or SQS in front, many teams still write an events table so support can answer “did we get event X?” without digging through logs.

If your only goal is “don’t process Stripe evt_… twice,” Postgres is enough.

Kit: If you want this pattern packaged — Express + TypeScript + Prisma, timing-safe HMAC, unique ingest, and a SKIP LOCKED worker — HookQueue is a MIT-licensed starter for $19 on Gumroad. Same ideas as this article, wired to run with Docker Compose.

Checklist before you ship

Webhooks look small until the first double charge. Build the boring path once, put a unique constraint on it, and let Postgres do the serialization work it was designed for.

← Back to Singh DevTools · Next: Multi-tenant Postgres →