Code Agency
19 min readBy Fabio Tielen

Two-way SMS for service businesses: confirmations that answer back

A reminder tells the customer something. A confirmation tells your calendar something — and only one of those lets you refill the slot. The virtual number that makes replies possible, the matching problem nobody warns you about, parsing what people actually type in three languages, and the Odoo automations that reschedule without a human.

Every service business we work with has a version of the same person. At a garage someone starts phoning around four on Thursday afternoon, working down Friday's list. At a dental practice it's whoever is on reception between patients. At an HVAC installer it's the person who printed the job sheets that morning. They are all asking one yes-or-no question — are you still expecting us tomorrow? — and a good half of those calls go unanswered.

That half is the whole problem, and it isn't the phone's fault. The call didn't fail. It moved the uncertainty from the calendar into a voicemail box, where it will sit until 08:55 tomorrow when a van with two technicians in it is parked outside a house with nobody home.

The usual fix is a one-way SMS reminder, and it is genuinely worth doing: it costs a few cents, it arrives, and it removes the "I completely forgot" category of no-show. But look at what it leaves behind. After the reminder goes out, your calendar says exactly what it said before — scheduled — and it still means we hope. You have informed the customer and learned nothing.

A reminder tells the customer something. A confirmation tells your calendar something. Only the second one lets you do anything about it. That difference is what a reply is worth, and it is the reason we put a virtual number behind these flows instead of a sender ID that looks nicer.

Do this arithmetic before you build anything

The number everyone quotes is the no-show rate, and it's the wrong number to design against. The one that decides whether this project pays for itself is the recovery rate: of the appointments that fall through, how many do you find out about early enough to sell that slot to somebody else?

Put your own figures in. A two-person crew, a 90-minute slot, half an hour of driving each way. If you learn about the cancellation at 08:55, you've lost the slot, the drive and most of the crew's morning. If you learn about it at 16:30 the day before, you've lost nothing but an SMS — provided somebody actually refills it, which is a separate problem we'll get to.

So the goal of a two-way flow is not "fewer no-shows". It's earlier information, and that reframing changes almost every design decision downstream: when you send, what you ask, how long you wait, and what happens the moment an answer lands. A system that reduces no-shows by 2% but tells you about the rest a day earlier is worth far more than one that gets the percentage down and still surprises you on the morning.

The flow, and the one part that is actually hard

End to end it looks like nothing:

the appointment confirmation loop
  Odoo / your app         SMS platform            customer's phone
  ─────────────────       ─────────────           ────────────────
  T-48h  send reminder ──────► outbound ──────────────► "Tomorrow 09:00,
                                                          reply YES to confirm"

                              inbound webhook ◄───────────────┘  "ja"

  resolve ◄──────────────────────  │   from: +32478…  to: +3278…
  reply → appointment              │   body: "ja"     at: 16:41

  write state ──► confirmed ──► automation reacts ──► nothing more to do

Six boxes, and five of them are a REST call. The one that isn't is resolve reply → appointment, and it's where every naive implementation goes wrong, because SMS has no conversation. There is no thread ID, no In-Reply-To, no correlation header. What arrives at your webhook is four fields:

what arrives at your webhook, in full
{
  "from": "+32478123456",
  "to": "+3278123456",
  "body": "ja ok tot morgen",
  "received_at": "2026-08-28T16:41:07Z"
}

Everything your business logic needs — which appointment, which customer, which of the two cars in that household — you have to reconstruct from a phone number and a timestamp. Get that reconstruction wrong and you will confirm the wrong job, which is worse than never having asked.

Decide the sender before you send the first message

Almost every reminder programme starts life sending from an alphanumeric sender ID, because GARAGE VDB in the sender line looks better than a number nobody recognises. It's also cheaper and it needs no provisioning.

Alphanumeric sender IDs cannot receive replies. There is no mailbox behind a name; it's a display field on a one-way message. The day you decide you want confirmations, you need a virtual number, and swapping is not a config toggle — the handset threads messages by originator, so your existing customers get a fresh, empty conversation from an unknown number while the old thread with all your previous reminders sits above it. You get to explain that once per customer.

The rules also differ by destination. Belgium and most of the EU accept alphanumeric senders; the US and Canada require a number and layer their own registration regime on top. If you send into more than one country and want replies from all of them, you are provisioning a number per country, not a sender ID per brand. We map this in the intake for exactly that reason — the numbers and sender IDs you need fall out of the use cases and the destinations, and they're the one part that's painful to change later.

The practical rule: if the message asks a question, it goes out from a number. Mixed programmes are fine — the "your technician is on the way" message can still come from a sender ID, because nobody is meant to answer it — but be deliberate about which thread each message lands in.

Matching a reply to an appointment

