Why @WebhookEmit is a NestJS interceptor, not a controller decorator
The design choices behind @WebhookEmit — how a NestJS interceptor solves the response-capture, serverless-await, and dependency-injection problems that a plain method decorator can't.
If you've shipped webhooks from a NestJS service before, you've probably written this:
@Controller('orders')
export class OrdersController {
constructor(
private readonly orders: OrdersService,
private readonly webhooks: WebhookService,
) {}
@Post()
async create(@Body() dto: CreateOrderDto) {
const order = await this.orders.create(dto);
await this.webhooks.emit('order.created', order);
return order;
}
}Every endpoint repeats the pattern. New developers forget the second line. Tests have to mock the webhook service everywhere. And the await placement is subtle: emit before responding and you couple every webhook to your p99 latency; emit after responding and serverless runtimes might kill the function mid-flight.
We didn't want to ship the @nestarc/webhook SDK with that as the recommended path. We wanted this:
@Controller('orders')
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Post()
@WebhookEmit('order.created', { await: true })
create(@Body() dto: CreateOrderDto) {
return this.orders.create(dto);
}
}The handler does one thing. The decorator says this method emits order.created and the platform should wait for the emit to succeed before responding. The webhook service is no longer a controller dependency.
Getting from the first example to the second is a five-line change in the consumer, but the implementation underneath has to make three design decisions. This post walks through each.
Decision 1: where the emit code lives
The first instinct is to make @WebhookEmit a method decorator that wraps the function:
// Naive method decorator (do not ship this)
function WebhookEmit(eventType: string) {
return function (target, propertyKey, descriptor) {
const original = descriptor.value;
descriptor.value = async function (...args) {
const result = await original.apply(this, args);
const client = /* ??? how do we get this ??? */;
await client.emit(eventType, result);
return result;
};
};
}The /* ??? */ line is the problem. The decorator runs once at class-definition time and rewrites the prototype method. Inside the rewritten method, this is the controller instance, so we could assume the controller has a webhookService property — but now @WebhookEmit only works on controllers that opted into the dependency, which is exactly the boilerplate we were trying to delete.
We could reach into a global registry instead. NestJS has one — the module's DI container — but a plain decorator doesn't get access to it. The decorator function isn't a class, isn't a provider, doesn't participate in the application lifecycle.
The thing in NestJS that does get all of those is an interceptor (opens in new tab). An interceptor is a class. It's a provider, so it has constructor injection. It wraps the handler with access to the ExecutionContext, the response observable, and exception handling.
So @WebhookEmit is split in two:
- A method decorator that does nothing at runtime except attach metadata via
Reflector. - A global interceptor that reads the metadata and runs the emit.
// The decorator is just metadata
export const WebhookEmit = (eventType: string, options?: WebhookEmitOptions) =>
SetMetadata(WEBHOOK_EMIT_KEY, { eventType, ...options });
// The interceptor reads it
@Injectable()
export class WebhookEmitInterceptor implements NestInterceptor {
constructor(
private readonly reflector: Reflector,
private readonly webhooks: WebhookService,
) {}
intercept(context: ExecutionContext, next: CallHandler) {
const meta = this.reflector.get<WebhookEmitMeta>(
WEBHOOK_EMIT_KEY,
context.getHandler(),
);
if (!meta) return next.handle();
return next.handle().pipe(
tap(/* or mergeMap, see Decision 2 */, async (response) => {
await this.webhooks.emit(meta.eventType, response);
}),
);
}
}The decorator is dumb on purpose. All the runtime work — DI, the emit call, error handling — lives in the interceptor, which is what NestJS already knows how to construct.
Decision 2: when the emit awaits
Now the harder question. The interceptor sees the controller's response. There are two places the emit could happen:
Path A — fire and return. Schedule the emit, return the response immediately. The handler's HTTP response goes out the door at p99 unchanged. The emit happens in the background.
Path B — await, then return. Emit synchronously inside the response pipeline. The HTTP response waits for webhooks.emit() to resolve before going to the client.
Both are right answers, for different webhooks.
Path A is what you want for analytics-shaped events: user.signed_in, feature.used, note.viewed. Missing one is fine. Adding 30ms to the response is not fine.
Path B is what you want for state-change events that downstream systems depend on: order.created, payment.captured, account.deleted. If the emit fails, you'd rather the caller see a 5xx and retry the whole operation than ship a successful response with no event.
There's also a third behavior people often want without realizing it: the emit needs to survive the function's lifecycle. On Lambda, Cloud Run, and Vercel Functions, the runtime can suspend the container the instant your response is flushed. If you scheduled an emit with no await and the network call is still in flight when the response leaves, the request can get torn down mid-flight. Path A is wrong in serverless unless you have a real background queue.
So @WebhookEmit takes an option:
@WebhookEmit('order.created', { await: true }) // Path B
create(@Body() dto: CreateOrderDto) { ... }
@WebhookEmit('feature.used') // Path A (default)
recordUsage(@Body() dto: UsageDto) { ... }When await: true, the interceptor uses mergeMap to wait for the emit before re-emitting the value downstream. When await is false or omitted, the emit goes through tap with its own catch-and-log so the response isn't blocked.
There's no third option that "works everywhere." We considered defaulting to await: true for safety, but that would couple every webhook to response latency by default, and we'd be surprising people more than helping them. Defaulting to await: false is the surprise we accept — but await: true is documented next to every example that emits state-change events, and the option name reads as "yes, I want this awaited before the response goes."
Decision 3: when emission is conditional
State-change endpoints don't always produce a state change. A POST /orders/:id/refund returns successfully whether or not the refund actually went through — sometimes the order is already refunded, sometimes the payment processor declines, sometimes nothing changed.
You don't want to emit order.refunded on the no-op responses.
A method decorator with no access to the response can't help here. The interceptor does have the response value. So @WebhookEmit accepts a when predicate:
@Post(':id/refund')
@WebhookEmit('order.refunded', {
await: true,
when: (response: RefundResult) => response.status === 'completed',
})
refund(@Param('id') id: string): Promise<RefundResult> {
return this.orders.refund(id);
}The interceptor evaluates when(response) after the handler resolves. If it returns false, no emit. If true, the emit runs against the response.
This is the part you cannot do with a controller-level decorator and you cannot do cleanly with a wrapping method decorator. The interceptor has the only seat in the pipeline that sees the response and has DI access and runs inside the lifecycle.
How registration looks
The module exposes both forRoot() and forRootAsync() because that's the NestJS convention for everything (TypeORM, Redis, Cache, etc.) and you should let people register your module the same way they register the rest of their infrastructure.
// Static config — fine for small services
@Module({
imports: [
WebhookClientModule.forRoot({
baseUrl: 'https://api.nestarc.dev',
apiKey: process.env.WEBHOOK_API_KEY!,
}),
],
})
export class AppModule {}// Async config — what most production apps use
@Module({
imports: [
ConfigModule.forRoot(),
WebhookClientModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
baseUrl: config.getOrThrow('WEBHOOK_BASE_URL'),
apiKey: config.getOrThrow('WEBHOOK_API_KEY'),
}),
}),
],
})
export class AppModule {}The async variant is the one we tested first, because if the typed-config pattern doesn't compose, nothing else will. forRoot() came afterward, as the simpler API for people who haven't set up ConfigModule yet.
Internally both variants register the same interceptor as APP_INTERCEPTOR. You don't need to add it to your @UseInterceptors() list — the module wires it globally so every @WebhookEmit annotation in the application picks it up.
What we considered and didn't ship
A few alternatives were on the table.
@nestjs/event-emitter style — @OnEvent('order.created'). Decouples emission from the handler entirely. Looks clean. We rejected it because grepping a NestJS codebase for "what emits order.created?" should return one line — the @WebhookEmit on the handler — and event-emitter patterns turn that into a chase across listeners. The local annotation is part of the readability story.
A service method — webhooks.emit('order.created', order). Explicit, no magic. We ship this too, as WebhookService injected directly, because some emits don't correspond to a controller method (cron jobs, queue consumers, anything that isn't an HTTP request flowing through Nest's interceptor pipeline). But for the controller case, the decorator is shorter and the boilerplate doesn't accumulate.
Combining multiple decorators with applyDecorators. That helps when you want one annotation that bundles @UseGuards, @HttpCode, etc. It doesn't solve the response-capture problem, because the response only exists inside the interceptor pipeline.
What this buys
For NestJS users, @WebhookEmit reads as "this handler emits this event" — the same vocabulary they already use for @Get, @Post, @UseGuards. There's nothing new to learn about lifecycle, no boilerplate to add to controllers, no DI to thread through. The interceptor is invisible in day-to-day code, which is the point.
For us, building on the interceptor primitive meant we got error handling, request context, observability hooks, and the global-vs-controller-vs-method registration story for free. Picking the right NestJS primitive was the design.
If you want to see the implementation, the @nestarc/webhook package (opens in new tab) is open source on the Nestarc GitHub (opens in new tab), and the docs walk through the same options with copy-pasteable examples.