| name | webhook-architecture |
| description | 3-layer webhook architecture pattern for NestJS applications handling inbound webhooks from external services (payment providers, messaging platforms, CI/CD, etc.). Use when implementing webhook endpoints, processing external events, handling webhook signature verification, building event routing systems, implementing idempotent webhook processing, or setting up webhook failure recovery. Covers controller-level HTTP validation, service-level signature verification and event routing, and business-level domain logic handlers. |
3-Layer Webhook Architecture
Pattern for handling inbound webhooks in NestJS with strict separation of concerns across three layers: HTTP validation, event verification and routing, and domain logic execution. Apply this pattern whenever an external service sends events to your application via HTTP callbacks.
Activation scope
This skill activates per-module during Stage B of the SaaS bootstrap, once per inbound webhook source on the roadmap (one Stage B session per webhook-<provider> item). It is NOT part of the Stage A foundation produced by saas-workspace-initializer — webhook layers land alongside the domain that consumes them, not up front.
Architecture
External Service (Stripe, Paddle, GitHub, Twilio, etc.)
|
| POST /webhooks/{provider}
| Headers: signature, timestamp
| Body: raw bytes
v
+-----------------------------------------------------------+
| LAYER 1: Controller (HTTP Concerns) |
| - Extract raw body (Buffer, not parsed JSON) |
| - Extract signature/timestamp headers |
| - Validate required headers present |
| - Apply rate limiting / throttle |
| - ALWAYS return 200 (delegate errors to service layer) |
+-----------------------------------------------------------+
|
| rawBody: Buffer, signature: string, headers
v
+-----------------------------------------------------------+
| LAYER 2: Webhook Service (Verification & Routing) |
| - Verify signature (SDK or manual HMAC) |
| - Parse raw body into typed event |
| - Check idempotency (skip duplicate event IDs) |
| - Route to correct handler by event type |
| - Catch ALL errors, store as FailedWebhook |
| - Never throw (controller always returns 200) |
+-----------------------------------------------------------+
|
| typed event payload
v
+-----------------------------------------------------------+
| LAYER 3: Business Service (Domain Logic) |
| - One handler method per event type |
| - Database operations (create/update records) |
| - Side effects (email, SSE push, audit log) |
| - Can throw errors (Layer 2 catches them) |
+-----------------------------------------------------------+
Quick Start
1. Controller (HTTP Layer)
import { Controller, Post, RawBody, Headers, HttpCode } from '@nestjs/common';
import { ExternalWebhookService } from './external-webhook.service';
@Controller('webhooks')
export class ExternalWebhookController {
constructor(private readonly webhookService: ExternalWebhookService) {}
@Post('external-provider')
@HttpCode(200)
async handleWebhook(@RawBody() rawBody: Buffer, @Headers('x-provider-signature') signature: string, @Headers('x-provider-timestamp') timestamp: string): Promise<{ received: boolean }> {
if (!signature) {
return { received: false };
}
await this.webhookService.processWebhook(rawBody, signature, timestamp);
return { received: true };
}
}
2. Webhook Service (Verification and Routing Layer)
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { BusinessService } from '../services/business.service';
@Injectable()
export class ExternalWebhookService {
private readonly logger = new Logger(ExternalWebhookService.name);
constructor(
private readonly prisma: PrismaService,
private readonly businessService: BusinessService,
) {}
async processWebhook(rawBody: Buffer, signature: string, timestamp: string): Promise<void> {
try {
const event = this.verifyAndParse(rawBody, signature, timestamp);
const existing = await this.prisma.processedEvent.findUnique({
where: { eventId: event.id },
});
if (existing) {
this.logger.log(`Duplicate event ${event.id}, skipping`);
return;
}
await this.routeEvent(event);
await this.prisma.processedEvent.create({
data: { eventId: event.id, eventType: event.type, processedAt: new Date() },
});
} catch (error) {
this.logger.error(`Webhook processing failed: ${error.message}`, error.stack);
await this.storeFailedWebhook(rawBody, error);
}
}
private async routeEvent(event: ProviderEvent): Promise<void> {
switch (event.type) {
case 'subscription.created':
await this.businessService.handleSubscriptionCreated(event.data);
break;
case 'subscription.canceled':
await this.businessService.handleSubscriptionCanceled(event.data);
break;
case 'payment.completed':
await this.businessService.handlePaymentCompleted(event.data);
break;
default:
this.logger.warn(`Unhandled event type: ${event.type}`);
}
}
private async storeFailedWebhook(rawBody: Buffer, error: Error): Promise<void> {
await this.prisma.failedWebhook.create({
data: {
rawPayload: rawBody.toString('utf-8'),
errorMessage: error.message,
stackTrace: error.stack ?? '',
attemptedAt: new Date(),
},
});
}
private verifyAndParse(rawBody: Buffer, signature: string, timestamp: string): ProviderEvent {
return sdk.webhooks.unmarshal(rawBody.toString(), signature, timestamp);
}
}
3. Business Service (Domain Logic Layer)
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class BusinessService {
private readonly logger = new Logger(BusinessService.name);
constructor(
private readonly prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
) {}
async handleSubscriptionCreated(data: SubscriptionData): Promise<void> {
const subscription = await this.prisma.subscription.create({
data: {
externalId: data.id,
userId: data.customerId,
planId: data.planId,
status: 'active',
currentPeriodEnd: new Date(data.currentPeriodEnd),
},
});
this.eventEmitter.emit('subscription.created', subscription);
this.logger.log(`Subscription created: ${subscription.id}`);
}
async handleSubscriptionCanceled(data: SubscriptionData): Promise<void> {
await this.prisma.subscription.update({
where: { externalId: data.id },
data: { status: 'canceled', canceledAt: new Date() },
});
this.logger.log(`Subscription canceled: ${data.id}`);
}
async handlePaymentCompleted(data: PaymentData): Promise<void> {
await this.prisma.payment.create({
data: {
externalId: data.id,
amount: data.amount,
currency: data.currency,
userId: data.customerId,
paidAt: new Date(),
},
});
this.logger.log(`Payment recorded: ${data.id}`);
}
}
Key Design Decisions
| Decision | Rationale |
|---|
| Always return 200 to provider | Providers retry on non-2xx, causing duplicate processing. Store failures internally instead. |
| Use raw body for signature verification | Parsed JSON alters whitespace/ordering, breaking HMAC comparison. Always pass the original bytes. |
| Track event IDs for idempotency | Providers may retry even on 200 (network timeouts). Deduplication prevents double-processing. |
| Keep controllers thin | Controllers handle HTTP concerns only (headers, raw body, response code). Zero business logic. |
| Catch all errors in webhook service | Handler errors must never bubble up to the controller. Log, store, and swallow. |
| One handler method per event type | Each event type maps to exactly one business method. Easy to test, easy to find. |
When to Use This Pattern
Apply the 3-layer webhook architecture whenever you receive HTTP callbacks from external services:
- Payment providers: Stripe, Paddle, LemonSqueezy, PayPal
- Messaging platforms: Twilio, SendGrid, Mailgun, Resend
- CI/CD systems: GitHub Actions, GitLab CI, CircleCI
- Version control: GitHub webhooks, GitLab webhooks, Bitbucket
- Communication: Slack, Discord, Microsoft Teams
- Infrastructure: AWS SNS, Datadog, PagerDuty
NestJS Main Configuration
Enable raw body parsing in main.ts to preserve the original bytes for signature verification:
const app = await NestFactory.create(AppModule, { rawBody: true });
app.setGlobalPrefix('api', {
exclude: [{ path: 'webhooks/(.*)', method: RequestMethod.ALL }],
});
References
Load these for detailed implementation guidance:
- three-layer-pattern.md - Full implementation guide for all three layers including module setup, main.ts configuration, and typed event routing
- signature-verification.md - SDK-based and manual HMAC signature verification, timestamp validation, constant-time comparison
- resilience-and-recovery.md - Idempotent processing, failed webhook storage, always-200 pattern, retry queues