One curly apostrophe turned a 2-text message into 5
6 min read
The cancellation that cost 2.5 times what it should
I build the software behind CYD Soccer Academy, a youth soccer academy in Calgary. One part of it is a broadcast page: pick a group of families, write a message, and it goes out by text and email.
On July 24, 2026 we cancelled training because of smoke and bad air quality, and the notice went to 221 parents. Twilio billed it as 5 segments per parent. After I cleaned up the punctuation, the exact same words fit in 2.
What tipped me off was the Twilio bill: texts were costing several times what the number of messages suggested.
The message had two curly apostrophes (today’s, We’ll) and a soccer ball emoji at the end. Any one of them would have done it. I ran the message back through the counter with only one curly apostrophe left in, and it still comes out at 5.
How texts are actually billed
Carriers don't bill per message. They bill per segment, and a long text is split into several segments that your phone stitches back together. How much fits in a segment depends on the encoding.
-
GSM-7 is the default SMS alphabet: plain letters, digits, common punctuation, and a few accented letters like é, à, ñ and ü. One segment holds 160 characters. Once a message is split, each part loses room to a small header, so it's 153 per segment.
-
UCS-2 is the fallback for anything else. Each character takes 16 bits, so a segment holds 70 characters, or 67 once it's split.
The catch is that the switch is all or nothing. One character outside GSM-7 and the whole message goes UCS-2. Nothing tells you. The text looks exactly the same on the parent's phone.
The characters that cause it are the ones you never think about: curly quotes ’ “ ”, en and em dashes – —, the ellipsis …, bullets, non-breaking spaces, zero-width characters, and every emoji. The straight apostrophe ' is in GSM-7. The curly one ’ is not. iPhone keyboards, Word and AI-written drafts all produce the curly kind without asking.
Nobody types a curly apostrophe on purpose. The keyboard or the editor swaps it in for you, which is exactly why nobody notices.
A worked example
Here's a made-up 249-character notice, with exactly one curly apostrophe in "tonight’s":
Hi parents, field update: tonight’s training is moved to the indoor gym because of the rain. Same start time, same group. Please bring indoor shoes and a water bottle. If your child can't make it, no need to reply, we will see you next week. Thanks!
As GSM-7 (straight apostrophe): 249 is more than 160, so it's split. 249 / 153 = 1.63, which rounds up to 2 segments.
As UCS-2 (curly apostrophe): 249 is more than 70, so it's split. 249 / 67 = 3.72, which rounds up to 4 segments.
Same words, twice the texts. The longer the message, the worse it gets. A 320-character notice is 3 segments in GSM-7 (320 / 153 = 2.09) and 5 in UCS-2 (320 / 67 = 4.78).
For the real July 24 notice, using the per-segment rate I eventually measured from our own Twilio bill ($0.0175 USD, more on that below):
-
As sent: 5 segments x 221 parents = 1,105 segments = $19.34 USD
-
Cleaned: 2 segments x 221 parents = 442 segments = $7.74 USD
That's one message. Broadcasts at the academy go to a few hundred families at a time, so it adds up every time someone hits send.
The counter was wrong too
The broadcast composer already showed a segment count before any of this. The problem was how it counted: s.length <= 160 ? 1 : Math.ceil(s.length / 153). It assumed every message was GSM-7. For the July 24 notice it would have said 2. Twilio billed 5.
The fix, in three places
I put all the SMS math in one small file, sms-segments.ts, with no dependencies so the browser and the server can both import it. It does two jobs.
1. A sanitizer. sanitizeForSms turns curly quotes into straight ones, dashes into hyphens and … into three dots. Accented letters GSM-7 doesn't have get folded to their base letter (ó becomes o), while the ones it does have (é, à, ñ, ü) are kept. Emoji, zero-width characters and anything else that can't be mapped get dropped, and leftover spaces get tidied. Running it twice gives the same result as running it once.
2. A real segment counter. segmentInfo counts the way the carrier does:
export function segmentInfo(text: string): SegmentInfo {
const offenders = new Set<string>();
let septets = 0;
let charCount = 0;
for (const ch of text) {
charCount++;
if (BASIC_SET.has(ch)) septets += 1;
else if (EXT_SET.has(ch)) septets += 2;
else offenders.add(ch);
}
if (offenders.size === 0) {
const segments = septets === 0 ? 0 : septets <= 160 ? 1 : Math.ceil(septets / 153);
return { charCount, encoding: "gsm7", segments, nonGsmChars: [] };
}
// UCS-2: billed per UTF-16 code unit (surrogate-pair emoji count as 2).
const units = text.length;
const segments = units === 0 ? 0 : units <= 70 ? 1 : Math.ceil(units / 67);
return { charCount, encoding: "ucs2", segments, nonGsmChars: [...offenders] };
}Characters from the GSM-7 extension table (like € or [ ]) cost two slots, which is why it counts septets instead of characters. In UCS-2 it counts UTF-16 units, so most emoji count as two.
With that in place, the composer shows a live count as you type: characters, texts per parent, and an estimated cost for the audience you've picked. If you typed something pricey, a note says what got auto-fixed (for example ’ and ⚽), and a preview shows exactly what will go out. Over 3 segments, it suggests keeping the text to 1 or 2 and putting the details in the email, since email is free.
3. The same check on the server. The browser's count is only a preview. The broadcast API runs the same function before it sends anything, so a direct API call can't skip it:
// Footer once, then forced to the cheap GSM-7 encoding: one smart quote or
// emoji would switch every recipient's message from 160-char to 70-char
// segments.
if (wantSms) smsBody = withSmsFooter(smsBody);
const smsSegments = wantSms ? segmentInfo(smsBody) : null;The tests use the real July 24 text. One test checks that the counter gets 5 segments for it, matching what Twilio billed. Another checks that after the sanitizer it's GSM-7 and 2 segments.
The same change also moved outbound texts off Twilio's Conversations API and onto plain messages.create. Conversations adds a per-message channels fee on top of the SMS price, and replies reach us the same way either way.
Getting the price right took three tries
The cost estimate is only useful if the per-segment rate is right, and I got it wrong twice.
-
$0.0083. This was Twilio's base rate only. Carrier fees are billed as a separate usage category and never show up in a message's own
pricefield, so they were counted as zero. -
$0.0447. To fix that, I divided the real total for a settled two-week window (July 20 to August 3: $69.48) by the usage record's
countfield, 1,553. That field isn't segments, so this overstated everything by about 2.5x. -
$0.0175. Counting segments from the actual message list for the same window gave 3,972. 3,972 x $0.0175 = $69.51, against $69.48 actually billed.
Carrier fees were $32.86 of that $69.48. They're charged per segment too, so a long message pays twice: once in segment count, and again in the fee on each segment. I left the whole derivation in a comment above the constant so nobody has to work it out a fourth time.
It came back once
In September, while I was adding tests for reschedule texts, one caught a new leak. Time slot labels were built for screens and had an arrow in them ("8-9 PM → 8-9 PM"). The arrow isn't in GSM-7, so every reschedule result text had been going out as UCS-2. The fix was to map the arrow to -> and run the message through sanitizeForSms like the others.
What I'd do next
That arrow shows the weak spot. Sanitizing is still opt-in: sms.ts calls sanitizeForSms in five places, but the shared sendSMS helper that most texts go through doesn't. Any new message built from a config string can slip through.
Next I'd move the sanitizer into sendSMS itself, or at least log a warning any time a message is about to go out as UCS-2, so the next leak shows up in the logs on day one.
The bigger lesson: when you pay per unit, count units the way the biller counts them. Character count isn't segment count, and a message's price field isn't the bill.