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.reasonismissing_signature,invalid_signature,stale_timestamp,body_hash_mismatch,malformed_signature, orinvalid_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 NOTHINGthat 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.