Build an Idempotent Payment Webhook Processor
A complete payment webhook processor: signature verification, raw-body capture, idempotency keys, ordered state updates, and replay tests.
Project goal
Verify payment provider webhooks, apply each event exactly once even if it is replayed, and keep the local order state consistent with the provider.
Final features
- Raw body capture
- Signature verification
- Idempotency key
- Transactional state update
- Ordering guard
Architecture
The endpoint captures the raw body before parsing, verifies the signature over those exact bytes, and records the provider event id as an idempotency key. A transactional handler updates the order only if the event id is new, so retries are harmless.
Prerequisites
- Node.js 20 LTS or later
- PostgreSQL 15+
- A provider webhook signing secret
Dependencies
- express ^4.21.0 — Webhook endpoint with raw body
- pg ^8.12.0 — Idempotency and order state
Directory structure
payment-webhooks/
├── src/verify.ts
├── src/handle.ts
├── src/idempotency.ts
├── src/order-state.ts
├── tests/webhook.test.ts
└── package.json
Phases
1. Raw body capture
Keep the exact bytes used for signature verification.
app.post('/webhook', express.raw({ type: 'application/json' }), handler);
Expected behavior: Re-serializing JSON changes the bytes and breaks the signature.
2. Signature verification
Compute an HMAC over the timestamp and raw body and compare in constant time.
const expected = hmac(secret, ts + '.' + body);
if (!timingSafeEqual(expected, provided)) return res.status(400).end();
Expected behavior: A valid signature proves the payload came from the provider.
3. Idempotency key
Insert the event id with a unique constraint before side effects.
const fresh = await db.insertIgnore('processed_events', { id: event.id });
if (!fresh) return res.status(200).end();
Expected behavior: Replays are ignored instead of double-charging.
4. Transactional state update
Apply the order change and the idempotency record in one transaction.
await db.tx(async (t) => { await t.markProcessed(event.id); await t.updateOrder(orderId, nextState); });
Expected behavior: A crash between the two would otherwise leave an applied event unrecorded.
5. Ordering guard
Ignore events older than the current order version.
if (event.version <= order.version) return;
Expected behavior: Out-of-order deliveries must not regress the state.
Testing
Send the same signed event twice and assert one state change. Tamper with the body and assert 400. Send an older event and assert the order state is unchanged.
Troubleshooting
Signature fails after a deploy
The body parser normalized the JSON before verification; capture raw bytes first.
Customer charged twice
The idempotency insert and the state update were not in the same transaction.
Deployment
Respond 200 quickly and process asynchronously if the handler is slow, but record the event durably first. Retain raw payloads briefly for debugging, then purge under the privacy policy.
Known limitations
- One provider; adding another needs a separate verifier.
- No reconciliation job against the provider API.
- Refunds are handled as another event type, not specially.
Project overview
A payment webhook is an untrusted request that claims money moved, and it will be delivered more than once. This build verifies the signature over the exact bytes and applies each event exactly once, so a replay cannot double-charge or double-credit.
You will capture the raw body before parsing, verify the provider signature over those bytes, record the provider event id as an idempotency key, and update state in a transaction that only proceeds for a new event.
Capturing the raw body before parsing is the detail most implementations miss. Any framework that parses and re-serializes the body changes the bytes, and a signature check over re-serialized bytes fails or, worse, is skipped.
Idempotency at the event level is what makes retries safe: the same event arriving twice updates state once, and the provider’s at-least-once delivery becomes a non-issue rather than a source of duplicate orders.
Each phase adds one guarantee: raw capture, signature verification, an idempotency key, a transactional update, and an ordering guard, so the processor is safe at every stage.
It is for teams that have received a duplicate payment notification in production and want a design where duplicates are simply ignored. The processor makes that a property of the transaction rather than a best-effort check, so a race between two deliveries cannot produce two effects.
What you will build
The processor provides five capabilities, each with a test that replays a signed event:
- Raw body capture
- Signature verification
- Idempotency key
- Transactional state update
- Ordering guard
Raw body capture preserves the signed bytes; signature verification authenticates the sender; an idempotency key makes retries harmless; a transactional update keeps state consistent; and an ordering guard ignores an event older than what was already applied.
The design assumes the sender is hostile and unreliable at the same time, so it verifies authenticity and enforces idempotency rather than trusting either the sender or the network.
Architecture and data model
The endpoint captures the raw body before parsing, verifies the signature over those exact bytes, and records the provider event id as an idempotency key. A transactional handler updates the order only if the event id is new, so retries are harmless.
The endpoint reads the raw body as bytes, verifies the provider signature over exactly those bytes, and only then parses the payload. Verification before parsing means a tampered body is rejected before any of its fields are trusted.
The provider event id is recorded as an idempotency key. A handler that sees an existing key returns success without applying the change, which turns a redelivery into a no-op instead of a second mutation.
State changes happen in one transaction that also inserts the idempotency key, so an event can never be marked processed without its effect, nor have its effect without the mark.
An ordering guard compares the event’s sequence to the last applied one and ignores an older event, because providers can deliver out of order and a late event must not undo a newer state.
The idempotency key is unique in the store, so two concurrent deliveries of the same event contend on one row and exactly one insert succeeds; that uniqueness is the concurrency control, and it is why the key must be inserted in the same transaction as the effect.
The ordering guard reads the last applied sequence from the order row it is already updating, so the comparison costs nothing extra and cannot disagree with the state it protects; a separate tracking table would introduce exactly the inconsistency the transaction avoids.
Prerequisites and dependencies
You need the provider’s signing secret, a store for idempotency keys and order state, and a way to reach the endpoint over HTTPS.
- Node.js 20 LTS or later
- PostgreSQL 15+
- A provider webhook signing secret
The dependencies cover the HTTP surface, the signature library, and the transactional store:
express(^4.21.0) — Webhook endpoint with raw bodypg(^8.12.0) — Idempotency and order state
pnpm init
pnpm add express pg
pnpm add -D typescript tsx vitest
Keep the signing secret in a secret manager and support two valid secrets during a rotation window, because a hard cutover rejects valid webhooks until the provider switches. Record the accepted event types in configuration so an unexpected type is rejected rather than guessed at.
Directory structure
Separate raw body handling, signature verification, the idempotency store, the state update, and the ordering guard so each is testable with a signed fixture:
payment-webhooks/
├── src/verify.ts
├── src/handle.ts
├── src/idempotency.ts
├── src/order-state.ts
├── tests/webhook.test.ts
└── package.json
Phase 1: Raw body capture
Keep the exact bytes used for signature verification.
app.post('/webhook', express.raw({ type: 'application/json' }), handler);
Re-serializing JSON changes the bytes and breaks the signature.
Capture the raw body before any parser touches it, and verify the signature over those exact bytes. A signature over re-serialized bytes is either wrong or, worse, unchecked, and both outcomes defeat the purpose.
Phase 2: Signature verification
Compute an HMAC over the timestamp and raw body and compare in constant time.
const expected = hmac(secret, ts + '.' + body);
if (!timingSafeEqual(expected, provided)) return res.status(400).end();
A valid signature proves the payload came from the provider.
Verify the signature with the provider’s secret and compare in constant time, rejecting a request whose signature does not match before reading any field.
Phase 3: Idempotency key
Insert the event id with a unique constraint before side effects.
const fresh = await db.insertIgnore('processed_events', { id: event.id });
if (!fresh) return res.status(200).end();
Replays are ignored instead of double-charging.
Record the provider event id as an idempotency key and treat a repeat as a no-op. This is the property that makes the provider’s at-least-once delivery safe.
Phase 4: Transactional state update
Apply the order change and the idempotency record in one transaction.
await db.tx(async (t) => { await t.markProcessed(event.id); await t.updateOrder(orderId, nextState); });
A crash between the two would otherwise leave an applied event unrecorded.
Apply the state change and write the idempotency key in one transaction, so a crash between the two cannot leave an event marked with no effect or an effect with no mark.
Phase 5: Ordering guard
Ignore events older than the current order version.
if (event.version <= order.version) return;
Out-of-order deliveries must not regress the state.
Compare the event sequence with the last applied and ignore an older event, because out-of-order delivery must not move the state backward.
Testing
Send the same signed event twice and assert one state change. Tamper with the body and assert 400. Send an older event and assert the order state is unchanged.
Send the same signed event twice and assert one state change, which is the idempotency guarantee as a test and the single most important case to cover.
Tamper with the body and assert 400, because a signature check that only runs sometimes is the same as no check.
Send an older event after a newer one and assert the state is unchanged, which pins the ordering guard as behavior rather than as a comment.
Assert that the idempotency key and the state change are written together, since a partial write is the failure that produces a duplicate on the next retry.
Test the concurrent case as well as the sequential one, because two identical events arriving at the same moment are the realistic duplicate, and a sequential replay test would not exercise the unique constraint that prevents both from applying.
Assert that a request with a valid signature but an unknown event type is acknowledged and ignored, because rejecting it would cause the provider to retry an event the receiver cannot use.
pnpm vitest run
Deployment and operations
Respond 200 quickly and process asynchronously if the handler is slow, but record the event durably first. Retain raw payloads briefly for debugging, then purge under the privacy policy.
Respond two hundred quickly and process asynchronously if the handler is slow, but record the event durably first so a crash after the response does not lose it.
Retain raw payloads briefly for debugging and purge them under the privacy policy, because a webhook archive is otherwise a store of payment details.
Monitor duplicate events and signature failures separately, since a rise in duplicates is a provider retry pattern while a rise in signature failures may be an attack.
Support two signing secrets during rotation so a provider switch does not cause a burst of rejected events.
Acknowledge quickly and record durably before processing, because a provider that receives a timeout retries, and a handler that acknowledges without recording produces the duplicate it was meant to prevent.
Security considerations
Verify the signature over raw bytes, enforce a timestamp tolerance to block replay, and never trust amounts or ids from the body without cross-checking the provider.
Verify over the raw bytes and never trust a field from a parsed body before the signature matches, because the payload is entirely attacker-controlled until then.
Compare signatures in constant time and reject a malformed signature rather than returning a detailed error, so probing yields little.
Limit the payload size and rate-limit the endpoint, because an unauthenticated path that verifies a signature is still a compute cost a caller can drive.
Never log the full payload, since payment webhooks carry amounts and identifiers that belong under the same retention policy as the payment data itself.
Treat the parsed payload as untrusted until the signature over the raw bytes matches, and re-verify before any retry side effect, because a queue that stores a parsed event loses the bytes the signature depended on.
Cap the payload size and reject an oversized body before verification, since signature verification is itself work an unauthenticated caller can force.
Keep a separate record of rejected events with their reason and without their payload, so an investigation can proceed without retaining the payment details themselves.
Observability
Track verified, rejected, duplicate, and out-of-order event counts. Alert on a spike in rejections, which indicates a rotated secret or an attack.
Track event types applied, duplicates ignored, and rejections by reason, because together they describe whether the pipeline is healthy or under attack.
Chart processing latency from receipt to state change, since a growing latency usually means the transaction is contending on the same order.
Alert on signature failures, because a sustained rate is either a misconfigured provider or an attempt to forge events.
Measure the age of the oldest unprocessed event, because a growing backlog means events are acknowledged but not applied.
Measure both duplicates ignored and events applied, because a rising duplicate rate with a stable applied rate means the provider is retrying more while the receiver does less; the two numbers together describe the integration’s health.
Track the signature verification duration, because a rise can indicate a payload larger than expected and worth rejecting before it becomes a throughput problem.
Troubleshooting
Signature fails after a deploy. The body parser normalized the JSON before verification; capture raw bytes first.
Customer charged twice. The idempotency insert and the state update were not in the same transaction.
If every signature check fails, compare the signed bytes with the parsed and re-serialized bytes, because a framework that normalizes the body before the check is the usual cause.
If an order changes twice, check the idempotency key and the ordering guard, since either a missing key or a missing sequence comparison produces exactly that symptom.
If an order double-applies despite the idempotency key, check that the key insert and the state change share one transaction, because two separate transactions can both pass a prior existence check.
Limitations and next steps
- One provider; adding another needs a separate verifier.
- No reconciliation job against the provider API.
- Refunds are handled as another event type, not specially.
The processor covers a single provider’s webhook format, so supporting another provider needs a second verifier and event mapper.
There is no reconciliation job against the provider’s API, so a missed webhook relies on the provider’s retry rather than on a pull-based check.
Event ordering is guarded with a sequence value but not resolved for genuinely concurrent events, which the provider rarely emits but does not guarantee against.
The example does not model refunds or partial captures beyond the events the chosen provider sends, which a fuller payment model would need.
There is no reconciliation against the provider’s API, so a webhook that never arrives depends on the provider’s own retry rather than on a pull-based safety net, which a high-volume integration would add.