---
title: "Webhooks and events"
description: "@opencoredev/sms-sdk/webhooks: parseSmsWebhook options per provider, the SmsEvent union, keyword helpers, and signature verify helpers."
---

```ts
import { parseSmsWebhook, type SmsEvent } from "@opencoredev/sms-sdk/webhooks";
```

## `parseSmsWebhook(options)`

Reads the request body once, verifies the provider's signature, and returns a normalized `SmsEvent`. No field is interpreted before verification succeeds.

```ts
import { parseSmsWebhook } from "@opencoredev/sms-sdk/webhooks";

export async function POST(request: Request): Promise<Response> {
  const event = await parseSmsWebhook({
    provider: "twilio",
    request,
    publicUrl: "https://example.com/webhooks/sms/twilio",
    credentials: { authToken: process.env.TWILIO_AUTH_TOKEN ?? "" },
  });
  console.log(event.type, event.dedupeKey);
  return new Response(null, { status: 200 });
}
```

Guides: [Receive webhooks](/receiving/webhooks), [Webhook security](/receiving/webhook-security).

### Parameters

Options are discriminated by `provider`. Every variant accepts the common options.

| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| `provider` | `"twilio" \| "telnyx" \| "plivo" \| "vonage" \| "bandwidth" \| "sinch"` | required | Which scheme to verify and parse. |
| `request` | `Request` | required | The incoming web request. Its body must not have been read. |
| `credentials` | per provider, below | required | Verification secret. |
| `unsafeSkipVerification` | `boolean` | `false` | Skips signature checks. For tests only. The event is not authenticated. |
| `detectKeywords` | `boolean` | `true` | Match STOP/HELP keywords in inbound text when the provider sends no signal. |
| `now` | `() => number` | `Date.now` | Clock in epoch ms, for timestamp checks. |

Per-provider options:

| `provider` | `credentials` | Extra options |
| --- | --- | --- |
| `"twilio"` | `{ authToken: string }` | `publicUrl?: string`, `trustProxy?: boolean` |
| `"telnyx"` | `{ publicKey: string }` (base64, 32-byte Ed25519) | `toleranceSec?: number` (default 300) |
| `"plivo"` | `{ authToken: string }` | `publicUrl?: string`, `trustProxy?: boolean` |
| `"vonage"` | `{ signatureSecret: string }` | `toleranceSec?: number` (default 300) |
| `"bandwidth"` | `{ username: string; password: string }` (Basic credentials on the Bandwidth application) | none |
| `"sinch"` | `{ kind: "basic"; username: string; password: string }` or `{ kind: "hmac"; webhookSecret: string }` | `toleranceSec?: number` (HMAC only, default 300) |

`publicUrl` is the exact URL configured with the provider. If it has no query string, the request's query string is appended. Without `publicUrl`, `trustProxy: true` builds the URL from `X-Forwarded-Proto` and `X-Forwarded-Host`. Otherwise `request.url` is used and forwarded headers are ignored. See [Webhook security](/receiving/webhook-security).

### Returns

`Promise<SmsEvent>`.

### Throws

- `WebhookSignatureError` (`code: "webhook_signature"`) when the request is not authentic. `reason` is `missing_signature`, `invalid_signature`, `stale_timestamp`, `body_hash_mismatch`, `malformed_signature`, or `invalid_credentials`. Respond 401 or 403.
- `WebhookPayloadError` (`code: "webhook_payload"`) when an authentic payload lacks required fields.

## `SmsEvent`

Every event has these fields.

| Field | Type | Meaning |
| --- | --- | --- |
| `provider` | `"twilio" \| "telnyx" \| "plivo" \| "vonage" \| "bandwidth" \| "sinch"` | Which provider sent it. |
| `dedupeKey` | `string` | Stable across repeated deliveries of the same event. |
| `occurredAt` | `Date \| undefined` | When the provider says it happened, if the payload includes it. |
| `raw` | `unknown` | The verified payload, as a string record of form fields or as parsed JSON. Unknown extra fields are kept here. |

### `message.received`

```ts ignore="type listing"
{ type: "message.received"; providerId: string; from: string; to: string; body: string; mediaUrls: readonly string[] }
```

An inbound message. `from` is the sender and `to` is your number. Vonage and Sinch numbers that arrive without `+` are normalized to E.164. Media comes from Twilio, Telnyx, Plivo, and Bandwidth; `mediaUrls` is always empty for Vonage and Sinch. Media URLs point at provider storage, may expire, and may need provider credentials to fetch.

