Messaging makes measurement look easy, which is the trap. Delivery rates are near-perfect and open rates are extraordinary, so every dashboard looks like a triumph. Neither number tells you whether the channel made you any money.
The metric ladder
- Delivered — did it arrive. Useful only for spotting breakage.
- Replied or tapped — did they engage. The first number with any signal in it.
- Completed the action — booked, paid, showed up. This is where measurement should start, not end.
- Incremental revenue — how much of that would not have happened anyway. The only number that justifies the spend.
Most texting ROI claims skip step four
If you text 500 customers and 60 book, that is not 60 bookings caused by texting. Some of those people were booking regardless. Without a holdout you are measuring your customers' existing habits and taking credit for them.
Run a holdout, even a crude one
Withhold the message from a random 10% of an otherwise identical group. Compare the completion rate. The gap is your actual effect. It is the single highest-value hour of analytics work available to a small business, and almost nobody does it.
// Deterministic bucketing: the same customer always lands the same way,// so a person is not in the holdout one week and the treatment the next.import { createHash } from "node:crypto"; export function inHoldout(customerId: string, campaign: string, pct = 10) { const hash = createHash("sha256") .update(`${campaign}:${customerId}`) .digest(); return hash[0] % 100 < pct;} const audience = customers.filter((c) => !inHoldout(c.id, "winback_offer"));// Then compare completion rate: audience vs the withheld 10%.Wire the conversion, not the click
Tag your links (here is how), then make sure your analytics records the completed action with the campaign attached — not just the landing-page view. A booking page visit with no booking is not a result.
// Fire on the action that actually pays you, with the campaign attached.posthog.capture("booking_completed", { value: booking.totalCents / 100, service: booking.service, utm_campaign: session.utm_campaign ?? "none", utm_content: session.utm_content ?? null,});The arithmetic
Take the incremental completions from your holdout comparison, multiply by average order value and by margin, then subtract the full cost of the channel — platform fee, per-message costs, and the time someone spends answering replies. The remainder is what the channel earned.
What people report
- 98% delivered
- 'Huge open rate'
- 312 clicks
- 60 bookings after the send
What to report
- 60 bookings vs 41 in the holdout
- 19 incremental bookings
- × $85 average ticket = $1,615
- − $94 channel cost = $1,521 earned
The no-show calculation is easier
For reminders you do not even need a holdout if you have historical data. Compare your no-show rate for the eight weeks before reminders launched against the eight weeks after. Multiply the difference by appointments and by average ticket. For most service businesses that number alone settles the question — see the reminder playbook.
Report in dollars, always
Nobody makes a decision from a click-through rate. Everybody makes a decision from 'this cost $94 and returned $1,521'. Build the reporting to produce the second sentence.
Next step
Generate a tagged link for whatever you send next with the UTM builder, see what this looks like in your industry, or compare the services that can send it on the providers page.