aw({ type: 'application/json' }),
async (req: Request, res: Response) => {
try {
const signature = req.headers['stripe-signature'] as string;
const payload = req.body as Buffer;
const event = await verifier.validateStripe(payload, signature);
await fulfillmentEngine.process(event);
res.status(200).json({ acknowledged: true });
} catch (error) {
console.error('Stripe webhook validation failed:', error);
res.status(400).json({ error: 'Invalid payload or signature' });
}
}
);
// Razorpay route: JSON parsing is safe after HMAC verification
router.post(
'/v1/gateways/razorpay/events',
express.json(),
async (req: Request, res: Response) => {
try {
const signature = req.headers['x-razorpay-signature'] as string;
const payload = req.body;
const event = await verifier.validateRazorpay(payload, signature);
await fulfillmentEngine.process(event);
res.status(200).json({ acknowledged: true });
} catch (error) {
console.error('Razorpay webhook validation failed:', error);
res.status(400).json({ error: 'Invalid payload or signature' });
}
}
);
export default router;
**Architectural Rationale:** Stripe requires `express.raw()` because its signature algorithm hashes the exact request bytes. Razorpay uses HMAC-SHA256 over a JSON string, so `express.json()` is acceptable as long as the stringified payload matches the gateway's serialization. Separating routes prevents middleware interference and keeps verification logic isolated.
### Step 2: Cryptographic Verification Implementation
Signature validation ensures the payload originated from the payment provider and hasn't been altered in transit. Both platforms use different algorithms, requiring distinct verification strategies.
```typescript
import crypto from 'crypto';
import Stripe from 'stripe';
export interface GatewayEvent {
id: string;
type: string;
payload: Record<string, unknown>;
timestamp: Date;
}
export class PaymentVerifier {
private stripeClient: Stripe;
private razorpaySecret: string;
constructor() {
this.stripeClient = new Stripe(process.env.STRIPE_API_KEY!, { apiVersion: '2024-10-01.acacia' });
this.razorpaySecret = process.env.RAZORPAY_WEBHOOK_SECRET!;
}
async validateStripe(rawBody: Buffer, signatureHeader: string): Promise<GatewayEvent> {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
const stripeEvent = this.stripeClient.webhooks.constructEvent(
rawBody,
signatureHeader,
webhookSecret
);
return {
id: stripeEvent.id,
type: stripeEvent.type,
payload: stripeEvent.data.object as Record<string, unknown>,
timestamp: new Date(stripeEvent.created * 1000)
};
}
validateRazorpay(jsonPayload: Record<string, unknown>, signatureHeader: string): GatewayEvent {
const expectedHash = crypto
.createHmac('sha256', this.razorpaySecret)
.update(JSON.stringify(jsonPayload))
.digest('hex');
if (expectedHash !== signatureHeader) {
throw new Error('Razorpay signature mismatch');
}
const event = jsonPayload as { event: string; payload: { payment?: { entity: Record<string, unknown> } } };
return {
id: `${event.event}-${Date.now()}`,
type: event.event,
payload: event.payload?.payment?.entity || jsonPayload,
timestamp: new Date()
};
}
}
Architectural Rationale: Stripe's SDK handles signature verification internally, abstracting the HMAC computation. Razorpay requires manual HMAC generation. Both return a normalized GatewayEvent interface, allowing downstream services to process events uniformly regardless of the originating gateway.
Step 3: Idempotency & Asynchronous Fulfillment
Payment gateways guarantee at-least-once delivery. Network timeouts, retry logic, and edge routing mean your endpoint will receive duplicate events. Processing them synchronously risks double-fulfillment, duplicate emails, and database constraint violations.
import { PrismaClient } from '@prisma/client';
import { Queue } from 'bullmq';
const db = new PrismaClient();
const fulfillmentQueue = new Queue('payment-fulfillment', {
connection: { host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT) }
});
export class OrderFulfillmentEngine {
async process(event: GatewayEvent): Promise<void> {
// 1. Check idempotency store
const existing = await db.webhookLog.findUnique({
where: { eventId: event.id }
});
if (existing) {
console.log(`Duplicate event skipped: ${event.id}`);
return;
}
// 2. Record event immediately to prevent race conditions
await db.webhookLog.create({
data: {
eventId: event.id,
eventType: event.type,
gatewayPayload: event.payload,
processedAt: new Date()
}
});
// 3. Delegate to async worker
await fulfillmentQueue.add('process-payment', {
eventId: event.id,
eventType: event.type,
payload: event.payload
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: true
});
}
}
Architectural Rationale: The idempotency check uses a database constraint (eventId as primary key) to guarantee single processing. Recording the event before queuing prevents race conditions where two identical requests arrive simultaneously. Offloading fulfillment to a message queue (BullMQ) ensures the HTTP response returns within 200ms, satisfying gateway timeout requirements while allowing heavy operations (license generation, email dispatch, inventory updates) to complete asynchronously.
Pitfall Guide
1. JSON Middleware Interference on Stripe Routes
Explanation: Applying express.json() or body-parser before Stripe signature verification alters byte-level payload structure, causing HMAC validation to fail consistently.
Fix: Apply express.raw({ type: 'application/json' }) exclusively to the Stripe webhook route. Keep JSON parsing disabled for that specific path.
2. Synchronous Fulfillment Blocking
Explanation: Running database writes, email dispatch, and third-party API calls inside the webhook handler exceeds gateway timeout thresholds (typically 5-10 seconds), triggering retries and duplicate processing.
Fix: Return HTTP 200 immediately after signature validation and idempotency logging. Delegate business logic to a background job queue with retry policies.
3. Ignoring Failure & Refund Events
Explanation: Only listening to success events creates state drift. Failed renewals, chargebacks, and refunds leave licenses active and billing records inconsistent.
Fix: Register handlers for invoice.payment_failed, charge.refunded, subscription.cancelled, and gateway-specific failure events. Implement revocation logic that mirrors fulfillment logic.
4. Hardcoded or Exposed Secrets
Explanation: Embedding webhook secrets in source code or committing them to version control enables attackers to forge valid signatures and bypass verification.
Fix: Store secrets exclusively in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault). Rotate secrets quarterly and validate rotation with a test webhook.
5. Missing Database Constraints for Idempotency
Explanation: Relying solely on application-level checks allows race conditions where concurrent duplicate requests bypass the idempotency guard.
Fix: Enforce eventId as a unique constraint at the database level. Use INSERT ... ON CONFLICT DO NOTHING or equivalent upsert patterns to guarantee atomic deduplication.
6. Client-Side State Mutation
Explanation: Triggering license activation, feature unlocks, or billing updates from frontend callbacks creates a trust boundary violation. Attackers can manipulate URL parameters or intercept XHR responses.
Fix: Treat frontend success pages as read-only dashboards. Poll your own backend API for order status using the session ID. Never execute business logic based on client-provided state.
7. Inadequate Monitoring & Alerting
Explanation: Silent webhook failures, signature mismatches, and queue backlogs go unnoticed until customers report missing access or duplicate charges.
Fix: Implement structured logging for all webhook events. Set up alerts for signature validation failures, queue depth thresholds, and fulfillment latency spikes. Maintain a dashboard tracking gateway delivery rates vs. internal processing rates.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| SaaS Subscription | Webhook-First + Async Queue | Handles renewals, failures, and cancellations reliably; prevents license drift | Moderate infrastructure cost (Redis/queue), low support overhead |
| One-Time Digital Product | Webhook-First + Direct DB Write | Simpler architecture sufficient for low-volume, single-event transactions | Minimal infrastructure cost, fast implementation |
| High-Volume Marketplace | Webhook-First + Event Sourcing + CQRS | Decouples payment confirmation from inventory, payouts, and dispute handling | Higher initial complexity, scales linearly with transaction volume |
| Internal/B2B Portal | Webhook-First + Synchronous Processing | Lower traffic allows direct DB writes without queue overhead | Lowest infrastructure cost, acceptable latency for internal users |
Configuration Template
# .env.production
STRIPE_API_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
RAZORPAY_KEY_ID=rzp_live_...
RAZORPAY_WEBHOOK_SECRET=...
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
DATABASE_URL=postgresql://user:pass@localhost:5432/payments
NODE_ENV=production
// src/config/webhook.routes.ts
import express from 'express';
import { PaymentVerifier } from '../services/payment-verifier';
import { OrderFulfillmentEngine } from '../services/order-fulfillment';
const router = express.Router();
const verifier = new PaymentVerifier();
const fulfillmentEngine = new OrderFulfillmentEngine();
router.post('/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const event = await verifier.validateStripe(req.body, req.headers['stripe-signature'] as string);
await fulfillmentEngine.process(event);
res.status(200).json({ status: 'acknowledged' });
} catch (err) {
res.status(400).json({ error: 'Validation failed' });
}
});
router.post('/razorpay', express.json(), async (req, res) => {
try {
const event = verifier.validateRazorpay(req.body, req.headers['x-razorpay-signature'] as string);
await fulfillmentEngine.process(event);
res.status(200).json({ status: 'acknowledged' });
} catch (err) {
res.status(400).json({ error: 'Validation failed' });
}
});
export default router;
Quick Start Guide
- Initialize the project: Create a new TypeScript Express application and install dependencies:
npm install express stripe bullmq @prisma/client dotenv crypto.
- Configure environment variables: Copy the
.env template and populate gateway keys, webhook secrets, and database connection strings. Never commit these values.
- Deploy the webhook endpoint: Register the
/stripe and /razorpay routes in your Express app. Ensure raw body parsing is applied only to the Stripe path.
- Test with gateway simulators: Use Stripe CLI (
stripe listen) and Razorpay's test mode to send sample events. Verify signature validation, idempotency logging, and queue dispatch. Monitor logs to confirm successful processing before routing production traffic.