---
title: "Message length and cost"
description: "Check a message with sms.validate() before you send, see how GSM-7 and UCS-2 change the segment count, and why the estimate is not a price."
---

Providers bill SMS per segment, not per message. `sms.validate()` tells you how many segments a message uses and whether it would be refused, before you send. It runs locally, costs nothing, and never contacts a provider.

## Check a message

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

const check = sms.validate({
  to: "+14155550123",
  body: "Your package is ready 📦",
});

console.log(check.supported); // true
console.log(check.encoding, check.segments); // "ucs2" 1
console.log(check.nonGsmCharacters); // ["📦"]
console.log(check.issues); // []
```

`supported` is `true` when the first adapter can send the message and nothing is wrong with it. The emoji forces UCS-2 encoding, as `encoding` and `nonGsmCharacters` show.

When something is wrong, `issues` lists each problem with a `code`, the `field`, a readable `message`, and `provider` when it applies to one adapter:

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

const check = sms.validate({
  to: "+14155550123",
  body: "Your appointment is tomorrow.",
  sendAt: new Date("2026-12-01T09:00:00Z"),
});

for (const issue of check.issues) {
  console.log(`${issue.provider ?? "message"}: ${issue.field} ${issue.code}: ${issue.message}`);
}
```

With an adapter that cannot schedule, this prints one `unsupported_field` issue for `sendAt`, and `supported` is `false`.

With several adapters, `validate()` checks each one. `adapterCandidates` lists the adapters that could send the message, in order, and `issues` holds the problems from the rest. `supported` still reflects only the first adapter, because `send()` throws when the first adapter cannot send the message, even with fallback on.

[validate() reference](/reference/validate) has every issue code and the full result shape. Validation checks format and declared capabilities only. It cannot see whether your sender is registered, whether the country accepts your sender type, whether the recipient opted out with the provider, or your balance. The provider reports those when you send.

## GSM-7 and UCS-2

A message uses GSM-7 when every character is in the GSM 03.38 alphabet or its extension table. Otherwise the whole message uses UCS-2, which fits fewer characters per segment.

| Encoding | Counts | One segment holds | Each part of a longer message holds |
| --- | --- | --- | --- |
| GSM-7 | Septets | 160 | 153 |
| UCS-2 | UTF-16 code units | 70 | 67 |

A longer message loses a few units per part to the header that joins the parts. So 161 GSM-7 septets cost 2 segments.

The SDK also applies these rules:

- Extension characters count 2 septets: form feed, `^`, `{`, `}`, `\`, `[`, `~`, `]`, `|`, and `€`. Eighty `€` signs fill one segment.
- An extension character's two septets never split across segments.
- Most emoji count 2 code units in UCS-2, and they never split across segments.

## Why one emoji changes the count

One character outside GSM-7 switches the whole message to UCS-2. That lowers the limit from 160 to 70, so a message that fit easily can suddenly need more segments.

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

const body = "Hi Sam, your order #4821 has shipped and should arrive Thursday. Reply STOP to opt out.";

console.log(estimateSegments(body).segments); // 1 (gsm7, 87 septets)
console.log(estimateSegments(`${body} 🎉`).segments); // 2 (ucs2, 90 code units)
```

Curly quotes (`“ ”`), long dashes, and many accented letters do the same. The SDK never rewrites your text to stay in GSM-7. `nonGsmCharacters` lists what forced UCS-2, so you can change the text yourself.

`estimateSegments(body)` is also exported from `@opencoredev/sms-sdk/encoding`, and every `SmsSendResult` includes `encoding` and `segments`.

## The estimate is not a price

The count is how a standard handset would split the message. Your bill can differ:

- Some providers can replace characters such as curly quotes before sending, which lowers the count. Twilio does this with the `smartEncoded` [provider option](/sending/send-a-message#provider-options).
- Some toll-free and international routes split messages differently or have their own length limit. Twilio accepts at most 1600 characters and Bandwidth 2048, and the SDK checks both before sending.
- Providers set their own prices for MMS, carrier fees, failed messages, and each country, sender type, and contract.

Use the count to catch surprises, such as a template that grew past 160 characters or a stray emoji. Use your provider's pricing pages and invoices for money.

## Block long messages

`beforeSend` receives the segment estimate, so you can refuse messages longer than you expect:

```ts
import { createSmsClient } from "@opencoredev/sms-sdk";
import { twilio } from "@opencoredev/sms-sdk/twilio";

const sms = createSmsClient({
  adapters: [
    twilio({
      accountSid: process.env.TWILIO_ACCOUNT_SID ?? "",
      authToken: process.env.TWILIO_AUTH_TOKEN ?? "",
      from: "+15550100001",
    }),
  ],
  beforeSend: ({ segments, encoding }) =>
    segments > 3
      ? { kind: "reject", reason: `Message is ${segments} ${encoding} segments; the limit is 3.` }
      : { kind: "allow" },
});
```

A refused message throws `PolicyRejectedError` before any request, so nothing is billed. See [Block and log sends](/advanced/policy-and-hooks).

## Next steps

- [validate() reference](/reference/validate)
- [Block and log sends](/advanced/policy-and-hooks)
