TypeScript SDK

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:

Install the SDK

Add the SDK to your existing project:

npm install @sentdm/sentdm

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_secret

Create 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 dev

Send 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 timeout and maxRetries values, 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 text instead of template. 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

On this page