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

Message length and cost

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

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:

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

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.
  • 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:

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.

Next steps