---
title: "How to send SMS through an API in TypeScript"
type: blog
date: 2026-10-31
description: "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.

```package-install
npm install @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](/providers/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](/providers/telnyx) | `POST https://api.telnyx.com/v2/messages` | `Bearer` API key |
| [Plivo](/providers/plivo) | `POST https://api.plivo.com/v1/Account/{auth_id}/Message/` | Basic: Auth ID and Auth Token |
| [Vonage](/providers/vonage) | `POST https://api.nexmo.com/v1/messages` (Messages API) | JWT, or Basic with API key and secret (no delivery webhooks with Basic) |
| [Bandwidth](/providers/bandwidth) | `POST https://messaging.bandwidth.com/api/v2/users/{userId}/messages` | Basic: API token and secret |
| [Sinch](/providers/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:

```ts
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](/blog/sms-fallback-without-duplicate-texts) 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](/receiving/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](/providers/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:

```ts sms.ts
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](/sending/idempotency).

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`:

```ts route.ts
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](/sending/fallback-and-retries). For delivery updates, [Receive webhooks](/receiving/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:

```ts
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](/testing/local-testing) 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](/providers/overview) lists what each SMS SDK adapter supports. Because the adapter is one line, you can start with one provider and [switch later](/migration/switch-providers).

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

## Related

- [Send a message](/sending/send-a-message): every `send()` option, sender types, and error handling.
- [Twilio alternatives for developers](/blog/twilio-alternative): what changes when you switch SMS providers.
- [SMS fallback without double-texting](/blog/sms-fallback-without-duplicate-texts): which errors are safe to fall back on.
- [SMS segment calculator](/tools/sms-segment-calculator): how many segments a message costs before you send it.
- [SMS opt-out handling for developers](/blog/sms-opt-out-handling): what to do when someone replies STOP.
