javascript 128 lines · 3 tabs

Idempotent Stripe Webhook Processing With an Express Event-Store Middleware

Shared by codesnips Aug 2026
3 tabs
const { pool } = require('./db');

async function recordEvent(client, { eventId, type, payload }) {
  const { rows } = await client.query(
    `INSERT INTO webhook_events (event_id, type, payload, status)
     VALUES ($1, $2, $3, 'received')
     ON CONFLICT (event_id) DO NOTHING
     RETURNING id, event_id, status`,
    [eventId, type, payload]
  );
  return rows[0] || null; // null => duplicate delivery
}

async function markProcessed(client, id) {
  await client.query(
    `UPDATE webhook_events
     SET status = 'processed', processed_at = now()
     WHERE id = $1`,
    [id]
  );
}

async function markFailed(client, id, err) {
  await client.query(
    `UPDATE webhook_events
     SET status = 'failed', last_error = $2, attempts = attempts + 1
     WHERE id = $1`,
    [id, String(err && err.message || err).slice(0, 500)]
  );
}

async function withTransaction(fn) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const result = await fn(client);
    await client.query('COMMIT');
    return result;
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

module.exports = { recordEvent, markProcessed, markFailed, withTransaction };
3 files · javascript Explain with highlit

Webhook providers guarantee at-least-once delivery, which means the same event can arrive multiple times — on retries, network hiccups, or provider-side redeliveries. Processing a delivery twice can double-charge a customer or send duplicate emails, so the receiver must be idempotent. This snippet shows the common pattern of persisting each delivery to an event store before handling it, keyed by the provider's unique event id, then letting a handler consume events exactly once.

In webhookStore.js, recordEvent performs an INSERT ... ON CONFLICT (event_id) DO NOTHING against a webhook_events table. Postgres treats the event_id as the natural dedupe key: the first delivery inserts a row in the received state and returns it, while any duplicate insert affects zero rows and returns null. That single atomic statement is the whole idempotency guarantee — no read-then-write race window. markProcessed and markFailed transition the row's status so retries and observability are possible.

verifyAndStore.js is the Express middleware that runs first. It needs the raw request body to verify the signature, which is why the route is mounted with express.raw. It calls stripe.webhooks.constructEvent to authenticate the payload — an invalid signature returns 400 immediately, before anything touches the database. Once verified, it records the event and stashes both the parsed event and the freshly-stored row on req. Crucially, if recordEvent returns null the delivery is a duplicate, so the middleware short-circuits with 200 OK; acknowledging duplicates keeps the provider from retrying and never invokes the handler again.

webhookRoutes.js wires it together. The handler only runs for genuinely new events. It dispatches on event.type, does the real work inside a transaction, and calls markProcessed on success. On failure it calls markFailed and returns 500, which signals the provider to retry later — and because the row already exists in a non-processed state, that retry flows back through the same dedupe logic safely.

The trade-off is that business side effects must themselves be transactional or idempotent; the store only prevents reprocessing, not partial work. A cleanup job can later sweep stale received rows for events that crashed mid-flight.


Related snips

ruby
timestamp = request.headers.fetch('X-Signature-Timestamp')
signature = request.headers.fetch('X-Signature')
payload = request.raw_post

data = "#{timestamp}.#{payload}"
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('WEBHOOK_SECRET'), data)

HMAC signed API requests for webhook and partner integrity

hmac api-signing webhooks
by Kai Nakamura 2 tabs
typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
go
package dbutil

import (
  "context"

  "github.com/jackc/pgconn"

Retry Postgres serialization failures with bounded attempts

go postgres transactions
by Leah Thompson 1 tab
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
ruby
class SignupForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :account_name, :string
  attribute :email, :string

Shallow Controller, Deep Params: Form Object Pattern

rails activemodel form-object
by codesnips 3 tabs
typescript
export type Settled<R> =
  | { status: 'fulfilled'; value: R }
  | { status: 'rejected'; reason: unknown };

export interface ConcurrencyOptions {
  limit: number;

Simple concurrency limiter for batch operations

node concurrency async
by codesnips 2 tabs

Share this code

Here's the card — post it anywhere.

Idempotent Stripe Webhook Processing With an Express Event-Store Middleware — share card
Link copied