Validate webhook signatures
Twilio signs every webhook request it sends (SMS, voice, status callbacks,
…) with an X-Twilio-Signature header. TwilioWebhookGuard recomputes
that signature for the exact URL and body the guard reconstructs, and
rejects the request if it doesn’t match. The comparison is byte for byte,
using the twilio SDK’s constant-time comparison.
Basic usage
Section titled “Basic usage”@TwilioWebhook() is all you need: it binds the guard and records the
route’s options in one step:
import { Body, Controller, Post } from '@nestjs/common';import { TwilioWebhook, TwilioWebhookRequest } from 'nestjs-twilio';
@Controller('webhooks/sms')export class WebhookController { @Post() @TwilioWebhook() handleIncomingSms(@Body() body: TwilioWebhookRequest) { // Reached only if the Twilio signature verified. console.log('From:', body.From); console.log('Body:', body.Body); }}You can still reference TwilioWebhookGuard directly (for example to register
it globally with APP_GUARD), but you do not need to pair it with the
decorator.
@TwilioWebhook() applies to a controller class (setting the default for
every handler) or to an individual handler method, where it overrides the
class-level options for that route only.
Resolving the auth token
Section titled “Resolving the auth token”TwilioWebhookGuard needs an auth token to compute the expected signature.
It resolves one with the following priority, from highest to lowest:
- The
authTokenpassed to@TwilioWebhook({ authToken: '...' })on the matched handler or controller. - A per-request override attached by earlier middleware/guards.
- A module-level default, injected via the
TWILIO_WEBHOOK_OPTIONSDI token.
import { Module } from '@nestjs/common';import { TwilioWebhookGuard, TWILIO_WEBHOOK_OPTIONS } from 'nestjs-twilio';
@Module({ providers: [ TwilioWebhookGuard, { provide: TWILIO_WEBHOOK_OPTIONS, useValue: { authToken: process.env.TWILIO_AUTH_TOKEN }, }, ],})export class WebhookModule {}If no auth token can be resolved, the guard throws a ForbiddenException
before attempting validation.
Reconstructing the signed URL
Section titled “Reconstructing the signed URL”Twilio signs the exact public URL it called. TwilioWebhookGuard
reconstructs that URL with this priority:
options.url, if supplied via@TwilioWebhook({ url: '...' }).options.protocol/options.host, if supplied.- The
X-Forwarded-ProtoandX-Forwarded-Hostheaders, for deployments behind a reverse proxy or load balancer. - The protocol and host Nest’s HTTP adapter observed directly on the request.
@Post('sms')@TwilioWebhook({ url: 'https://example.com/webhooks/sms' })handleIncomingSms(@Body() body: TwilioWebhookRequest) { // Signature validated against the exact URL above.}Use url whenever a proxy rewrites the request path in a way that
protocol/host overrides can’t describe.
Form-encoded vs. JSON bodies
Section titled “Form-encoded vs. JSON bodies”- Form-encoded webhooks (the default Twilio format) are validated with
the SDK’s
validateRequest, using the parsed request body directly. - JSON webhooks, signed by Twilio with a
bodySHA256query parameter, are validated withvalidateRequestWithBodyagainst the raw request bytes.TwilioWebhookGuardautomatically selects this path when the resolved URL containsbodySHA256.
For JSON webhooks, enable raw body capture when bootstrapping your
application so request.rawBody is populated with the exact bytes Twilio
sent:
const app = await NestFactory.create(AppModule, { rawBody: true });Without rawBody, the guard falls back to re-serializing the parsed body,
which only produces a matching hash if the JSON serialization happens to be
byte-identical to what Twilio sent.
Disabling validation for a route
Section titled “Disabling validation for a route”@Post('health')@TwilioWebhook({ disableValidation: true })health() { return { ok: true };}Useful for local development or intentionally public endpoints under a controller that otherwise requires validation.
Typed request bodies
Section titled “Typed request bodies”Interfaces for the bodies Twilio posts are exported from the package root, so handlers can be typed without hand-declaring the shape.
import { TwilioWebhook, TwimlInterceptor, type TwilioIncomingMessagePayload,} from 'nestjs-twilio';
@Post('webhooks/sms')@TwilioWebhook()@UseInterceptors(TwimlInterceptor)handleSms(@Body() payload: TwilioIncomingMessagePayload) { const reply = new twilio.twiml.MessagingResponse(); reply.message(`Thanks, ${payload.ProfileName ?? payload.From}`); return reply;}| Type | Body it describes |
|---|---|
TwilioIncomingMessagePayload |
An inbound SMS, MMS, RCS or WhatsApp message |
TwilioMessageStatusPayload |
A message StatusCallback as an outbound message progresses |
TwilioIncomingCallPayload |
An inbound voice call, and any TwiML action callback |
TwilioCallStatusPayload |
A call StatusCallback, adding duration and recording fields |
The supporting unions TwilioMessageStatus, TwilioCallStatus and
TwilioCallDirection constrain the status fields to the values Twilio
documents, so a typo in a comparison is a compile error.
Twilio states that it may add parameters without notice, so treat these as the documented subset rather than an exhaustive list. Signature validation covers the entire body whatever it contains, so a new parameter never breaks verification.
Reference
Section titled “Reference”See TwilioWebhookOptions for the
full set of per-route options, TwilioWebhookGuard
for the guard itself, TwilioWebhook for
the decorator, and TwilioWebhookRequest
for the request shape the guard reads.