Behind the scenes9 min read
Two-way SMS API: handling replies with webhooks
How to build on a two-way SMS API: the message.received and opt_out.created webhooks, matching a reply to the right booking, and what to answer each delivery.
In short
- RFC 9110 defines 202 Accepted as a status meaning a request "has been accepted for processing, but the processing has not been completed", which suits a webhook queued for later work.
- CaptiFi Connect counts a webhook delivery as done when the endpoint answers with any 2xx status within 8 seconds, and retries a failure after 1, 5, 30, 120, 360 and 720 minutes.
- A reply of STOP, STOPALL, STOP ALL, UNSUBSCRIBE, CANCEL, END, QUIT, OPTOUT or OPT OUT puts the number on the CaptiFi Connect opt-out list and fires an opt_out.created webhook.
- Under Article 21(3) of the UK GDPR, once a person objects to processing for direct marketing, their personal data "shall no longer be processed for such purposes".
- A text sent from an alphanumeric sender name can't be replied to, so a two-way SMS flow has to send from a mobile number.

Sending a text through an API is one request. A two-way SMS API asks more of your code, because the answer can turn up at any time, from anyone who has the number, saying anything at all, and your system has to work out what it's about. This post covers the receiving half with CaptiFi Connect: the two webhook events that carry replies and opt-outs, how to tie a reply back to the booking that prompted it, and what your endpoint should send back so deliveries are neither lost nor doubled. The wording of the texts that ask for replies is in our post on designing texts people answer, and the signature check is in webhook signatures; neither is repeated here.
Replies need a number to come back to
A reply only exists if the phone has somewhere to send it. Texts from an alphanumeric sender name (your business name in place of a number) can't be answered, so any flow that wants replies has to send from a mobile number. With CaptiFi Connect that means leaving sender out of the POST /messages body, and the text goes from a real UK mobile number. If the account has more than one number, the from field picks one of those listed at GET /numbers/from; leave it out and we pick the best one available. Receiving replies costs nothing. Answering them is an ordinary send and costs credits like any other.
Every reply lands in the dashboard inbox, threaded by number, whatever your code does with it. The rest of this post is about getting the same reply into your own system as well, which is where the two-way SMS features meet the API.
The two events that matter
Add an endpoint under Developers, Webhooks (or POST /webhooks) and tick the events you want. Two of the six are about people texting you.
message.received fires for every text that arrives. Its data is a message object in the same shape GET /messages/{id} returns, which for an inbound text looks like this:
| Field | What it holds |
|---|---|
| id | The inbound message id, starting msg_ |
| direction | inbound |
| status | received |
| from and contact_number | The sender's number, in +44 form |
| to | Your number that received it |
| body | What they wrote |
| contact_id | The contact with that number, if you have one, otherwise null |
| received_at | When it arrived, as an ISO 8601 time |
| client_ref | Always null on a reply |
That last row is the one that surprises people. Your client_ref comes back on every event about a text you sent, which is how delivery reports get matched up. A reply is a new message from someone else, so nothing of yours is attached to it, and matching it is your job (more on that below).
opt_out.created fires when a number joins your opt-out list by texting an opt-out word. Its data is smaller: phone, source (keyword for a texted STOP), note and created_at. Our glossary entry on the STOP keyword lists the words: STOP, STOPALL, STOP ALL, UNSUBSCRIBE, CANCEL, END, QUIT, OPTOUT and OPT OUT, in any mix of capitals and ignoring punctuation. The text has to be the word on its own. "Please stop texting me" is a reply like any other and goes to the inbox, and you add the number to the list yourself.
STOP fires both events
When someone texts STOP, two things happen in the same moment. The number goes on your opt-out list (with any texts still waiting to go to it cancelled and refunded), which fires opt_out.created, and the text itself is stored as a reply, which fires message.received with "STOP" as the body. Your code will see both, in either order, so handle them as separate facts. The opt-out event updates your suppression list. The received event shows up in the conversation history and shouldn't trigger a "Sorry, we didn't understand that" answer from your own reply logic (nobody wants that text after asking to be left alone, and we refuse sends to an opted-out number anyway).
opt_out.created only fires when a number is newly added, so a second STOP from someone already on the list produces a received message and nothing else. And there's no event for opting back in. START or UNSTOP takes a number off the list, and you'll see it only as a message.received with that word in the body, so if your system keeps its own opt-out flag, watch for those two words as well.
Record opt-outs against the customer in your own database too. Article 21(3) of the UK GDPR says that once someone objects to direct marketing, their personal data "shall no longer be processed for such purposes". Our list stops our sends, and your own record stops every other part of your system from texting them, including any provider you might use later. The opt-outs page covers the rest.
CANCEL is an opt-out word, so a reminder that says "Reply CANCEL to cancel" will opt out everyone who uses it, appointment reminders included. Ask for a different word, or for any reply at all.
Matching a reply to the right booking
Since a reply carries no reference of yours, you match on the number. When you send a text that expects an answer, store a row with the number in the same +44 form our payloads use, the message id we returned, the booking it's about and how long an answer stays meaningful (until the appointment starts, say). When message.received arrives, look for open rows for data.contact_number.
Most of the time there's exactly one, and the reply belongs to it. The other cases are where the bugs live.
- No open row. Someone has replied to an old text, or texted the number out of the blue. Leave it in the inbox for a person.
- Two or more open rows. One phone is often shared, a parent booking two children say, and "Y" doesn't say which. Either answer asking which one, naming both, or hand it to a person. Don't guess the most recent.
- A reply that isn't an answer. Normalise before you compare (trim, capitals, strip punctuation), accept the obvious forms (Y, YES, YES THANKS), and send anything else to the inbox. "Running 10 mins late" is useful, and it isn't a yes.
GET /conversations/{phone} returns the last 200 texts with that number, oldest first, which helps when a person has to read the thread to decide. If you use keywords as well, note that a text starting with one of your keywords gets the keyword's automatic reply (if it has one) and still fires message.received, so your own logic shouldn't answer it a second time.
Don't make a reply wait for a delivery report. Our guide says events "can also arrive out of order after a retry", and a quick reply can beat a slow receipt from the network, so a booking may get its "Y" before the outbound text is marked delivered.
What to answer, and when
We post each event and wait 8 seconds. Any 2xx status in that time counts as done; anything else, a timeout or a refused connection included, counts as a failure and is tried again on the schedule below.
Our own running total of the six waits the guide publishes (1, 5, 30, 120, 360 and 720 minutes). Each wait starts after the previous attempt fails, so real times run slightly later. After the sixth retry the event is given up.
Source: CaptiFi Connect, Webhooks guide, Answering and retries
See the figures as a table
| Minutes after first failure (min) | |
|---|---|
| 1st retry | 1 |
| 2nd retry | 6 |
| 3rd retry | 36 |
| 4th retry | 156 |
| 5th retry | 516 |
| 6th retry | 1,236 |
That's six retries over roughly 20 hours, after which the event is dropped, and an endpoint that fails 100 times in a row is switched off until you turn it back on. RFC 9110, the June 2022 statement of HTTP semantics, gives the status codes their meanings, and three of them fit a webhook receiver. 200 OK and 204 No Content both say the work is done. 202 Accepted says the request "has been accepted for processing, but the processing has not been completed", which is the honest answer from an endpoint that checks the signature, stores the event and hands it to a queue. All three are 2xx, and we treat them the same.
The 4xx class, in RFC 9110's words, means "the client seems to have erred", and a failure of any kind brings a retry from us. So use 4xx only when you want the event again. A 401 for a bad signature is right: a forged request didn't come from us, so nothing retries it, and a real one signed with a secret you've just rotated will keep coming back until you've fixed the config. An event type you don't handle, or a reply you can't match, should still get a 2xx. Answer those with a 400 and the same event comes back six times over the next day.
Do the slow work after you've answered. Sending the "Thanks, see you Thursday" confirmation is a call to our API, and making that call inside the handler eats into the 8 seconds. Queue it, and give the answer an Idempotency-Key built from the inbound message id (reply-to- plus the id), so a webhook that arrives twice can't produce two confirmations.
Duplicates and retries
RFC 9110 doesn't count POST among the idempotent methods, and a webhook can arrive more than once: a retry can land after your server did handle the first attempt, if it answered a fraction too late. Every delivery carries an X-Connect-Delivery header that stays the same across retries, and every event has its own id. Store one of them when you process an event and skip any you've seen, before you touch a booking or send a text. The webhooks guide names the headers and shows a full payload.
Test the whole path before customers use it. A cc_test_ key fires real signed message.sent and message.delivered events for texts that never leave, but nobody can reply to a text that never arrived, so test keys can't produce message.received or opt_out.created. Write those handlers against the payload shapes, check the plumbing with the endpoint's test button (it posts a signed webhook.test event), then do a final run with a live key and your own phone once the account is approved, texting YES, something unexpected, and STOP.
Common questions
How does a two-way SMS API tell my system about a reply?
It posts a webhook. With CaptiFi Connect, each incoming text fires a message.received event carrying the sender's number, the number it was sent to and the body. Your endpoint answers with a 2xx status and handles the reply from a queue.
How do I know which booking an SMS reply is about?
Match on the sender's number. Store each text that expects an answer with the number, the booking and a time limit, then look up open rows for that number when a reply arrives. If one phone has two open bookings, ask which one or pass it to a person.
Should my webhook endpoint return 200 or 202?
Either works, since any 2xx status tells CaptiFi Connect the delivery succeeded. RFC 9110 defines 202 Accepted for a request accepted for processing that hasn't finished, which describes an endpoint that queues the work. Don't return 4xx for events you simply don't use, because a failure is retried.
Can customers reply STOP to a text from my business name?
No. A sender name has no number behind it, so phones can't reply to it at all. Marketing sent from a name needs another way to opt out, such as a link, and anything you want answers to should go from a number.
Sources
Read for this post on 4 November 2026.
Written by the CaptiFi Connect team. Published 4 November 2026. More in Behind the scenes
Send your first text this week
Set up your account and contacts now. Until we've approved it, a person reads each text before it goes out.