Recipes← all recipesView on GitHub

Run an SMS survey over several messages

MessagingSMS survey (text message survey)

Text a customer a question, keep track of where each number is in the survey, ask the next question when a reply arrives, re-ask when it does not fit, and stop the moment they say STOP.

webhooksstateopt-out

The claim

Every inbound text is a fresh webhook with no memory of the last one. A survey is therefore a small state machine of your own. The sender’s number is the key, the step is the value, and the reply you return is the next question. This recipe keeps that state in a file, advances it one reply at a time, and answers with a messaging SWML reply.

Why it holds

Four things the handler does that a survey needs.

  • An answer that does not fit the question is re-asked, and the step does not move.
  • The last answer gets a closing line, and any text after that gets an empty document: nothing sent, nothing recorded.
  • STOP, or any of the other stop words, marks the number and ends the survey. A number that has stopped is never texted a first question, and the refusal happens before any request is made.
  • The webhook checks the platform’s signature before it touches state, because an unsigned POST could otherwise fill your survey with anything.
  • A webhook delivered twice gets the same reply twice and moves nothing, even when the retry is late or the survey is already complete. The record keeps every message_id it acted on, with the reply it gave. Without that, a retried rating would be parsed at the comment step and stored as the comment. A retry that arrives after STOP gets silence, not the cached question.
  • /begin sends texts at your expense, so it is behind a key the server holds and your systems present as X-Survey-Key. The public internet gets a 403.

The vendored REST spec documents the inbound payload as {message, vars, params}, where message carries from, to and a body that may be null. The first question goes out as POST /api/messaging/messages, which requires to and from.

How it works

def handle_inbound(message):
    sender = message["from"]
    state = _load()
    record = state.get(sender)
    word = keyword(message.get("body"))
    if word in STOP_WORDS:
        state[sender] = {**(record or {"step": 0, "answers": {}}), "stopped": True}
        _save(state)
        return reply(STOPPED)
    if not record or record["stopped"] or record["step"] >= len(QUESTIONS):
        return silence()
    key, _, kind = QUESTIONS[record["step"]]
    answer = parse(kind, message.get("body"))
    if answer is None:
        return reply(REASK.get(kind, QUESTIONS[record["step"]][1]))
    record["answers"][key] = answer
    record["step"] += 1
    _save(state)
    return reply(DONE if record["step"] == len(QUESTIONS) else QUESTIONS[record["step"]][1])

What the webhook returns after a valid first answer:

{"version": "1.0.0",
 "sections": {"main": [{"reply": {"body": "Would you recommend us to a friend? Reply YES or NO."}}]}}

parse is where the survey stops being a chat. A rating is one of five digits after trim; yes or no accepts y and n; the comment accepts anything and treats SKIP as an empty answer. Anything else returns None, and the same question comes back.

The state is a JSON file keyed by number, written through a rename so a crash mid-write leaves the old file intact. In your app that is a table with the number as the key. The two documented commands, the server and begin, are two processes, which is why the state cannot live in a dictionary.

The TypeScript surface is the same handler on @signalwire/sdk, with a small node:http server for the two routes.

Limitations

The verifier proves the handler and the requests, not delivery. Whether a question reaches the phone, and how long a customer takes to answer, are live.

The state file is a stand-in. Two server processes sharing one file would race; a database with the number as the key is the real version.

A survey texts people who did not text first. Consent for that is yours to collect and record before begin, and the recipe does not claim otherwise.

What to change first

Change "4" in the verifier’s second turn to "four" and run it. The re-ask comes back instead of the next question and the step stays put. That is the point: the survey advances only on an answer it can store.