---
title: "Send a message"
description: "Call sms.send(), read the result, handle errors by whether a retry is safe, choose a sender, and use MMS, scheduling, and provider options."
---

Send a text, read the result, and handle failures without texting anyone twice. No client yet? Start with the [Quick start](/getting-started/quick-start).

## Send and read the result

```ts
import { db } from "./db";
import { sms } from "./sms";

const result = await sms.send({
  to: "+14155550123",
  body: "Your order #123 has shipped.",
  idempotencyKey: "order:123:shipped:v1",
});

await db.messages.create({
  id: result.id,
  provider: result.provider,
  providerId: result.providerId,
  to: "+14155550123",
  status: result.delivery,
});
```

`send()` resolves only when a provider accepts the message. The fields you will use most:

| Field | What it holds |
| --- | --- |
| `id` | The SDK's ID for this message. Stable across idempotent replays. |
| `provider` | The adapter that accepted it. |
| `providerId` | The provider's message ID. Store it so status [webhooks](/receiving/webhooks) can find the message. |
| `delivery` | Status from the send response, usually `"queued"`. Delivery to the phone arrives later by webhook. |

The result also has segment counts, every attempt made, and whether it was replayed. All fields are in the [send() reference](/reference/send#returns-smssendresult).

## Handle errors

Every error extends `SmsError` and has a stable `code` and a `retrySafe` flag. `retrySafe: true` means the provider did not take the message, so sending again cannot create a duplicate. It does not promise the retry will work.

```ts
import {
  HandoffUnknownError,
  InvalidRecipientError,
  isSmsError,
  PolicyRejectedError,
  ProviderRejectedError,
} from "@opencoredev/sms-sdk";
import { db } from "./db";
import { sms } from "./sms";

export async function notifyShipped(orderId: string, to: `+${string}`): Promise<void> {
  const idempotencyKey = `order:${orderId}:shipped:v1`;
  try {
    await sms.send({ to, body: `Order ${orderId} has shipped.`, idempotencyKey });
  } catch (error) {
    if (error instanceof HandoffUnknownError) {
      // The provider may have the message. Do not resend automatically.
      await db.messages.markNeedsReview(idempotencyKey, error.reason);
      return;
    }
    if (error instanceof InvalidRecipientError || error instanceof PolicyRejectedError) {
      return; // Fix the data or respect the policy. A retry would fail the same way.
    }
    if (error instanceof ProviderRejectedError && error.category === "compliance") {
      await db.suppressions.add(to); // The recipient opted out with the provider.
      return;
    }
    if (isSmsError(error) && !error.retrySafe) {
      await db.messages.markNeedsReview(idempotencyKey, error.code);
      return;
    }
    throw error; // Retry safe: let your job queue try again later.
  }
}
```

:::warning[Never resend after HandoffUnknownError]
After a timeout, network error, 5xx, or malformed success response, the provider may have created the message, so the SDK stops and throws `HandoffUnknownError`. Check the provider's console or API, or wait for a status webhook, before you send again. With an idempotency store, a later send with the same key throws it again instead of sending.
:::

[Errors](/reference/errors) lists every error class, code, and what to do about it. To block sends yourself, for example to people who opted out, see [Block and log sends](/advanced/policy-and-hooks).

## Choose a sender

`to` must be E.164: a `+`, a country code, and up to 15 digits, with no spaces. `+14155550123` is valid; `(415) 555-0123` is not. The SDK checks this before any request. Narrow user input with `isE164`:

```ts
import { isE164 } from "@opencoredev/sms-sdk";
import { sms } from "./sms";

const input: string = "+14155550123"; // for example, from a form field

if (!isE164(input)) {
  throw new Error("Enter the number with its country code, such as +14155550123.");
}

await sms.send({ to: input, body: "Thanks for signing up." });
```

`isE164` checks format only. To convert national formats, use a phone number library.

`from` is optional when the adapter has a default sender. It takes one of four shapes:

| You send from | Write |
| --- | --- |
| A long code or toll-free number | `"+15550100001"` |
| A short code | `{ shortCode: "12345" }` |
| An alphanumeric sender ID | `{ senderId: "Acme" }` |
| A provider sender pool, such as a Twilio Messaging Service | `{ messagingService: "MG…" }` |

Which providers support each shape is in [Sender types](/providers/overview#sender-types), and the formats the SDK checks are in the [send() reference](/reference/send#from).

Set a default with the adapter's `from` option, such as `twilio({ ..., from: { messagingService: "MG…" } })`, and override it for one message:

```ts
import { sms } from "./sms";

await sms.send({ to: "+447700900123", from: { senderId: "Acme" }, body: "Your parcel is out for delivery." });
```

A sender the first adapter cannot use throws `UnsupportedFieldError` before any request. Whether your account may use a sender (ownership, 10DLC or toll-free registration, country rules) is checked by the provider; see [Registration and consent](/providers/registration-and-consent).

## More options

| Field | What it does | Details |
| --- | --- | --- |
| `mediaUrls` | Sends MMS with images or files at public `http`/`https` URLs. | [`mediaUrls`](/reference/send#mediaurls) |
| `sendAt` | The provider holds the message and sends it at this `Date`. | [`sendAt`](/reference/send#sendat) |
| `validityPeriodSec` | How long the provider keeps trying before the message expires. | [`validityPeriodSec`](/reference/send#validityperiodsec) |
| `webhookUrl` | Sends this message's status updates to a different URL. | [`webhookUrl`](/reference/send#webhookurl) |

```ts
import { sms } from "./sms";

await sms.send({
  to: "+14155550123",
  body: "Here is your boarding pass. It is valid for 10 minutes.",
  mediaUrls: ["https://example.com/passes/abc123.png"],
  validityPeriodSec: 600,
});
```

Each of these works only on some providers; see [Capabilities](/providers/overview#capabilities), or call `sms.capabilities()` at runtime. If the adapter cannot send a field, the SDK throws `UnsupportedFieldError` before any request instead of dropping it.

## Provider options

`providerOptions` passes provider parameters the shared fields do not cover, keyed by adapter name. An entry applies only when that adapter sends.

```ts
import { sms } from "./sms";

await sms.send({ to: "+14155550123", body: "Your order “123” shipped.", providerOptions: { twilio: { smartEncoded: true } } });
```

Here Twilio replaces the curly quotes with plain ones. Typed options, the `extra` escape hatch, and the validation rules are in the [send() reference](/reference/send#provideroptions).

## Cancel a send

Pass an `AbortSignal` to stop waiting. If no request had started, `send()` throws `SendAbortedError` and it is safe to send later. If the request was in flight, it throws `HandoffUnknownError` with `reason: "aborted"`, because the provider may already have the message.

```ts
import { sms } from "./sms";

await sms.send({ to: "+14155550123", body: "Reminder" }, { signal: AbortSignal.timeout(2_000) });
```

## Next steps

- [Fallback and retries](/sending/fallback-and-retries)
- [Prevent duplicate sends](/sending/idempotency)
- [send() reference](/reference/send)
