How a missed call gets caught
Before a single text goes out, the system has to be sure it should send one at all: that the call really was missed, that it hasn’t already replied to this caller, that it isn’t the middle of the night, and that the number hasn’t opted out. This post is about that gate — how a raw webhook becomes a safe, matched, send-once job.
Key takeaways
- The trigger is a missed-call webhook from your telephony provider, posted to one Lambda Function URL — no API Gateway.
- The webhook is verified by its signature before anything else runs, so a stranger can’t make the system text people.
- Repeat rings from the same number inside a short window collapse to one job, so a nervous caller gets one text, not five.
- Quiet hours and the STOP opt-out list are checked before sending — nobody is texted overnight, and an opt-out is permanent.
- The caller’s number is matched to a known customer for the name and booking link; an unknown number still gets a warm generic text.
From a ring-out to a job
Everything starts with one event the business never normally sees: a call that rang out. Telephony providers expose this as a webhook — when a call ends without being answered (and without going to a voicemail the caller actually used), the provider sends a small HTTP POST with the caller’s number, the time, and a call id. That POST lands on a single Lambda Function URL. There’s no API Gateway in front of it; a Function URL is a plain HTTPS endpoint on the function itself, which is all a webhook needs and the cheapest way to receive one.
The catch function’s job is not to send a text. Its job is to decide whether a text is warranted at all, and to do that safely and exactly once. Most of this post is about the checks that sit between “a webhook arrived” and “a text-back is queued”, because that gap is where a missed-call system either earns trust or becomes a nuisance.
Prove it’s real, then prove it’s new
The first check is authenticity. A Function URL is public, so the first thing the function does is verify the provider’s signature — a hash of the request body signed with a shared secret held in Secrets Manager. If the signature doesn’t match, the request is dropped with a 401 and nothing else happens. Without this, anyone who found the URL could make your system text strangers all day; with it, only your provider can trigger a send.
The second check is duplication, and it’s the one that keeps the system from becoming spam. A worried caller often rings two or three times in quick succession; some providers also retry a webhook they think failed. Either way, the system must send exactly one text per missed call. So the function writes a record keyed on the caller’s number with a short dedup window — a few minutes — using a conditional write to DynamoDB. The first ring in that window wins and creates the job; any further rings or retries inside the window see the existing record and stop. One text-back, however many times the phone rang.
Should we text at all?
Two more gates decide whether sending is even allowed, regardless of who called. Quiet hours come from the settings doc: the business’s opening hours, plus a rule for after-hours calls. A call missed at 2am shouldn’t buzz someone’s phone at 2am, so the text is held and released when quiet hours lift — the caller still hears back first thing, before they’ve chased anyone else. Opt-out is the STOP list: anyone who has ever replied STOP to a text is recorded as suppressed, and the system will never text that number again. Both checks run before a single word is composed, because the cheapest message to handle is the one you correctly decide not to send.
Only once a call is verified, de-duplicated, inside allowed hours, and not opted out does the function look up who called.
Matching the caller
Matching here is simpler than it sounds, because a phone call gives you the cleanest possible key: the caller’s number. The function normalises it to a single canonical form (E.164, the +44… international format) so that 07700 900412 and +447700900412 are the same person, then looks it up in the customers table mirrored from your Drive sheet. A hit returns the customer’s first name and, if you tag rows that way, the right booking link — a regular at the barber gets a different link from a first-time enquiry. A miss is not a failure: an unknown number still gets a warm, generic text-back inviting them to book or reply, just without a name.
The only case that doesn’t flow straight to compose is the one that’s genuinely ambiguous or risky — a withheld or malformed number the provider couldn’t give you, or a number flagged in your sheet as “do not auto-text” (a supplier, say, or a known nuisance caller). Those are recorded and, if they look like a real missed customer, dropped into the escalation lane for a person to glance at, rather than guessed about. Everything else — the overwhelming majority — becomes a clean job on the SQS queue: caller number, matched name if any, booking link, and the original call id, ready for Part 3 to phrase.
Design rules that shaped the catch step
- One public surface. A single Lambda Function URL receives the webhook; there is no API Gateway and no other way in.
- Verify before you trust. A bad signature is dropped with a 401 before any work — the URL being public is fine because the secret isn’t.
- One call, one text. A conditional write over a short window collapses repeat rings and provider retries into a single job.
- Decide not to send, cheaply. Quiet hours and the STOP list are checked before composing — the held or suppressed message costs nothing.
- The number is the key. Match on the normalised caller number; a known caller gets a name, an unknown one still gets a warm text.
- When in doubt, a person. Withheld, malformed, or flagged numbers escalate rather than getting an automatic guess.