yload and causes verification to fail.
The controller must extract raw bytes before any parsing or logging occurs.
import { Controller, Post, Req, Headers, UnauthorizedException } from '@nestjs/common';
import { Request } from 'express';
import { HmacValidator } from '../security/hmac.validator';
import { EventDispatcher } from '../events/event.dispatcher';
@Controller('financial-gateways')
export class PaymentWebhookController {
constructor(
private readonly hmac: HmacValidator,
private readonly dispatcher: EventDispatcher,
) {}
@Post('stripe')
async ingest(
@Req() request: Request,
@Headers('stripe-signature') signature: string,
) {
const rawPayload = request.rawBody as Buffer;
if (!this.hmac.validate(rawPayload, signature, process.env.STRIPE_WEBHOOK_SECRET)) {
throw new UnauthorizedException('Invalid gateway signature');
}
const payload = JSON.parse(rawPayload.toString());
await this.dispatcher.route(payload);
return { ack: true };
}
}
Rationale: Verification happens before parsing. If the signature fails, the request terminates immediately. No database queries, no queue operations, no logging. This minimizes attack surface and prevents resource exhaustion from malicious payloads.
The HTTP handler performs exactly two operations: verify the signature, enqueue the payload. Business logic lives entirely outside the request lifecycle.
import { Injectable } from '@nestjs/common';
import { Queue } from 'bullmq';
@Injectable()
export class EventDispatcher {
private readonly ingestionQueue: Queue;
constructor() {
this.ingestionQueue = new Queue('financial-events', {
connection: { host: process.env.REDIS_HOST, port: 6379 },
});
}
async route(payload: Record<string, unknown>): Promise<void> {
await this.ingestionQueue.add('process', payload, {
jobId: `${payload.type}-${payload.id}`,
removeOnComplete: true,
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
});
}
}
Rationale: Decoupling acknowledgment from execution guarantees sub-60ms response times. The queue absorbs traffic spikes, handles backpressure, and provides dead-letter routing for malformed payloads. The HTTP connection closes immediately, satisfying gateway SLAs.
3. Database-Enforced Idempotency
Distributed caches (Redis, Memcached) are unsuitable for idempotency enforcement. TTL expiration, network partitions, and concurrent write races create windows where duplicate events slip through. Relational databases provide ACID guarantees that eliminate deduplication logic entirely.
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, QueryFailedError } from 'typeorm';
import { WebhookEvent } from './webhook-event.entity';
@Injectable()
export class IdempotencyGuard {
private readonly logger = new Logger(IdempotencyGuard.name);
constructor(
@InjectRepository(WebhookEvent)
private readonly eventRepo: Repository<WebhookEvent>,
) {}
async isNovel(eventId: string): Promise<boolean> {
try {
await this.eventRepo.insert({ eventId, processedAt: new Date() });
return true;
} catch (error) {
if (error instanceof QueryFailedError && error.driverError?.code === '23505') {
this.logger.debug(`Duplicate event skipped: ${eventId}`);
return false;
}
throw error;
}
}
}
Rationale: The event_id column serves as the primary key. The database handles concurrency natively. If two workers process the same event simultaneously, only one INSERT succeeds. The other receives a constraint violation and safely aborts. No distributed locks, no cache invalidation, no race conditions.
4. Deterministic State Transition Validation
Payment statuses are not mutable strings. They represent nodes in a directed graph. Certain transitions are legal; most are not. Treating status as a flat field ignores temporal ordering and gateway retry behavior.
export type PaymentState = 'initiated' | 'authorising' | 'succeeded' | 'failed' | 'refunded';
const TRANSITION_GRAPH: Record<PaymentState, PaymentState[]> = {
initiated: ['authorising', 'failed'],
authorising: ['succeeded', 'failed'],
succeeded: [],
failed: [],
refunded: [],
};
export class PaymentStateMachine {
static isTransitionValid(current: PaymentState, next: PaymentState): boolean {
return TRANSITION_GRAPH[current]?.includes(next) ?? false;
}
}
The worker applies this validation before touching the ledger:
import { Injectable } from '@nestjs/common';
import { IdempotencyGuard } from './idempotency.guard';
import { PaymentStateMachine } from './payment-state-machine';
import { LedgerService } from '../ledger/ledger.service';
@Injectable()
export class PaymentProcessor {
constructor(
private readonly idempotency: IdempotencyGuard,
private readonly ledger: LedgerService,
) {}
async execute(payload: Record<string, unknown>): Promise<void> {
const eventId = payload.id as string;
const paymentId = payload.data.object.payment_intent as string;
const newState = payload.data.object.status as PaymentState;
if (!(await this.idempotency.isNovel(eventId))) return;
const currentState = await this.ledger.getCurrentState(paymentId);
if (!PaymentStateMachine.isTransitionValid(currentState, newState)) {
return; // Silently drop illegal transitions
}
await this.ledger.applyTransition(paymentId, newState, eventId);
}
}
Rationale: When failed arrives after succeeded, the state machine rejects the transition. The system preserves the terminal state. No exceptions are thrown, no rollbacks occur. The daily reconciler later cross-references gateway records with local state and resolves discrepancies automatically.
Pitfall Guide
1. Premature Body Parsing
Explanation: Framework middleware parses JSON before your handler executes. This normalizes whitespace, reorders keys, and escapes Unicode characters. The resulting byte sequence no longer matches the gateway's HMAC signature, causing valid webhooks to fail verification.
Fix: Extract request.rawBody or configure the framework to bypass JSON parsing for webhook routes. Verify the signature against the exact byte array before calling JSON.parse().
2. Synchronous Business Logic in HTTP Handlers
Explanation: Chaining database writes, external API calls, or email dispatches inside the controller blocks the event loop. Execution time exceeds the gateway's 5–10 second timeout, triggering automatic retries. Retries cascade into duplicate processing and database deadlocks.
Fix: The controller must only verify and enqueue. Return 200 OK immediately. Offload all side effects to a background worker pool.
3. Cache-Backed Deduplication
Explanation: Using Redis or in-memory stores for idempotency introduces TTL expiration windows, network partition vulnerabilities, and concurrent write races. During high throughput, duplicate events bypass cache checks and corrupt ledger balances.
Fix: Rely on relational database UNIQUE constraints. ACID transactions guarantee exactly-once semantics without distributed coordination overhead.
4. Mutable Status Enums
Explanation: Treating payment status as a simple UPDATE status = ? ignores temporal ordering. Out-of-order delivery overwrites terminal states (succeeded → failed), causing financial discrepancies and customer disputes.
Fix: Implement an explicit transition matrix. Validate current_state → next_state before applying updates. Silently drop illegal transitions and log them for reconciliation.
5. Unrestricted Payload Logging
Explanation: Payment webhooks contain PII, account identifiers, and sensitive transaction metadata. Logging full payloads at INFO level violates PSD2/GDPR compliance, inflates log storage costs, and creates audit liabilities.
Fix: Implement structured logging with field redaction. Log only event_id, payment_id, status, and timestamp. Store raw payloads in encrypted, access-controlled storage if audit trails are required.
6. Blind Retry Mechanisms
Explanation: Manual replay tools or aggressive retry policies that re-execute handlers without idempotency safeguards trigger duplicate side effects: double fulfillment, duplicate ledger entries, and repeated customer notifications.
Fix: Enforce idempotency keys at the persistence layer. Route malformed or repeatedly failing jobs to a dead-letter queue. Require manual review before replaying DLQ items.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-volume payment gateway (>10k events/min) | BullMQ/Kafka + Postgres idempotency | Queue absorbs spikes; DB constraints guarantee exactly-once processing | Moderate infrastructure cost; eliminates reconciliation labor |
| Low-volume internal billing (<500 events/day) | Simple queue + in-memory dedup cache | Overhead of DB constraints unnecessary at low throughput | Lower infra cost; acceptable risk of rare duplicates |
| Strict compliance environment (PSD2/GDPR) | Encrypted raw payload storage + field-redacted logging | Audit trails required; PII exposure prohibited | Higher storage cost; reduces compliance risk and audit penalties |
| Multi-gateway aggregation (Stripe, Adyen, GoCardless) | Provider-agnostic event schema + unified state machine | Normalizes gateway quirks; simplifies reconciliation | Higher initial dev cost; reduces long-term maintenance overhead |
Configuration Template
// nestjs-bootstrap.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as bodyParser from 'body-parser';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bodyParser: false });
// Enable raw body extraction for webhook routes only
app.use('/financial-gateways/*', bodyParser.raw({ type: 'application/json', limit: '1mb' }));
// Standard JSON parsing for all other routes
app.useGlobalParsers();
await app.listen(3000);
}
bootstrap();
-- idempotency-schema.sql
CREATE TABLE webhook_events (
event_id VARCHAR(255) PRIMARY KEY,
payment_id VARCHAR(255) NOT NULL,
gateway_source VARCHAR(50) NOT NULL,
processed_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT fk_payment FOREIGN KEY (payment_id) REFERENCES payments(id)
);
CREATE INDEX idx_webhook_events_payment_id ON webhook_events(payment_id);
CREATE INDEX idx_webhook_events_processed_at ON webhook_events(processed_at);
// queue-config.ts
import { Queue, Worker } from 'bullmq';
export const eventQueue = new Queue('financial-events', {
connection: { host: process.env.REDIS_HOST, port: 6379 },
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 100,
removeOnFail: false,
},
});
export const eventWorker = new Worker('financial-events', async (job) => {
// Delegate to processor service
await paymentProcessor.execute(job.data);
}, {
connection: { host: process.env.REDIS_HOST, port: 6379 },
concurrency: 10,
limiter: { max: 50, duration: 1000 },
});
Quick Start Guide
- Configure raw body extraction: Disable default JSON parsing for webhook routes. Apply
bodyParser.raw() middleware to capture exact byte sequences for HMAC verification.
- Deploy idempotency table: Run the provided SQL schema. Ensure
event_id is the primary key and foreign keys reference your payments table.
- Initialize queue and worker: Instantiate BullMQ queue with exponential backoff. Spin up worker pool with concurrency limits matching your database connection pool.
- Wire state machine validation: Export the transition matrix as a versioned constant. Inject it into your ledger service before any
UPDATE operation.
- Enable observability: Add metrics for
webhook_ack_latency, duplicate_events_skipped, illegal_transitions_dropped, and reconciler_discrepancies. Alert on reconciler_discrepancies > 0.