---
title: "Receive webhooks"
description: "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

```ts sms-webhook.ts
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](/receiving/webhook-security#how-each-provider-signs).

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

:::warning[Twilio and Plivo sign the URL]
Behind a proxy or on most hosting platforms, your app sees a different URL from the one the provider called, and verification fails. Pass `publicUrl` with the exact URL from the provider console. See [Webhook security](/receiving/webhook-security#fix-url-mismatches-behind-a-proxy).
:::

## 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:

```ts route.ts
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:

```ts
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:

```ts
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](/advanced/multiple-instances) instead of `memoryIdempotencyStore()`. To test locally, expose your port with a tunnel; see [Test without a provider](/testing/local-testing).

## 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](/receiving/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](/reference/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.

```ts
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](/reference/webhook-events#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](/reference/webhook-events#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:

```ts
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](/sending/idempotency).

## 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](/reference/webhook-events#duplicates-and-ordering).

## Next steps

- [Webhook security](/receiving/webhook-security)
- [STOP, HELP, and opt-outs](/receiving/opt-outs)
- [Webhook events reference](/reference/webhook-events)