The resolver is the piece worth writing carefully, and it is shorter than people expect. Normalise the number to E.164 first (libphonenumber with the customer's country as the default region — a Belgian customer who replies from a handset roaming abroad still sends the same MSISDN, but the formats you've stored over the years will not be consistent). Then find the most recent outbound message to that number that expected an answer, and only accept a reply inside a window you chose on purpose.

lib/sms/resolve.ts — a reply is only an answer if something asked a question
const REPLY_WINDOW_HOURS = 72
 
export async function resolveInbound(inbound: InboundMessage) {
  const from = toE164(inbound.from, "BE")
 
  const pending = await db.query(
    `select p.* from sms_prompt p
      where p.msisdn = $1
        and p.answered_at is null
        and p.sent_at > now() - make_interval(hours => $2)
      order by p.sent_at desc
      limit 2`,
    [from, REPLY_WINDOW_HOURS]
  )
 
  // Nothing asked this number anything recently: not an answer, a message.
  if (pending.length === 0) return { kind: "unsolicited", from }
 
  // Two open prompts to one number — a household with two cars, a company
  // phone covering three sites. "yes" is genuinely ambiguous here and we
  // refuse to guess which job the customer meant.
  if (pending.length > 1) return { kind: "ambiguous", from, prompts: pending }
 
  return { kind: "answer", prompt: pending[0] }
}

Three things in there are deliberate.

The prompt is a record, not an inference. We write a row every time we send a message that expects an answer, and we resolve against that table rather than against the appointment table. It means a reply to a reminder we sent and later cancelled resolves to a closed prompt instead of silently attaching itself to the next appointment on that customer's account.

Two open prompts is a refusal, not a coin flip. This case is much more common than it sounds — one mobile number attached to the household, two cars in for service the same week. When it happens the reply goes to a human with both appointments attached. We have never regretted that; we have regretted the version that took the most recent one.

The window is a business decision. Seventy-two hours suits appointment reminders sent two or three days out. A "the technician is at your door" message needs a window measured in minutes. Set it per prompt type, not globally, and store it on the prompt row.

The reply also isn't always the number you texted. People forward the message to a partner who answers from their own phone. That resolves to unsolicited, and unsolicited should never be a silent drop — it goes to the same human queue, because a customer who bothered to type something and got nothing back has learned that the channel is fake.

People do not type YES

Here is the kind of thing that actually lands in the webhook, and none of it is a straw man:

What arrivedWhat it means
ja, JA, Ja., oui, yes, ok, okay, 👍confirmed — if you handle the emoji before you strip it
Ja bedankt, tot morgen!confirmed to any human reading it — and the guard below sends it to one
Ja maar kan het om 10u ipv 9u?not an answer — a reschedule request
nee, ik ben in het buitenlandcancellation, with a reason
Wie is dit?you have the wrong number, or a customer who forgot
STOPopt-out, and it is not about this appointment
Y, +, 1confirmed, if you told them to reply that

Belgium adds one more complication for free: the same inbox carries Dutch, French and English, sometimes inside one message. So the parser has two jobs, and only one of them is linguistic.

lib/sms/classify.ts — confident about yes, deliberately timid about no
const YES = new Set([
  "ja", "j", "oui", "o", "yes", "y", "ok", "oke", "okay", "dacord",
  "1", "+", "confirm", "bevestig", "bevestigd", "confirme", "accord",
])
 
/** Only an unambiguous, standalone cancellation counts. Anything with more
 *  words in it is a conversation, and conversations go to people. */
const NO = new Set(["nee", "neen", "non", "no", "n", "annuleer", "annuler", "cancel", "2"])
 
export function classify(body: string): Intent {
  // Two emoji are answers. Check them before the normaliser throws them away.
  if (/[👍👌]/u.test(body)) return { kind: "confirm" }
 
  const text = body
    .normalize("NFD")
    .replace(/\p{Diacritic}|\p{Extended_Pictographic}/gu, "")
    .toLowerCase()
    .replace(/[^a-z0-9+ ]/g, " ")
    .trim()
    .replace(/\s+/g, " ")
 
  if (text === "stop" || text === "stopsms") return { kind: "opt_out" }
 
  const words = text.split(" ")
  if (words.length <= 3 && words.some((w) => YES.has(w))) return { kind: "confirm" }
  if (words.length === 1 && NO.has(words[0])) return { kind: "cancel" }
 
  return { kind: "unclear", text }
}

The asymmetry is the point, and it is not a linguistics decision — it's a cost decision. Confirming wrongly costs you one wasted visit. Cancelling wrongly costs you a customer, because they told you they were coming and you deleted them from the calendar. So a short, clear affirmative auto-confirms, and anything else — including a nee with an explanation attached to it — lands in front of a human who can read the sentence and phone if needed. Ja maar kan het om 10u is the one that punishes a keyword matcher: it contains ja, it is not a confirmation, and the three-word guard is what catches it.

