Sending messages from NestJS with the Sent TypeScript SDK
This guide shows you how to wire Sent messaging into an existing NestJS app: install the TypeScript SDK, register the client as a provider, send a template message from a controller, receive delivery webhooks, and verify the whole loop in sandbox mode.
Prerequisites
This guide assumes a working NestJS app that uses @nestjs/config. You also need:
- A Sent API key from the API Keys page in your Sent Dashboard
- A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the webhook local development guide
Configure the client provider
Add the credentials to your .env; the webhook secret arrives in step 4:
# .env
SENT_DM_API_KEY=your_api_key_here
SENT_DM_WEBHOOK_SECRET=whsec_your_signing_secretRegister one shared client as a global provider so any service can inject it:
// sent/sent.module.ts
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import SentDm from '@sentdm/sentdm';
export const SENT_CLIENT = Symbol('SENT_CLIENT');
@Global()
@Module({
providers: [
{
provide: SENT_CLIENT,
useFactory: (config: ConfigService) =>
new SentDm({ apiKey: config.getOrThrow<string>('SENT_DM_API_KEY') }),
inject: [ConfigService],
},
],
exports: [SENT_CLIENT],
})
export class SentModule {}Import SentModule in your AppModule alongside ConfigModule.forRoot({ isGlobal: true }).
Send a template message from a controller
Wrap the SDK call in a service; the pass-through sandbox flag lets callers exercise the endpoint without delivering anything:
// messages/messages.service.ts
import { Inject, Injectable, Logger } from '@nestjs/common';
import SentDm from '@sentdm/sentdm';
import { SENT_CLIENT } from '../sent/sent.module';
export interface SendMessageDto {
phoneNumber: string; // E.164 format, for example +14155551234
templateName: string; // reference by name or id, never both
parameters?: Record<string, string>;
channels?: string[]; // omit to let Sent pick per recipient
sandbox?: boolean; // true = validate and simulate only
}
@Injectable()
export class MessagesService {
private readonly logger = new Logger(MessagesService.name);
constructor(@Inject(SENT_CLIENT) private readonly sentClient: SentDm) {}
async sendMessage(dto: SendMessageDto) {
const response = await this.sentClient.messages.send({
to: [dto.phoneNumber],
template: { name: dto.templateName, parameters: dto.parameters ?? {} },
channel: dto.channels,
sandbox: dto.sandbox ?? false,
});
const recipient = response.data.recipients[0];
this.logger.log(`Message queued: ${recipient.message_id}`);
return { messageId: recipient.message_id, status: response.data.status };
}
}Expose it through a controller:
// messages/messages.controller.ts
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
import { MessagesService, SendMessageDto } from './messages.service';
@Controller('api/messages')
export class MessagesController {
constructor(private readonly messagesService: MessagesService) {}
@Post('send')
@HttpCode(202)
sendMessage(@Body() dto: SendMessageDto) {
return this.messagesService.sendMessage(dto);
}
}Sent accepts sends asynchronously: the API responds with status QUEUED and one message_id per recipient-and-channel pair. Store the message_id, because delivery outcomes arrive on your webhook endpoint instead of in this response.
Receive delivery webhooks
Enable raw body capture first, because signature verification needs the exact request bytes:
// main.ts (excerpt)
const app = await NestFactory.create(AppModule, { rawBody: true });Add a controller that verifies the x-webhook-signature header before trusting any event. The scheme is HMAC-SHA256 over {X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}, keyed with the base64-decoded secret after stripping its whsec_ prefix. Refer to webhook signature verification for the full scheme. Every event arrives in the same envelope (field, event, timestamp, payload), so one handler routes all of them:
// webhooks/webhooks.controller.ts
import {
BadRequestException,
Controller,
Headers,
Logger,
Post,
Req,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHmac, timingSafeEqual } from 'crypto';
import type { Request } from 'express';
// With rawBody enabled, Nest exposes the unparsed bytes on the request
type RawBodyRequest = Request & { rawBody?: Buffer };
interface WebhookEvent {
field: 'message' | 'templates';
event?: string; // granular sub-type; omitted for template events
timestamp: string;
payload: Record<string, any>;
}
@Controller('webhooks')
export class WebhooksController {
private readonly logger = new Logger(WebhooksController.name);
constructor(private readonly configService: ConfigService) {}
@Post('sent')
async handleWebhook(
@Headers('x-webhook-id') webhookId: string,
@Headers('x-webhook-timestamp') timestamp: string,
@Headers('x-webhook-signature') signature: string,
@Req() req: RawBodyRequest,
): Promise<{ received: boolean }> {
const rawBody = req.rawBody;
if (!rawBody) {
throw new BadRequestException('Raw body unavailable; enable rawBody in NestFactory.create');
}
const webhookSecret = this.configService.get<string>('SENT_DM_WEBHOOK_SECRET');
if (!webhookSecret) {
// Fail closed: never process an event you cannot verify
throw new UnauthorizedException('Webhook secret not configured');
}
if (!this.verifySignature(webhookId, timestamp, rawBody, signature, webhookSecret)) {
throw new UnauthorizedException('Invalid webhook signature');
}
// Reject replayed events older than 5 minutes
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
throw new UnauthorizedException('Webhook timestamp too old');
}
const event = JSON.parse(rawBody.toString('utf8')) as WebhookEvent;
await this.handleEvent(event);
return { received: true };
}
// The HMAC key is the signing secret after stripping the "whsec_" prefix
// and base64-decoding the remainder.
private verifySignature(
webhookId: string,
timestamp: string,
rawBody: Buffer,
signature: string,
secret: string,
): boolean {
if (!webhookId || !timestamp || !signature) {
return false;
}
const keyBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const signedContent = `${webhookId}.${timestamp}.${rawBody.toString('utf8')}`;
const expected = 'v1,' + createHmac('sha256', keyBytes).update(signedContent).digest('base64');
const signatureBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expected);
return signatureBuffer.length === expectedBuffer.length && timingSafeEqual(signatureBuffer, expectedBuffer);
}
private async handleEvent(event: WebhookEvent): Promise<void> {
if (event.field !== 'message') {
this.logger.log(`Unhandled webhook field: ${event.field}`);
return;
}
switch (event.event) {
case 'message.delivered':
this.logger.log(`Message ${event.payload.message_id} delivered`);
break;
case 'message.failed':
this.logger.error(`Message ${event.payload.message_id} failed (status ${event.payload.message_status})`);
break;
case 'message.received':
this.logger.log(`Inbound message from ${event.payload.inbound_number}: ${event.payload.text}`);
break;
default:
this.logger.log(`Message ${event.payload.message_id} status: ${event.payload.message_status}`);
}
}
}Register the controller in a module, import it in AppModule, then tell Sent where to deliver events. If you prefer a UI, use the webhooks getting started guide; otherwise register over the API:
curl -X POST https://api.sent.dm/v3/webhooks \
-H "x-api-key: $SENT_DM_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"display_name": "NestJS integration",
"endpoint_url": "https://your-domain.example/webhooks/sent",
"event_types": ["message"]
}'Copy two values from the response: the webhook id (used to test delivery in the next step) and signing_secret. Put the secret in SENT_DM_WEBHOOK_SECRET. The message event type covers every message event; the webhook event types reference lists all payload fields.
Verify the integration
Start the app with your credentials loaded:
npm run start:devSend a sandbox message through your new endpoint. Full validation runs, but nothing is delivered and no credits are consumed:
curl -X POST http://localhost:3000/api/messages/send \
-H "Content-Type: application/json" \
-d '{"phoneNumber": "+14155551234", "templateName": "welcome",
"parameters": {"name": "Ada"}, "sandbox": true}'The response should contain a messageId and "status": "QUEUED". A 400 here means the request shape is wrong; sandbox requests return real validation errors.
Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook id you copied:
curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \
-H "x-api-key: $SENT_DM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"event_type": "message.delivered"}'Your Nest log should show a Message ... delivered line, and the endpoint should have answered 200 {"received": true}. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix.
Adapt this to your app
- If you validate request bodies with
class-validator, turnSendMessageDtointo a decorated class (the appendix below has the schema). - If webhook processing does slow work (database writes, downstream calls), acknowledge with 200 first and hand off to a queue such as BullMQ so retries do not pile up; see handling webhook retries.
- If you package the client for reuse across apps, wrap the provider in a
DynamicModulewithforRootAsyncoptions instead of the global module shown here. - To send free-form text instead of a template, pass
textinstead oftemplate. Each send carries exactly one of the two.
Appendix: production scaffolding
The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production NestJS stack. Adapt them to your own conventions rather than adopting them wholesale.
Next steps
- Review the webhook event types reference for every payload field
- Work through the webhook production checklist before going live
- Explore the TypeScript SDK reference for retries, timeouts, and error types
- New to Sent? The first integration tutorial walks the same loop from scratch
Sending messages from Next.js with the Sent TypeScript SDK
Wire the Sent TypeScript SDK into a Next.js app: install, configure a server client, send from a route handler, verify webhooks, and test sandbox sends.
Sending messages from Express with the Sent TypeScript SDK
Wire the Sent TypeScript SDK into an Express app: install, configure the client, send template messages from a route, verify webhooks, and test sandbox sends.