Inbound SMS
Receive replies to your numbers as verified message.received events, with sender, recipient, text, and media URLs.
Point the provider’s inbound webhook at your server and parse each request with parseSmsWebhook(). Every text sent to your number becomes a message.received event.
1. Configure the inbound URL
Inbound messages go to the URL set on the receiving number or its group. A per-message webhookUrl only affects status events.
| Provider | Where to set it |
|---|---|
| Twilio | The phone 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 documents that Basic auth fails with 401 for linked numbers. |
2. Parse the message
import { parseSmsWebhook, WebhookSignatureError } from "@opencoredev/sms-sdk/webhooks";
import { db } from "./db";
export async function POST(request: Request): Promise<Response> {
try {
const event = await parseSmsWebhook({
provider: "plivo",
request,
publicUrl: "https://example.com/webhooks/sms/plivo",
credentials: { authToken: process.env.PLIVO_AUTH_TOKEN ?? "" },
});
if (event.type === "message.received") {
await db.inbox.save({ from: event.from, to: event.to, body: event.body, providerId: event.providerId });
console.log(`Reply from ${event.from} with ${event.mediaUrls.length} attachment(s)`);
}
return new Response(null, { status: 200 });
} catch (error) {
if (error instanceof WebhookSignatureError) {
return new Response("Invalid signature", { status: 401 });
}
throw error;
}
}
Plivo signs the URL it called, so the example passes publicUrl. See Webhook security.
A message.received event has:
| Field | Value |
|---|---|
providerId |
The provider’s ID for the inbound message. |
from |
The sender’s number. Vonage numbers, which arrive without +, are normalized to E.164. |
to |
Your number that received it. |
body |
The text, as sent. |
mediaUrls |
Attached media from Twilio MediaUrl0…, Telnyx media[].url, or Plivo Media0…. Always empty for Vonage. |
dedupeKey, raw, occurredAt |
See Webhook events. |
Keywords become opt-out events
When the whole message is a keyword such as STOP, UNSUBSCRIBE, or HELP, you get recipient.opted_out or recipient.help instead of message.received. Handle those too, or turn detection off with detectKeywords: false. See STOP, HELP, and opt-outs.
Reply to a message
SMS SDK never replies automatically. To answer, send a new message from the number that was texted:
import { isE164 } from "@opencoredev/sms-sdk";
import { parseSmsWebhook } from "@opencoredev/sms-sdk/webhooks";
import { sms } from "./sms";
export async function POST(request: Request): Promise<Response> {
const event = await parseSmsWebhook({
provider: "telnyx",
request,
credentials: { publicKey: process.env.TELNYX_PUBLIC_KEY ?? "" },
});
if (event.type === "message.received" && isE164(event.from) && isE164(event.to)) {
await sms.send({
to: event.from,
from: event.to,
body: "Thanks, we got your message.",
idempotencyKey: `reply:${event.dedupeKey}`,
});
}
return new Response(null, { status: 200 });
}
Deriving the idempotency key from the inbound dedupeKey means a repeated webhook cannot send a second reply, as long as the client has an idempotency store. Catch WebhookSignatureError here as in the first example.
Twilio also lets you reply by returning TwiML from the webhook. SMS SDK does not build TwiML. If you do not want Twilio to reply, return an empty <Response></Response> document with Content-Type: text/xml.
Limitations
- SMS SDK is not a conversation store. It does not thread messages, keep history, or manage an inbox.
- Inbound MMS media URLs point at provider storage. They may expire, and fetching them may need provider credentials.
- Vonage inbound requires a number linked to a Vonage application. See Vonage.