Two more things carry more weight than the parser:

Teach the protocol in the message. "Reply YES to confirm or BEL to be called back" gets you clean answers from most people. If you never say what to reply, you're doing free-text intent classification on a channel that gives you 160 characters of context.

Keep the reminder to one segment. GSM-7 gives you 160 characters; one emoji flips the whole message to UCS-2 and drops that to 70, which quietly turns a full-length message into three parts and multiplies the send cost across the whole programme. It also mangles concatenation on older handsets. The smiley is not worth it.

The Odoo half: automations that reschedule without a human

Once the reply is resolved and classified, the write is small — and it should be the only thing your integration does. Everything after it belongs in the ERP, where the business rules already live and where someone other than a developer can change them.

an automation rule on the field-service task, not a script in a cron
# Base automation: trigger on sms_state, no code in the webhook layer.
def _on_sms_state_change(self):
    for task in self:
        if task.sms_state == "confirmed":
            task.message_post(body=_("Confirmed by SMS at %s") % task.sms_answered_at)
 
        elif task.sms_state == "cancelled":
            task.write({"stage_id": self.env.ref("fsm.stage_cancelled").id})
            # The freed slot is an opportunity with a shelf life. Offer it
            # immediately; do not wait for someone to notice the calendar.
            task.slot_id._offer_to_waiting_list()

The interesting half is _offer_to_waiting_list, and it contains the mistake we see most often: the freed slot gets announced to everyone on the waiting list, three people reply ja, and now dispatch has a problem that didn't exist before the automation.

An offer has to be a claim, not a notification. Whoever answers first takes the slot, and that has to be enforced where the race actually is — in the database, not in the handler:

first YES wins, and the loser gets told immediately
def _claim_slot(self, partner):
    self.env.cr.execute(
        "UPDATE fsm_slot SET partner_id = %s, state = 'booked' "
        "WHERE id = %s AND state = 'offered' RETURNING id",
        (partner.id, self.id),
    )
    if self.env.cr.fetchone():
        return True
    # Somebody was faster. Say so in the same channel, within seconds —
    # silence here reads as "the slot is yours" and produces two vans.
    self.env["sms.api"]._send(
        partner, _("Sorry — that slot was just taken. We'll offer you the next one.")
    )
    return False

Two vans at one address is the failure mode that makes a client switch the automation off, and it is entirely a concurrency bug. Offer to a small batch, enforce the claim with a conditional update, and always answer the losers.

The wiring around this is the same shape as any other event-driven integration into the ERP: a webhook endpoint that authenticates, validates and enqueues, and a worker that does the write. We've laid out the surfaces Odoo exposes for exactly this, and the Postgres-backed queue that makes an idempotent worker cheap — an inbound SMS webhook is a textbook case for it, because carriers retry and you will receive the same message twice. For field-service work specifically, the appointment already lives on a project.task with a technician and a planned window, which is why we keep the whole job in one database rather than syncing a scheduling tool against the ERP.

The appointment is a state machine; a reply is an event

Late and duplicate replies are not edge cases, they're the steady state. Someone replies ja three times because the network was slow. Someone replies at 07:40 on the morning of the appointment. Someone confirms on Tuesday and cancels on Wednesday. Model this explicitly or you'll be patching it forever.

Current stateReplyWhat happens
awaitingconfirmconfirmed, no acknowledgement needed beyond one short reply
awaitingcancelcancelled, slot offered to the waiting list
confirmedconfirmnothing. Idempotent, and no second acknowledgement
confirmedcancelcancelled, and dispatch is notified — this one is a person's problem
anyunclearstate unchanged, routed to a human, customer told a human will read it
after cutoffcancelstate unchanged, dispatch alerted. It is a notification, not a cancellation

That last row is the one worth arguing about with the client. Past a cutoff — usually two hours out, sometimes the previous evening — a cancellation cannot quietly rewrite the calendar, because the crew is already routed and the slot cannot be resold. It becomes an alert to a dispatcher who decides. Automating it away doesn't make the van turn around any faster; it just means nobody knew.

And every automatic state change gets a one-line reply back. "Bedankt, we zien u morgen om 09:00." It costs one message and it removes the entire category of customer who confirms twice because they weren't sure the first one worked.

Timing: late enough to remember, early enough to resell

The scheduling rule falls straight out of the recovery-rate framing: your first reminder has to go out further ahead than your rebooking lead time. If it takes you a day to fill a freed slot, a two-hour reminder is a courtesy, not an operations tool — it will tell you about the cancellation at a point where the only remaining decision is whether the van leaves.

