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

How to send SMS through an API in TypeScript

Send an SMS from TypeScript with one HTTP request to Twilio, Telnyx, Plivo, Vonage, Bandwidth, or Sinch, then add a protected route, idempotency, and delivery webhooks.

Every SMS API works the same way underneath. Your server sends one authenticated POST with a recipient, a sender, and the text. The provider answers with a message ID, and it can report later, through a webhook, whether the text reached the phone.

This post shows that request for six providers, a working example with fetch, and the five things a production send needs on top of it. The last part does the same with SMS SDK, the open-source TypeScript library this site documents. It calls your own provider account; it isn’t a carrier or an SMS provider, so you still need an account with one of them.

The short answer

To send an SMS through an API from TypeScript:

  1. Get an API credential and a sending number from an SMS provider.
  2. On your server, POST the recipient’s number (E.164, such as +14155550123), your sender, and the text to the provider’s message endpoint.
  3. Save the message ID from the response. A success response means the provider accepted the message, not that it was delivered.
  4. Receive the provider’s status webhook to learn whether it was delivered.
npm install @opencoredev/sms-sdk
pnpm add @opencoredev/sms-sdk
yarn add @opencoredev/sms-sdk
bun add @opencoredev/sms-sdk
nub add @opencoredev/sms-sdk
aube add @opencoredev/sms-sdk

The request for each provider

These are the endpoints the SMS SDK adapters call, with the ID each provider returns on success:

Provider Endpoint Auth
Twilio POST https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages.json Basic: Account SID and Auth Token, or API key SID and secret
Telnyx POST https://api.telnyx.com/v2/messages Bearer API key
Plivo POST https://api.plivo.com/v1/Account/{auth_id}/Message/ Basic: Auth ID and Auth Token
Vonage POST https://api.nexmo.com/v1/messages (Messages API) JWT, or Basic with API key and secret (no delivery webhooks with Basic)
Bandwidth POST https://messaging.bandwidth.com/api/v2/users/{userId}/messages Basic: API token and secret
Sinch POST https://sms.api.sinch.com/xms/v1/{servicePlanId}/batches Bearer API token

What goes in the body, and the ID to save from a success response:

Provider Body ID to save
Twilio Form-encoded To, From, Body sid
Telnyx JSON to, from, text data.id
Plivo JSON src, dst, text message_uuid[0]
Vonage JSON message_type: "text", channel: "sms", to, from, text message_uuid
Bandwidth JSON to (a list), from, text, applicationId id
Sinch JSON to (a list), from, body batch id

Each provider page walks through its raw request and response in full, including how it signs webhooks. Pricing, sender registration, and which countries each one reaches are on the providers’ own sites.

Send one with fetch

Telnyx takes JSON and a bearer token, so a send is a single fetch call. Node.js 18 and later, Bun, and Deno all have fetch built in:

const apiKey = process.env.TELNYX_API_KEY;
const from = process.env.TELNYX_FROM;
if (!apiKey || !from) throw new Error("Set TELNYX_API_KEY and TELNYX_FROM.");

const response = await fetch("https://api.telnyx.com/v2/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
    Accept: "application/json",
  },
  body: JSON.stringify({ from, to: "+14155550123", text: "Your order has shipped." }),
});

const payload: unknown = await response.json().catch(() => null);
const data =
  typeof payload === "object" && payload !== null && "data" in payload ? payload.data : undefined;
const id = typeof data === "object" && data !== null && "id" in data ? data.id : undefined;

console.log(response.status, id);

A 2xx response with an ID means Telnyx accepted the message. Save that ID: every status webhook for the message carries it.

That is enough for a script that texts you. It isn’t enough for an app that texts customers.

What a production send needs

1. A route nobody can abuse

Credentials stay on the server, and so does the decision about who gets a text. A route that takes a phone number and a message from the request body is an open relay: anyone who finds it can send any text to any number on your account, and you pay for every message. Take the recipient from your own data, behind your own auth, and add a per-user rate limit.

2. A valid number before you call

Store and check numbers in E.164: a +, the country code, and the number, with no spaces. Check the format before the request so a bad number fails in your code with a clear message instead of as a provider error. Most of these APIs take the number with its +; Vonage’s Messages API takes the digits without it.

3. One key per message

Job queues retry, users double-click, and serverless functions run twice. Without protection, each of those sends another text. Give every message a stable key, such as order:123:shipped:v1, and remember which keys already sent. Don’t rely on the provider for this: support for a provider-side idempotency key varies, and SMS SDK sends none to any of these six APIs, so keep your own record.

4. A timeout means “unknown”, not “failed”

When the request times out, the connection drops, or the provider answers 5xx, the provider may already have the message. Sending again, or trying a second provider, can text the person twice. Record the outcome as unknown and look the message up with the provider before resending. Only a 4xx with the provider’s error code proves the message was refused. SMS fallback without double-texting goes through which errors are safe to retry.

5. Delivery comes later, by webhook

The send response says the provider took the message. Whether the phone got it arrives minutes later as a webhook: delivered, undelivered, or filtered by the carrier. Check that each webhook really came from the provider before you trust it, because the URL is public. Most providers sign it, each differently: HMAC-SHA1 for Twilio, Ed25519 for Telnyx, HMAC-SHA256 for Plivo, and a signed JWT for Vonage. Bandwidth doesn’t sign callbacks; it sends Basic credentials you set, and Sinch uses Basic credentials or an HMAC secret. See Webhook security.

US numbers also need registration (10DLC or toll-free verification) before carriers deliver their texts. An unregistered number can get a success response and then fail at the carrier. See Registration and consent.

