Skip to content
SMS SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Receive webhooks

Receive delivery status updates and inbound texts from any provider as verified SmsEvent objects, in Next.js, Hono, or Bun.serve.

Providers tell you whether a message arrived, and pass on texts people send to your number, by calling a URL on your server. parseSmsWebhook() checks that the request really came from the provider, then gives you the same SmsEvent shape for all six providers.

One handler

import { parseSmsWebhook, WebhookSignatureError, type SmsEvent } from "@opencoredev/sms-sdk/webhooks";
import { db } from "./db";

export async function handleSmsWebhook(request: Request): Promise<Response> {
  let event: SmsEvent;
  try {
    event = await parseSmsWebhook({
      provider: "telnyx",
      request,
      credentials: { publicKey: process.env.TELNYX_PUBLIC_KEY ?? "" },
    });
  } catch (error) {
    if (error instanceof WebhookSignatureError) {
      return new Response("Invalid signature", { status: 401 });
    }
    throw error;
  }

  if (!(await db.webhookEvents.insertIfNew(event.dedupeKey))) {
    return new Response(null, { status: 200 }); // a repeat: acknowledge and stop
  }

  switch (event.type) {
    case "message.delivered":
    case "message.undelivered":
    case "message.filtered":
      await db.messages.updateStatus(event.providerId, event.type, event.errorCode);
      break;
    case "message.received":
      await db.inbox.save({ from: event.from, to: event.to, body: event.body, providerId: event.providerId });
      break;
    case "recipient.opted_out":
      await db.suppressions.add(event.from);
      break;
  }
  return new Response(null, { status: 200 });
}

The handler answers 401 to forged requests, acknowledges repeats without handling them twice, and records the events it cares about. For another provider, change provider and credentials; each provider’s credentials are in Webhook security.

parseSmsWebhook() reads the raw body itself, because the signature covers the exact bytes. Do not read or parse the body before calling it.

Wire it to your framework

Each route passes the untouched Request to the handler.

Next.js, in app/api/webhooks/sms/telnyx/route.ts. Next.js does not cache POST handlers, so every request runs:

import { handleSmsWebhook } from "./sms-webhook";

export const runtime = "nodejs";

export async function POST(request: Request): Promise<Response> {
  return handleSmsWebhook(request);
}

Hono, which also runs on Node.js with @hono/node-server. Pass c.req.raw and do not call c.req.json(), c.req.text(), or c.req.parseBody() first, because the body can be read only once:

import { Hono } from "hono";
import { handleSmsWebhook } from "./sms-webhook";

const app = new Hono();
app.post("/webhooks/sms/telnyx", (c) => handleSmsWebhook(c.req.raw));

export default app;

Bun.serve:

import { handleSmsWebhook } from "./sms-webhook";

Bun.serve({
  port: 3000,
  fetch(request) {
    const isWebhook = request.method === "POST" && new URL(request.url).pathname === "/webhooks/sms/telnyx";
    return isWebhook ? handleSmsWebhook(request) : new Response("Not found", { status: 404 });
  },
});

SMS SDK is tested on Node.js and Bun; edge runtimes have the APIs it needs but are not tested. Keep provider secrets in server-only environment variables (in Next.js, never prefix them with NEXT_PUBLIC_, and add import "server-only"; to the module that creates sms; Bun loads .env on its own). On serverless platforms, store the event before you respond, because the function can stop right after the response, and use a shared store instead of memoryIdempotencyStore(). To test locally, expose your port with a tunnel; see Test without a provider.

Event types

type When
message.queued, message.sent, message.delivered, message.undelivered, message.filtered The status of a message you sent changed.
message.received Someone texted your number.
recipient.opted_out, recipient.opted_in, recipient.help Someone replied STOP, START, or HELP. See STOP, HELP, and opt-outs.
unrecognized A verified event SMS SDK does not map, such as Twilio read. raw holds the payload.

Every event has provider, dedupeKey, and raw, plus occurredAt when the payload includes a time. Full shapes are in Webhook events.

Delivery status

A successful send() only means the provider accepted the message. Whether it reached the phone arrives later as a message.* event. Save result.providerId when you send; status events carry the same ID in event.providerId, with providerStatus (the provider’s own word) and errorCode when there is one.

import { db } from "./db";
import { sms } from "./sms";

const result = await sms.send({ to: "+14155550123", body: "Your order has shipped." });
await db.messages.create({ id: result.id, provider: result.provider, providerId: result.providerId, to: "+14155550123", status: result.delivery });

Point status callbacks at your route in the provider’s console or per message with webhookUrl. Where to set it differs by provider: see Where to point webhooks and each provider page. Not every carrier reports handset delivery, so a message.sent with nothing after it usually means delivered. How each provider’s statuses map to events is in Status mapping.

Replies

Texts to your number arrive as message.received with from (the sender), to (your number), body, and mediaUrls. A message that is only STOP, HELP, or a similar keyword arrives as a recipient.* event instead.

SMS SDK never replies on its own. To answer, send from the number that was texted, and derive the idempotency key from the event so a repeated webhook cannot send a second reply:

import { isE164 } from "@opencoredev/sms-sdk";
import type { SmsEvent } from "@opencoredev/sms-sdk/webhooks";
import { sms } from "./sms";

export async function reply(event: SmsEvent): Promise<void> {
  if (event.type !== "message.received" || !isE164(event.from) || !isE164(event.to)) return;
  await sms.send({
    to: event.from,
    from: event.to,
    body: "Thanks, we got your message.",
    idempotencyKey: `reply:${event.dedupeKey}`,
  });
}

This protects you only when the client has an idempotency store.

Duplicates and ordering

Providers retry webhooks that fail or time out, and send events in parallel. The same event can arrive twice, and events can arrive in any order.

  • Deduplicate on dedupeKey, which stays the same each time the provider delivers an event. Insert it atomically (for example INSERT ... ON CONFLICT DO NOTHING), and answer repeats with a 2xx so the provider stops retrying.
  • Never assume order. A late message.sent must not overwrite message.delivered: keep the furthest status, so delivered beats queued and sent.

Per-provider key formats, a ranking function, and storage details are in Webhook events.

Next steps