Webhook idempotency without Redis
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:
- Verify against the raw bytes. If your framework parses JSON first and you re-stringify, whitespace and key order will break signatures. Capture the body buffer before parsing.
-
Use timing-safe comparison. In Node,
crypto.timingSafeEqualon equal-length buffers. Do not compare hex strings with===in security-sensitive paths. - Keep one secret per environment. Rotate by supporting two secrets briefly if the provider allows, then drop the old one. Never log the secret or the full signature header in production traces.
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:
- Select a batch of
pendingrowsFOR UPDATE SKIP LOCKED. - Mark them
processing(or rely on the lock for the duration of a short transaction). - Run the provider-specific handler.
- Mark
doneorfailedwith 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
- Expose
GET /healththat checks DB connectivity for both API and worker processes. - Log
provider,externalId, and status transitions — not full PII payloads. - Alert on a rising
failedcount and on workers that stop claiming. - Keep a manual “replay” path for
failedrows after you fix a bug. - Document which header and which body field is the external id for each provider you add.
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.
Checklist before you ship
- Raw-body HMAC with timing-safe compare
- Unique
(provider, externalId)and duplicate-aware responses - Async worker; HTTP path stays thin
- Status column and failure visibility
- Secrets in env, never in the repo
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.