const express = require('express');
const webhookRouter = require('./webhookRouter');
const apiRouter = require('./apiRouter');
const app = express();
// Mount BEFORE express.json() so the raw body survives for signature checks.
app.use('/stripe', webhookRouter);
app.use(express.json());
app.use('/api', apiRouter);
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'internal_error' });
});
module.exports = app;
const express = require('express');
const Stripe = require('stripe');
const { recordAndDispatch } = require('./eventStore');
const { enqueueFulfillment } = require('./queue');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
const router = express.Router();
router.post(
'/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret);
} catch (err) {
console.warn('Stripe signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
const isNew = await recordAndDispatch(event);
if (!isNew) {
return res.json({ received: true, duplicate: true });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
await enqueueFulfillment(session.id, session.customer);
break;
}
case 'payment_intent.payment_failed': {
const intent = event.data.object;
console.warn('Payment failed for intent', intent.id);
break;
}
default:
break;
}
return res.json({ received: true });
}
);
module.exports = router;
const { pool } = require('./db');
async function recordAndDispatch(event) {
const result = await pool.query(
`INSERT INTO stripe_events (id, type, payload, received_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (id) DO NOTHING`,
[event.id, event.type, event]
);
// rowCount === 0 means this event.id was already stored: a Stripe retry.
return result.rowCount === 1;
}
module.exports = { recordAndDispatch };
A Stripe webhook endpoint must verify each request's signature against the exact bytes Stripe sent, which is why the raw request body — not a parsed JSON object — is required. Express by default runs express.json() globally and discards the raw buffer, so any downstream stripe.webhooks.constructEvent call fails with a signature mismatch. This snippet solves that by isolating the webhook route so it receives the untouched body while the rest of the app still enjoys JSON parsing.
In app.js, the ordering is deliberate: the Stripe router is mounted before the global express.json() middleware. That guarantees the webhook path is handled by a body parser that preserves raw bytes, and every other route mounted after it still parses JSON normally. Getting this order wrong is the single most common cause of constructEvent failures in production.
In webhookRouter.js, express.raw({ type: 'application/json' }) captures the body as a Buffer and hands it directly to stripe.webhooks.constructEvent, along with the stripe-signature header and the endpoint secret. If verification throws, the route returns 400 immediately — a rejected event should never reach business logic. Because Stripe retries on non-2xx responses, the handler acknowledges with res.json({ received: true }) as soon as the work is durably recorded, rather than after slow processing.
Idempotency matters because Stripe can deliver the same event more than once, and network timeouts often trigger retries even when the first delivery succeeded. The recordAndDispatch helper in eventStore.js uses the immutable event.id as a unique key, and INSERT ... ON CONFLICT DO NOTHING lets the database enforce exactly-once semantics without application-level locking. When rowCount is 0, the event was already seen and processing is skipped.
The handler switches on event.type, extracting the typed data.object for known events and ignoring the rest quietly. Heavy work is deliberately deferred to a queue via enqueueFulfillment so the HTTP response stays fast and within Stripe's timeout window. This pattern — verify, dedupe, acknowledge, then process asynchronously — is the standard way to build a webhook receiver that is both secure and resilient to retries.
Related snips
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
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
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
module EmailNormalization
extend ActiveSupport::Concern
included do
attr_accessor :soft_warnings
Soft Validation: Normalize + Validate Email
event_id = request.headers.fetch('X-Event-Id')
timestamp = request.headers.fetch('X-Signature-Timestamp').to_i
raise ActionController::BadRequest, 'stale request' if Time.now.to_i - timestamp > 300
raise ActionController::BadRequest, 'replay detected' if WebhookEvent.exists?(external_id: event_id)
Secure webhook endpoint design with replay protection
Share this code
Here's the card — post it anywhere.