What we implement, adjusted per trade:

  • T-72h or T-48h — the one that can save you. This is the message that asks the question and expects a reply. Send it far enough out that a cancellation is still a rescheduling problem rather than a loss.
  • T-24h — the nudge. Only to people who haven't answered. Sending it to customers who already confirmed teaches them their answer didn't count.
  • T-2h — "your technician is on the way", one-way. No question, no reply expected, sender ID is fine. This is the message that reduces "I popped out to the shop", and it is the one customers thank you for.

Respect quiet hours — nothing before 08:00 or after 20:00, whatever the scheduler thinks — and send in the language on the customer record, not the language of your office. In Belgium that's not politeness, it's whether the reply comes back parseable.

Where the line is between a service message and marketing

An appointment reminder rides on the contract: the customer booked a job, and telling them when you're coming is part of delivering it. No separate consent is needed, and asking for it would be worse than not asking. That is exactly the same reasoning we apply to transactional mail versus campaigns, and the boundary breaks in exactly the same place.

Add "and 10% off a winter service if you book now" to the bottom of the reminder and the entire message becomes a marketing message. Not the sentence — the message. It now needs opt-in, it needs an opt-out, and it needs to respect a suppression list that the reminder must not respect. The temptation is real, because the reminder is the message with the best read rate in the entire business. Resist it, and put the promotion in its own consented programme.

STOP needs the same scoped treatment. Honour it instantly — that's not optional — but scope it to the programme it arrived in. A customer who opts out of your campaigns must keep getting told that a technician is coming to their house tomorrow; a customer who opts out of appointment reminders has effectively cancelled the channel and someone should phone them. One flat suppression list cannot express that distinction, and the failure is silent in the direction that costs you visits.

The honest costs

A number per country, and inbound isn't free. Two-way means provisioning and paying for virtual numbers, plus a cost per inbound message. At appointment volumes it's small; it is not zero, and it scales with countries rather than with sends.

Somebody has to own the human queue. The unclear and ambiguous buckets are maybe one reply in eight, and they need a person with the appointment in front of them. A two-way channel nobody reads is worse than a one-way one, because you invited the answer.

Delivery reports flatter you. delivered means a handset acknowledged receipt, not that anyone looked at it. The reply is the only delivery report that means anything, which is a decent argument for the whole approach.

The keyword list is a maintenance item. Three languages, real customers, and a tail that keeps growing. Log every unclear reply with its normalised text; reviewing that log once a quarter is what keeps the parser honest.

Threading gets messy if you're careless. Reminders from a number, OTP codes from another, campaigns from a sender ID: three threads, one company, and a customer who replies to the wrong one. Decide the originator per programme deliberately and write it down.

Where two-way SMS is the wrong tool

  • When the negotiation is complex. "Can we do Thursday, but after four, and not the 12th" is not an SMS conversation. Send the link to a rescheduling page and let SMS carry the link. The channel is a switch, not a form.
  • High-value, low-volume B2B. If you run twelve installations a month at five figures each, the confirmation call is the relationship. Automating it saves nothing and costs something.
  • When the calendar isn't the source of truth. If dispatch really happens on a whiteboard and the ERP gets updated on Friday, the automation writes to a system nobody reads. Fix the sequencing first — this is the same reason we start Odoo projects with a fit-gap instead of an integration.
  • When nobody will act on a freed slot. The entire economic case is refilling. If there's no waiting list and no one whose job it is to call it, you have built a slightly better reminder — worth having, but don't budget for it as a revenue project.
  • When a voice call is what's needed. Elderly customers, a job that needs a decision, a complaint in progress. Route those to a person; the phone system is not the enemy of the SMS platform.

The short version

A one-way reminder informs the customer and leaves your calendar exactly as uncertain as it was. The reply is what converts a hope into a state you can act on, and it costs a virtual number and about a week of integration work.

Send from a number if you're asking a question. Write a prompt row so a reply resolves against something that actually asked, refuse to guess when two are open, and give the window a value per message type. Be confident about yes and timid about no, because the two mistakes cost wildly different amounts. Push the business logic into the ERP where the rules already live, make a freed slot a claim rather than an announcement, and enforce the claim in the database. Treat the appointment as a state machine and the reply as an event, so a duplicate is free and a late cancellation becomes a dispatcher's alert instead of a silent hole in the day. And keep the promotion out of the reminder, however good the read rate is.

That's what we build on our own SMS platform — around 400,000 messages a month with 100% measured uptime over the past six years — wired into the Odoo installations we develop and the applications we build, running on the same infrastructure as everything else we host. The technology is a REST call and a webhook. The value is knowing on Thursday afternoon what you would otherwise have found out on Friday morning, parked outside an empty house.

Want us to publish something specific?

Tell us what you'd like to read and we'll add it to our writing queue.

Get the next one in your inbox

New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.