Skip to main content

SMS segments and encoding

SMS is the only Orbit channel where a single character can change what a message costs. The billable unit is the segment, and the segment count depends on which of two encodings the body fits into — plus two normalization steps that run before the counting starts. This page explains the decision, the capacity math, why the number you see in the composer is the same number billing uses, and the ceilings and controls that sit around all of it. Read it once before you price a campaign or investigate an unexpected SMS charge; the billing mechanics live in wallets, credits, and charges, and what happens to the segments after you send is in the delivery lifecycle.

Two encodings, one decision

Every SMS body leaves Orbit in exactly one of two encodings:
  • GSM-7 — the 7-bit GSM alphabet (3GPP TS 23.038): ASCII, plus characters like @, £, , and a Greek subset. Characters that fit here pack tightly, so each segment holds more.
  • UCS-2 — 16-bit Unicode. Any character outside the GSM-7 alphabet — an emoji, CJK or Cyrillic, most non-Latin scripts — forces the entire message into UCS-2.
The decision is single-bit and whole-message: one non-GSM-7 character flips the whole body to UCS-2. There is no mixed encoding on the wire. This is why a 160-character ASCII newsletter with one emoji appended does not get one UCS-2 surcharge — it becomes a fully UCS-2 message with per-segment capacity less than half of what it had. Counting is also effective, not visual:
  • In GSM-7, ten characters live on an escape sequence (^, {, }, \, [, ], ~, |, , form feed) and each consumes 2 septets — the escape byte plus the character.
  • In UCS-2, count UTF-16 code units, not rendered glyphs: characters outside the basic multilingual plane (emoji, some historic scripts) are surrogate pairs and consume 2 units each.
A “character” as your eye sees it is not always one character on the wire.

Capacity: what a segment holds

Two consequences worth internalizing:
  • A 1,600-character GSM-7 message is not billed at 160-per-segment; it is billed as ceil(1600 / 153) = 11 segments at the multi-part rate — which trips the hard cap below.
  • A message just under the single-part limit that grows past it silently drops to the multi-part limit and picks the segment up from 1 to 2. The 160 → 161 boundary is expensive.

The 10-segment ceiling

Orbit refuses to bill beyond 10 segments — 1,530 GSM-7 septets or 670 UCS-2 code units. A body that exceeds the cap is trimmed to it and the counter reports a truncated flag together with the trimmed body, so the send path can decide to send the shortened form or reject the message; nothing long disappears silently. Extended characters are charged up-front during the trim, so a GSM-7 escape sequence is never split from its character at the boundary.

One counter, everywhere: what is billed is what is sent

Segment math lives in exactly one algorithm, shared by every surface that touches it:
  • The billing path counts segments at send time with that counter. SMS and MMS price per segment — the send-time price gate multiplies your per-segment rate by the same segments value you read back on the response.
  • The compose counter in the dashboard and the cost-preview API run the same counting algorithm, so the number shown while you type is produced by the same math that billing applies at dispatch.
  • The encode boundary at the SMPP gateway classifies the body with the same GSM-7 alphabet before submission, so a message billed as GSM-7 is also encoded as GSM-7 on the wire — never the reverse drift where you pay for UCS-2 and send GSM-7, or worse.
The counters across these surfaces are pinned together so they cannot drift apart. The guarantee to build on is: what is billed is what is sent — one shared counter, one shared alphabet. It is not a promise that every preview and every invoice will always agree in every corner (sender-side carrier rules can still re-split on delivery); it is a guarantee that Orbit’s own surfaces do not disagree with each other.

The normalization pipeline, before anything counts

Raw input is normalized before encoding detection, in this order:
  1. NFC Unicode normalization. A decomposed é typed as e + combining accent (U+0065 U+0301) collapses to the precomposed é (U+00E9). Both forms then count identically — the precomposed codepoint sits in the GSM-7 alphabet, so copy-paste from a source that emits decomposed forms does not blow your message into UCS-2. Without this step, the combining accent (U+0301) is non-GSM and the whole message would flip encoding.
  2. Zero-width character strip. Zero-width spaces, zero-width joiners, and BOM marks (U+200B–U+200D, U+FEFF) are invisible, inflate counts, and are not in the GSM-7 alphabet — they’d otherwise flip encoding for no visual reason. They are removed.
  3. Smart-punctuation sanitization. Pasted-from-Word punctuation has GSM-7-safe equivalents: curly quotes → straight quotes, en/em dashes → hyphens, ellipsis → three dots, non-breaking space → space, bullets → asterisks. Applying these replacements before counting is what keeps the billed body identical to the on-wire body — the message is counted in the form it will actually travel in, so there is no “billed as X, sent as Y” gap.
The operators’ takeaway: a decomposed é and a precomposed é count identically, and a smart quote you paste in never silently costs you a UCS-2 bill — it is rewritten to an apostrophe, then counted.

Worked examples

Run these against the compose counter to confirm the math before a campaign sends: 1. 300 GSM-7 chars with one extended char. Body: 299 basic characters + one ^ (extended = 2 septets) → effective count 301 septets. Encoding: GSM-7. That’s over the 160 single-part limit → multi-part at 153/segment → ceil(301/153) = 2 segments. The ^ quietly consumed 2 of your 153; shuffle paste away from extension-table characters in long copy. 2. A single emoji. Meeting moved to 3pm 👍 — one emoji flips the body to UCS-2. Effective: 22 visible characters, 23 code units (emoji = a surrogate pair, 2 units) → fits one UCS-2 segment → 1 segment, UCS-2. Still cheap. The danger is the next example. 3. Long concatenated marketing copy. A 150-character ASCII blast → 1 GSM-7 segment. Add one emoji → the whole body goes UCS-2; the emoji is a surrogate pair, so 152 UTF-16 units is over the 70 single-part limit → multi-part at 67 → ceil(152/67) = 3 segments. One glyph tripled the segment count — which is exactly the budget line that drives campaign cost at scale. 4. Smart quotes saved. “Limited offer” — today only pasted from a doc: un-normalized this flips to UCS-2 (curly quotes, em dash). After sanitization (", -) it is pure GSM-7 → 1 segment instead of a UCS-2 single segment on the same visible text — and on a 160-char body, the difference is 1 segment vs. 3. The compose dialog shows the same segments + encoding pair the send response returns — the preview is the counter, not an approximation of it.

Operator controls

Three tenant-side levers wrap the math; none of them changes the encoding decision itself:
  • The max_price send gate. On POST /messages/sms and /messages/mms, max_price caps the projected spend: the gate multiplies your per-segment rate by the counted segments and rejects with 422 MAX_PRICE_EXCEEDED when the projection exceeds your cap — so a body that just got emoji-flipped cannot burn your wallet on a 3-segment send you never approved. Increase max_price, shorten the body, or send GSM-7-clean copy.
  • The compose-time counter. The dashboard composer surfaces segments and encoding as you type, from the same counter billing uses at dispatch — the right place to catch a paste-from-docs mistake before it is a charge.
  • The 10-segment ceiling. Nothing bills beyond 10 segments; over-cap bodies arrive trimmed with a truncation flag so you see it rather than wonder where the tail went. Split long copy into two sends at a natural break if you need the full text through.

Where next