The same send with SMS SDK

SMS SDK puts those rules into one client. Each provider is an adapter, and the rest of your code calls sms.send() the same way whichever provider is behind it.

Create the client once, on the server:

import { createSmsClient, isE164, memoryIdempotencyStore } from "@opencoredev/sms-sdk";
import { telnyx } from "@opencoredev/sms-sdk/telnyx";

const from = process.env.TELNYX_FROM;
if (!isE164(from)) throw new Error("Set TELNYX_FROM to your Telnyx number, such as +15550100001.");

export const sms = createSmsClient({
  adapters: [telnyx({ apiKey: process.env.TELNYX_API_KEY ?? "", from })],
  idempotency: { store: memoryIdempotencyStore() },
});

memoryIdempotencyStore() keeps keys in one process, for 24 hours unless you set ttlSec. With more than one server or a serverless host, use a shared store; see Prevent duplicate sends.

Then send from a route that decides the recipient itself. This one is a Next.js route handler; the same function body works in Hono or Bun.serve:

import { HandoffUnknownError, isE164, isSmsError } from "@opencoredev/sms-sdk";
import { getSession } from "./auth";
import { db } from "./db";
import { sms } from "./sms";

export async function POST(request: Request): Promise<Response> {
  const session = await getSession(request);
  if (session === null) return new Response("Unauthorized", { status: 401 });

  const input: unknown = await request.json().catch(() => null);
  const orderId = typeof input === "object" && input !== null && "orderId" in input ? input.orderId : undefined;
  if (typeof orderId !== "string") return new Response("orderId is required", { status: 400 });

  const order = await db.orders.findForUser(orderId, session.userId);
  if (order === null || !isE164(order.phone)) return new Response("Order not found", { status: 404 });
  if (order.status !== "shipped") return new Response("Order has not shipped", { status: 409 });
  // Add your per-user rate limit here: the key stops repeats of one order, not texts for many orders.

  const idempotencyKey = `order:${order.id}:shipped:v1`;
  try {
    const result = await sms.send({ to: order.phone, body: `Order ${order.id} has shipped.`, idempotencyKey });
    await db.messages.saveSent({
      id: result.id,
      provider: result.provider,
      providerId: result.providerId,
      to: order.phone,
      status: result.delivery,
    });
    return Response.json({ id: result.id, delivery: result.delivery });
  } catch (error) {
    if (error instanceof HandoffUnknownError) {
      // The provider may have the message. Never resend automatically.
      await db.messages.markNeedsReview(idempotencyKey, error.reason);
      return Response.json({ status: "unknown" }, { status: 202 });
    }
    if (isSmsError(error)) return Response.json({ code: error.code }, { status: 422 });
    throw error;
  }
}

What the client handles for you in that call:

  • It rejects a malformed to before any request.
  • It turns each provider’s response into one of three outcomes: accepted (send() resolves), refused (an error with a stable code), or unknown (HandoffUnknownError).
  • It never retries or falls back after an unknown outcome.
  • With a store, sending the same message with the same key again returns the first result without a request, for as long as the record is kept: 24 hours by default (idempotency.ttlSec). If the first send ended unknown, the repeat throws HandoffUnknownError instead of sending. Set the TTL longer than your longest retry window.
  • It reports the delivery status from the send response, usually queued. Later changes arrive by webhook.

To switch to Twilio, Plivo, Vonage, Bandwidth, or Sinch, change the adapter in sms.ts. Check the provider’s page for anything its webhooks need: Sinch, for example, sends a status webhook per message only when you set providerOptions: { sinch: { deliveryReport: "per_recipient" } }, and Vonage needs JWT auth. To keep a second provider for when the first one refuses, list both adapters and set fallback: "on-known-rejection"; see Fallback and retries. For delivery updates, Receive webhooks has one verified handler for all six providers.

Test it without sending texts

Swap the adapter for memory() in tests. It records each send and never calls a network:

import { expect, test } from "bun:test";
import { createSmsClient, memoryIdempotencyStore } from "@opencoredev/sms-sdk";
import { memory } from "@opencoredev/sms-sdk/testing";

test("sends the shipping text once per order", async () => {
  const adapter = memory();
  const sms = createSmsClient({ adapters: [adapter], idempotency: { store: memoryIdempotencyStore() } });

  await sms.send({ to: "+14155550123", body: "Order 123 has shipped.", idempotencyKey: "order:123:shipped:v1" });
  await sms.send({ to: "+14155550123", body: "Order 123 has shipped.", idempotencyKey: "order:123:shipped:v1" });

  expect(adapter.sent).toHaveLength(1); // the second call replayed the first result
});

The second send() returns the stored result with replayed: true and makes no request. Test without a provider covers scripted refusals, timeouts, and signed webhook requests.

FAQ

Which SMS API should I use?

It depends on where you send, which sender types you need, and pricing, which change often; compare them on the providers’ own sites. Choose a provider lists what each SMS SDK adapter supports. Because the adapter is one line, you can start with one provider and switch later.

Can I send SMS from the browser?

No. Calling an SMS API from the browser exposes your credentials to every visitor. Send from a server route and let the browser call that route.

Is there a free SMS API?

Check each provider’s own site for trial terms; they differ and change. For development you don’t need one: the memory() adapter records sends, costs nothing, and sends nothing.

Does a 200 or 201 response mean the text was delivered?

No. It means the provider accepted the message. Delivery is reported later through a status webhook, and can still fail at the carrier, for example when the sending number isn’t registered.

Last updated on