SMS SDK never replies on its own and does not store conversations. Twilio also lets you reply by returning TwiML. SMS SDK does not build TwiML; to send no reply, return `<Response></Response>` with `Content-Type: text/xml`. See [Receive webhooks](/receiving/webhooks#replies).

### Status events

```ts ignore="type listing"
{
  type: "message.queued" | "message.sent" | "message.delivered" | "message.undelivered" | "message.filtered";
  providerId: string;
  providerStatus: string; // the provider's own status value
  errorCode?: string;
  from?: string;
  to?: string;
}
```

A status update for an outbound message. `MessageStatusType` is the union of these five types. The mapping per provider is in [Status mapping](#status-mapping).

### Opt-out and help events

```ts ignore="type listing"
{
  type: "recipient.opted_out" | "recipient.opted_in" | "recipient.help";
  providerId: string;
  from: string;
  to: string;
  body: string; // the original inbound text
  source: "provider" | "keyword";
  keyword: string;
}
```

`source: "provider"` comes from Twilio's `OptOutType`. `source: "keyword"` comes from whole-message keyword matching. `recipient.opted_in` comes only from Twilio `OptOutType=START`. See [STOP, HELP, and opt-outs](/receiving/opt-outs).

### `unrecognized`

```ts ignore="type listing"
{ type: "unrecognized"; providerEventType: string }
```

A verified event SMS SDK does not map, such as Twilio `read` or a new Telnyx event type. Inspect `raw`. A provider's new statuses arrive here instead of breaking your handler.

## Where to point webhooks

Status updates go to the status URL, set per message with `webhookUrl` or once in the provider's console:

| Provider | Status URL |
| --- | --- |
| Twilio | `webhookUrl` (sent as `StatusCallback`), or the Messaging Service's status callback. |
| Telnyx | `webhookUrl`, or the messaging profile's webhook URL. |
| Plivo | `webhookUrl` (sent as `url` with `method: "POST"`). |
| Vonage | `webhookUrl`, or the Vonage application's Status URL. Needs JWT auth; with Basic auth Vonage sends no status webhooks. |
| Bandwidth | The callback URL on your messaging application. A per-message `webhookUrl` is rejected as unsupported. |
| Sinch | `webhookUrl` (sent as `callback_url`), or the service plan's callback URL. Set `providerOptions.sinch.deliveryReport`, such as `"per_recipient"`, to receive reports. |

Texts to your number go to the URL set on the number or its group. A per-message `webhookUrl` only affects status events.

| Provider | Inbound URL |
| --- | --- |
| Twilio | The number's "A message comes in" webhook, or the Messaging Service's incoming message setting. |
| Telnyx | The messaging profile's inbound webhook URL. |
| Plivo | The message URL of the Plivo application linked to the number. |
| Vonage | The Inbound URL of the Vonage application linked to the number. Send from a linked number with JWT auth; Vonage rejects Basic auth for linked numbers with a 401. |
| Bandwidth | The callback URL on the messaging application. |
| Sinch | The service plan's callback URL. |

## Status mapping

| Event | Twilio | Telnyx | Plivo | Vonage | Bandwidth | Sinch |
| --- | --- | --- | --- | --- | --- | --- |
| `message.queued` | queued, accepted, scheduled, sending | queued, sending | queued | (none) | message-sending | Queued, Pending |
| `message.sent` | sent | sent, delivery_unconfirmed | sent | submitted | (none) | Sent, Accepted |
| `message.delivered` | delivered | delivered | delivered | delivered | message-delivered | Delivered |
| `message.undelivered` | undelivered, failed | sending_failed, delivery_failed, expired | undelivered, failed | rejected, undeliverable | message-failed | Failed, Undelivered, Expired; Rejected or Filtered without code 40003 |
| `message.filtered` | a failure with error 30007 | a failure with error 40002, 40003, or 40322 | never | a failure with error 1210, 1470, 1472, or 1480 to 1483 | never | Rejected or Filtered with code 40003 |

`message.filtered` means a carrier or the provider blocked the message as spam or for its content. It needs one of the error codes above; any other failure stays `message.undelivered`. Telnyx status events come from its `message.sent` and `message.finalized` webhooks.

`delivered` means the carrier reported delivery to the handset, and not every carrier does. Some routes stop at `sent`, and Telnyx reports `delivery_unconfirmed` when there is no receipt. Treat `message.sent` with nothing after it as probably delivered. SMS SDK does not poll for status, so if a webhook never arrives, look the message up with the provider.

## Duplicates and ordering

### Dedupe keys

`dedupeKey` stays the same each time the provider delivers the same event, and starts with the provider name, so keys from two providers never collide.

| Provider | `dedupeKey` |
| --- | --- |
| Twilio | `twilio:{MessageSid}:{MessageStatus}` or `twilio:{MessageSid}:received` |
| Telnyx | `telnyx:{data.id}`, the webhook event ID |
| Plivo | `plivo:{MessageUUID}:{Status}` or `plivo:{MessageUUID}:received` |
| Vonage | `vonage:{message_uuid}:{status}` or `vonage:{message_uuid}:received` |
| Bandwidth | `bandwidth:{message.id}:{type}` |
| Sinch | `sinch:{batch_id}:{recipient}:{status}` for reports (`summary` in place of the recipient when there is none), `sinch:{id}:received` for inbound |

### Storing keys

- Insert keys atomically, such as `INSERT ... ON CONFLICT DO NOTHING` that returns whether a row was inserted. Reading first and inserting second lets two parallel deliveries both pass.
- Keep keys at least as long as the provider retries, typically a day or more.
- Answer repeats with a 2xx, because an error makes the provider retry again. Respond fast and do slow work in a background job, since a timeout also causes a retry.
- With several servers or serverless functions, keep keys in a shared database. See [Share idempotency across servers](/advanced/multiple-instances).

### Ordering

Providers send events in parallel, so `message.sent` can arrive after `message.delivered`. Never let a late event move a message backward. Rank the statuses and keep the highest:

```ts
import type { MessageStatusType } from "@opencoredev/sms-sdk/webhooks";

const rank: Record<MessageStatusType, number> = {
  "message.queued": 0,
  "message.sent": 1,
  "message.delivered": 2,
  "message.undelivered": 2,
  "message.filtered": 2,
};

export function nextStatus(current: MessageStatusType | undefined, incoming: MessageStatusType): MessageStatusType {
  if (current === undefined) return incoming;
  return rank[incoming] >= rank[current] ? incoming : current;
}

console.log(nextStatus("message.delivered", "message.sent")); // "message.delivered"
```

The final states share a rank, so the last one to arrive wins. Providers rarely send two different final states; when they do, `occurredAt`, if present, says which came later.

A status webhook can also arrive before you have saved the `send()` result. Upsert the status row by `providerId`, or hold unknown IDs briefly and apply them once the send result is saved.

## Keyword helpers

### `detectKeyword(body)`

Matches a whole message against the keyword lists, ignoring case, surrounding whitespace, and trailing `.`, `!`, or `?`.

```ts
import { detectKeyword } from "@opencoredev/sms-sdk/webhooks";

console.log(detectKeyword("Stop!")); // { kind: "opted_out", keyword: "STOP" }
console.log(detectKeyword("help")); // { kind: "help", keyword: "HELP" }
console.log(detectKeyword("Please stop texting me")); // undefined
```

Returns `KeywordMatch | undefined`, where `KeywordMatch` is `{ kind: "opted_out" | "help"; keyword: string }`.

### `OPT_OUT_KEYWORDS` and `HELP_KEYWORDS`

- `OPT_OUT_KEYWORDS`: `["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"]`
- `HELP_KEYWORDS`: `["HELP", "INFO"]`

## Per-provider parsers

`parseTwilioWebhook`, `parseTelnyxWebhook`, `parsePlivoWebhook`, `parseVonageWebhook`, `parseBandwidthWebhook`, and `parseSinchWebhook` take the same options as `parseSmsWebhook` without `provider`, with option types `TwilioWebhookOptions`, `TelnyxWebhookOptions`, `PlivoWebhookOptions`, `VonageWebhookOptions`, `BandwidthWebhookOptions`, and `SinchWebhookOptions`.

## Verify helpers

These check a signature without parsing, for example in middleware.

### `verifyTwilioSignature({ authToken, url, params, signature })`

Returns `Promise<boolean>`. `params` is a list of `[name, value]` pairs from the form body, empty for JSON bodies and GET requests. Tries the URL with and without the default port.

`computeTwilioSignature({ authToken, url, params })` returns the expected `X-Twilio-Signature` as `Promise<string>`.

### `verifyTelnyxSignature({ publicKey, rawBody, signature, timestamp, toleranceSec?, now? })`

Returns `Promise<WebhookVerification>`, either `{ valid: true }` or `{ valid: false, reason }`. `signature` and `timestamp` are the `telnyx-signature-ed25519` and `telnyx-timestamp` header values, or `null`. `now` is epoch ms. `TELNYX_DEFAULT_TOLERANCE_SEC` is `300`.

### `verifyPlivoSignatureV2({ authToken, url, nonce, signature })`

Returns `Promise<boolean>`. `url` may include a query string, which is removed before signing. `computePlivoSignatureV2({ authToken, url, nonce })` returns the expected signature.

### `verifyVonageSignature({ signatureSecret, authorization, rawBody, toleranceSec?, now? })`

Returns `Promise<WebhookVerification>`. `authorization` is the `Authorization` header value, or `null`. `VONAGE_DEFAULT_TOLERANCE_SEC` is `300`.

```ts
import { verifyTelnyxSignature } from "@opencoredev/sms-sdk/webhooks";

export async function isAuthentic(request: Request): Promise<boolean> {
  const result = await verifyTelnyxSignature({
    publicKey: process.env.TELNYX_PUBLIC_KEY ?? "",
    rawBody: await request.clone().text(),
    signature: request.headers.get("telnyx-signature-ed25519"),
    timestamp: request.headers.get("telnyx-timestamp"),
  });
  return result.valid;
}
```

## Types

Exported from `@opencoredev/sms-sdk/webhooks`: `ParseSmsWebhookOptions`, `SmsEvent`, `SmsEventBase`, `MessageReceivedEvent`, `MessageStatusEvent`, `MessageStatusType`, `RecipientKeywordEvent`, `UnrecognizedEvent`, `WebhookProvider`, `KeywordMatch`, `WebhookCommonOptions`, `SignedUrlOptions`, `WebhookVerification`, the six per-provider option types, `BandwidthWebhookCredentials`, `SinchWebhookCredentials`, and `WebhookFailureReason`.
