Telnyx
Send and receive SMS and MMS through the Telnyx Messaging API v2 with the SMS SDK telnyx() adapter: setup, senders, webhooks, errors, and limits.
The telnyx() adapter sends SMS and MMS through the Telnyx Messaging API v2 and parses Telnyx’s Ed25519-signed messaging webhooks.
To compare providers, see the Providers overview.
Support status
Supported. Request fields, responses, the error catalog, and Ed25519 webhook signing follow Telnyx’s official documentation and its official Node SDK source. The adapter passes the shared contract tests.
Installation
npm install @opencoredev/sms-sdkpnpm add @opencoredev/sms-sdkyarn add @opencoredev/sms-sdkbun add @opencoredev/sms-sdknub add @opencoredev/sms-sdkaube add @opencoredev/sms-sdkimport { telnyx } from "@opencoredev/sms-sdk/telnyx";
Environment variables
| Variable | Required | Used for |
|---|---|---|
TELNYX_API_KEY |
Yes | API v2 key, sent as a Bearer token. |
TELNYX_FROM |
Unless you send from a profile | Default sender: a Telnyx number, short code, or alphanumeric sender ID. |
TELNYX_MESSAGING_PROFILE_ID |
For alphanumeric senders | Messaging profile ID sent with every message. |
TELNYX_PUBLIC_KEY |
For webhooks | Base64 Ed25519 public key from Mission Control, Keys & Credentials, Public Key. |
The examples and the doctor CLI use these names. The adapter itself reads no environment variables. Keep the API key on the server.
Basic usage
import { createSmsClient } from "@opencoredev/sms-sdk";
import { telnyx } from "@opencoredev/sms-sdk/telnyx";
const sms = createSmsClient({
adapters: [telnyx({ apiKey: process.env.TELNYX_API_KEY ?? "", from: "+15550100001" })],
});
const result = await sms.send({ to: "+14155550123", body: "Your order has shipped." });
console.log(result.providerId); // the Telnyx message ID (data.id)
telnyx() throws ConfigurationError at startup when apiKey is empty.
Authentication
Each request carries an API v2 key as Authorization: Bearer <apiKey>. Create keys in Mission Control under API Keys. Webhooks are verified with a separate public key, not the API key.
Configuration
| Option | Type | Default | Meaning |
|---|---|---|---|
apiKey |
string |
required | API v2 key. |
from |
SmsFrom |
none | Default sender. { messagingService: profileId } sends from the profile’s number pool. |
messagingProfileId |
string |
none | Sent as messaging_profile_id with every message. Required for alphanumeric senders. |
baseUrl |
string |
https://api.telnyx.com |
API origin, for a proxy or a mock server. |
fetch |
FetchLike |
global fetch |
Custom fetch. |
API version and endpoint
POST https://api.telnyx.com/v2/messagesAuthorization: Bearer <apiKey>, JSON body.- Fields sent:
to,fromormessaging_profile_id,text,media_urlswithtype: "MMS",webhook_url(fromwebhookUrl),send_at(fromsendAt), and any provider options. - Success: a 2xx response with
data.id. Delivery comes fromdata.to[0].status, usuallyqueued. A 2xx withoutdata.idis an unknown outcome.
Senders
| Sender | Write | Sent as |
|---|---|---|
| Long code or toll-free number | "+15550100001" |
from |
| Short code | { shortCode: "12345" } |
from |
| Alphanumeric sender ID | { senderId: "Acme" } |
from, plus messaging_profile_id from the adapter |
| Messaging profile number pool | { messagingService: "<profile id>" } |
messaging_profile_id, with no from |
An alphanumeric sender without messagingProfileId on the adapter fails local validation before any request. In Telnyx, assign each number to a messaging profile before it sends.
Regions and countries
Telnyx documents these rules in Choosing a sender type:
- 10DLC brand and campaign registration is required for A2P messaging to US mobile numbers.
- Toll-free numbers need verification, and they work for both US and Canada destinations.
- Short codes need carrier approval and work in one country. A US short code does not reach Canada.
- Alphanumeric senders can’t send to the US, Canada, or Puerto Rico. They are one-way, so recipients can’t reply.
Some countries, such as the UK and France, require alphanumeric sender IDs to be registered in advance. Telnyx’s international SMS compliance guide covers country rules.
SMS SDK does not check these rules before sending, and they have not been tested against live accounts. See Sender registration and consent.
Capabilities
| Capability | Telnyx adapter |
|---|---|
| MMS | Yes |
| Scheduling | Yes, any sender |
| Validity period | No. Telnyx’s request has no such field. |
Per-message webhookUrl |
Yes |
| Delivery status webhooks | Yes |
| Inbound webhooks | Yes |
| Native idempotency | No |
Provider options
Pass Telnyx’s other POST /v2/messages fields in providerOptions.telnyx:
import { createSmsClient } from "@opencoredev/sms-sdk";
import { telnyx } from "@opencoredev/sms-sdk/telnyx";
const sms = createSmsClient({
adapters: [telnyx({ apiKey: process.env.TELNYX_API_KEY ?? "", from: "+15550100001" })],
});
await sms.send({
to: "+14155550123",
body: "Your order has shipped.",
webhookUrl: "https://example.com/webhooks/sms/telnyx",
providerOptions: {
telnyx: { encoding: "gsm7", webhookFailoverUrl: "https://backup.example.com/webhooks/sms/telnyx" },
},
});
| Option | Telnyx field | Type |
|---|---|---|
subject |
subject |
string, the subject of an MMS |
webhookFailoverUrl |
webhook_failover_url |
http or https URL |
useProfileWebhooks |
use_profile_webhooks |
boolean, Telnyx default true |
autoDetect |
auto_detect |
boolean, Telnyx default false |
encoding |
encoding |
"auto" | "gsm7" | "ucs2". With "gsm7", Telnyx answers 400 if a character has no GSM-7 form. |
extra |
any other field | Record<string, string | number | boolean>, sent as is |
Unknown keys, wrong types, and extra keys that name a field SMS SDK sets throw before any request. The options go out only when the telnyx adapter sends. See providerOptions for fallback and idempotency.
SMS SDK sets to, from, messaging_profile_id, text, media_urls, type, webhook_url, and send_at itself, so extra cannot.
Example: send and track delivery
import { createSmsClient, isE164, isSmsError } from "@opencoredev/sms-sdk";
import { telnyx } from "@opencoredev/sms-sdk/telnyx";
const from = process.env.TELNYX_FROM;
const to = process.env.SMS_EXAMPLE_TO;
if (!isE164(from) || !isE164(to)) {
throw new Error("Set TELNYX_FROM and SMS_EXAMPLE_TO to E.164 numbers.");
}
const sms = createSmsClient({
adapters: [telnyx({ apiKey: process.env.TELNYX_API_KEY ?? "", from })],
});
try {
const result = await sms.send({
to,
body: "Your order has shipped.",
idempotencyKey: "order:123:shipped:v1",
webhookUrl: "https://example.com/webhooks/sms/telnyx",
});
console.log(`Telnyx accepted ${result.providerId} (${result.delivery})`);
} catch (error) {
if (isSmsError(error)) console.error(error.toJSON());
throw error;
}
You see Telnyx accepted <uuid> (queued). Acceptance is not delivery. Telnyx posts message.sent and message.finalized events to the webhookUrl, covered in Delivery status webhooks.
Delivery and inbound webhooks
- Status: pass
webhookUrlper message, or set the webhook URL on the messaging profile. - Inbound: set the inbound webhook URL on the messaging profile the number belongs to.
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: "telnyx",
request,
credentials: { publicKey: process.env.TELNYX_PUBLIC_KEY ?? "" },
});
if (event.type === "message.delivered" || event.type === "message.undelivered") {
await db.messages.updateStatus(event.providerId, event.type, event.errorCode);
} else if (event.type === "recipient.opted_out") {
await db.suppressions.add(event.from);
}
return new Response(null, { status: 200 });
} catch (error) {
if (error instanceof WebhookSignatureError) return new Response("Invalid signature", { status: 401 });
throw error;
}
}
SMS SDK maps Telnyx’s message.received, message.sent, and message.finalized events. Status comes from data.payload.to[0].status: queued and sending become message.queued; sent and delivery_unconfirmed become message.sent; delivered becomes message.delivered; sending_failed, delivery_failed, and expired become message.undelivered, or message.filtered with error 40002, 40003, or 40322. Other event types and statuses, such as read, become unrecognized.
dedupeKey is telnyx:{data.id}, the webhook event ID. providerId is the message ID, data.payload.id, which matches result.providerId.
Telnyx sends no opt-out flag on inbound messages, so STOP and HELP come from keyword detection, with source: "keyword".
Signature verification
Telnyx signs each webhook with two headers, telnyx-timestamp in Unix seconds and telnyx-signature-ed25519 in base64. The signature is Ed25519 over `${timestamp}|${rawBody}`. SMS SDK verifies it with your account’s public key through Web Crypto.
- A timestamp more than 300 seconds before or after now fails with
stale_timestamp. Change the window withtoleranceSec. - The URL is not signed, so proxies do not affect verification and there is no
publicUrloption. - A public key that is not 32 bytes of base64 fails with
invalid_credentials.
Error mapping
| Telnyx response | Category | Falls back |
|---|---|---|
| 10009, 10010, 20001, 20002, 20003, 20006, 20008; 401 or 403 without a listed code | auth |
Yes |
| 10011, 40318 (queue full) | rate_limited |
Yes, after retries |
| 40305, 40306, 40308, 40315, 40320, 40321, 40329, 40330 | sender |
Yes |
| 20013, 20100, 40309, 40312, 40314, 40331, 40333 (spend limit) | account |
Yes |
| 40301, 40310, 40319 | recipient |
No |
| 40300 (blocked due to STOP), 40322 (blocked content) | compliance |
Never |
| Any other 4xx | request |
No |
| 429 without code 10011 or 40318 | unknown | Never |
5xx, network error, timeout, 2xx without data.id |
unknown | Never |
The code comes from errors[0].code in the response. Telnyx does not document a Retry-After header, but SMS SDK honors one if present.
Limitations
- No
validityPeriodSec. For an expiry, use Twilio, Plivo, or Vonage. - Alphanumeric senders need a messaging profile ID on the adapter.
- Error 40008 is not mapped to
message.filtered, because Telnyx’s pages disagree on its meaning. It staysmessage.undelivered.
Testing
Point telnyx() at a fake API with mockFetch:
import { createSmsClient } from "@opencoredev/sms-sdk";
import { telnyx } from "@opencoredev/sms-sdk/telnyx";
import { mockFetch } from "@opencoredev/sms-sdk/testing";
const fake = mockFetch({
status: 200,
body: { data: { id: "40385f64-5717-4562-b3fc-2c963f66afa6", to: [{ phone_number: "+14155550123", status: "queued" }] } },
});
const sms = createSmsClient({ adapters: [telnyx({ apiKey: "test", from: "+15550100001", fetch: fake.fetch })] });
const result = await sms.send({ to: "+14155550123", body: "Hi" });
console.log(result.providerId, JSON.parse(fake.calls[0]?.body ?? "{}")); // the id, and the JSON request body
Test your webhook handler with real signatures from generateTelnyxKeyPair() and signedTelnyxRequest(). See Local testing.
The adapter’s own tests live in the SMS SDK repository. From packages/sms-sdk:
bun test test/contracts/telnyx.test.ts test/webhooks/telnyx.test.ts
The live test sends one real, billable message to each configured provider. It is skipped unless you opt in:
LIVE_SMS_TESTS=true LIVE_SMS_TO=+14155550123 LIVE_SMS_ALLOWLIST=+14155550123 \
TELNYX_API_KEY=... TELNYX_FROM=+15550100001 \
bun test test/integration/live.test.ts
Check your configuration first with npx @opencoredev/sms-sdk doctor --adapter telnyx.
API reference
telnyx(options: TelnyxOptions): SmsAdapter
type TelnyxOptions = {
apiKey: string;
from?: SmsFrom;
messagingProfileId?: string;
baseUrl?: string;
fetch?: FetchLike;
};
Also exported from @opencoredev/sms-sdk/telnyx: TELNYX_CAPABILITIES, TELNYX_PROVIDER_OPTIONS, buildTelnyxRequest(message, messagingProfileId), interpretTelnyxResponse(status, text, headers), telnyxRejectionCategory(status, code), telnyxDelivery(status), and the types TelnyxOptions, TelnyxMessageRequest, TelnyxSendOptions.
Official documentation
Checked on 2026-10-08. The full evidence list is in PROVIDERS.md.
- Send a message
- Receiving messaging webhooks
- Webhook delivery and signing
- API errors
- Official Node SDK webhook verification