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

Webhooks and events

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

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.

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, 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.

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

{ 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.

Status events

{
  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.

Opt-out and help events

{
  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.

unrecognized

{ 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.

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:

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 ?.

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.

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.