Sending messages from Next.js with the Sent TypeScript SDK
This guide shows you how to wire Sent messaging into an existing Next.js app: install the TypeScript SDK, configure a server-side client, send a template message from a route handler, receive delivery webhooks, and verify the whole loop in sandbox mode.
Prerequisites
This guide assumes a working Next.js 14+ app using the App Router. 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 a server-side client
Add the credentials to .env.local. Do not prefix them with NEXT_PUBLIC_; the API key must never reach the browser:
# .env.local
SENT_DM_API_KEY=your_api_key_here
SENT_DM_WEBHOOK_SECRET=whsec_your_signing_secretCreate one shared client module for all server code (route handlers, Server Actions, Server Components):
// lib/sent/client.ts
import SentDm from '@sentdm/sentdm';
const apiKey = process.env.SENT_DM_API_KEY;
if (!apiKey && process.env.NODE_ENV === 'production') {
throw new Error('SENT_DM_API_KEY is required in production');
}
export const sentClient = new SentDm({
apiKey: apiKey || 'test-key',
maxRetries: 2,
timeout: 30 * 1000,
});Send a template message from a route handler
Add a route handler that calls messages.send; the pass-through sandbox flag lets callers exercise the route without delivering anything:
// app/api/messages/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sentClient } from '@/lib/sent/client';
export async function POST(request: NextRequest) {
const { phoneNumber, templateName, parameters, channels, sandbox } = await request.json();
const response = await sentClient.messages.send({
to: [phoneNumber], // E.164 format, for example +14155551234
template: { name: templateName, parameters }, // reference by name or id, never both
channel: channels, // omit to let Sent pick per recipient
sandbox: sandbox ?? false, // true = validate and simulate only
});
const recipient = response.data.recipients[0];
return NextResponse.json(
{ messageId: recipient.message_id, status: response.data.status },
{ status: 202 },
);
}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. Client Components must call this route (or a Server Action); they can never hold the SDK client.
Receive delivery webhooks
Add a webhook route handler that verifies the x-webhook-signature header against the raw body before trusting any event; request.text() gives you the exact bytes. 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:
// app/api/webhooks/sent/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createHmac, timingSafeEqual } from 'crypto';
const WEBHOOK_SECRET = process.env.SENT_DM_WEBHOOK_SECRET;
// The HMAC key is the signing secret after stripping the "whsec_" prefix
// and base64-decoding the remainder; signature format is "v1,{base64(hmac)}"
function verifySignature(webhookId: string, timestamp: string, rawBody: string, signature: string, secret: string): boolean {
const keyBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const signedContent = `${webhookId}.${timestamp}.${rawBody}`;
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);
}
export async function POST(request: NextRequest) {
const webhookId = request.headers.get('x-webhook-id');
const timestamp = request.headers.get('x-webhook-timestamp');
const signature = request.headers.get('x-webhook-signature');
if (!webhookId || !timestamp || !signature) {
return NextResponse.json({ error: 'Missing webhook headers' }, { status: 401 });
}
if (!WEBHOOK_SECRET) {
// Fail closed: never process an event you cannot verify
return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 });
}
const rawBody = await request.text();
if (!verifySignature(webhookId, timestamp, rawBody, signature, WEBHOOK_SECRET)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
// Reject replayed events older than 5 minutes
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return NextResponse.json({ error: 'Timestamp too old' }, { status: 401 });
}
const event = JSON.parse(rawBody);
const payload = event.payload ?? {};
if (event.field === 'message') {
switch (event.event) {
case 'message.delivered':
console.log(`Message ${payload.message_id} delivered`);
break;
case 'message.failed':
console.error(`Message ${payload.message_id} failed (status ${payload.message_status})`);
break;
case 'message.received':
console.log(`Inbound ${payload.channel} from ${payload.inbound_number}: ${payload.text}`);
break;
default:
console.log(`Message ${payload.message_id} status: ${payload.message_status}`);
}
}
return NextResponse.json({ received: true });
}Keep this route on the default Node.js runtime, because it uses the crypto module. 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": "Next.js integration",
"endpoint_url": "https://your-domain.example/api/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 devSend a sandbox message through your new route. Full validation runs, but nothing is delivered and no credits are consumed:
curl -X POST http://localhost:3000/api/messages \
-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 dev server 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 send from a form, use a Server Action instead of the route handler. The appendix below shows the pattern; the SDK call is identical.
- If you deploy on the Edge runtime, create the client inside the edge route with lower
timeoutandmaxRetriesvalues, and keep the webhook route on Node.js. - If webhook processing does slow work (database writes, downstream calls), return 200 first and hand off to a queue so retries do not pile up; see handling webhook retries.
- 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 Next.js 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
Your first Sent integration with the TypeScript SDK
Learn Sent by building a real TypeScript integration: send sandbox messages, receive delivery webhooks on your machine, and look up message status with the SDK.
Sending messages from NestJS with the Sent TypeScript SDK
Wire the Sent TypeScript SDK into a NestJS app: install, register a client provider, send from a controller, verify webhooks, and test with sandbox mode.