Part 2 of 7 · Missed-call text-back series ~7 min read

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.

The catch gate: webhook in, verify, dedup, quiet-hours and opt-out checks, caller match, then enqueue On the left, an external box “Telephony provider” sends a missed-call webhook by an arrow into a Lambda Function URL box labelled the catch function, inside a dotted AWS account container. From the catch function a vertical sequence of decision steps runs downward: first “Verify signature”, reading a shared secret from a Secrets Manager box; a failed signature exits to a small “Drop 401” box. Next “New call? (dedup window)”, backed by a DynamoDB calls table with a conditional write; a duplicate exits to a “Stop, already replied” box. Next “Quiet hours / STOP opt-out”, reading settings; if quiet, the job is held for later, and if opted out it stops entirely. Next “Match caller” against a DynamoDB customers table keyed by phone, producing either a known customer or an unknown number, both of which continue. Finally an arrow into an SQS queue box labelled enqueue text-back job. A note reads: nothing is sent until a call is proven real, proven new, allowed by hours and opt-out, and matched — one text per missed call. Telephony missed-call webhook AWS account Catch function Function URL Verify signature Secrets Manager shared secret Drop 401 bad sig New call? dedup window DynamoDB calls conditional write Stop, already replied dup Quiet hours / STOP hold or suppress Match caller known or unknown DynamoDB customers PK phone Enqueue job SQS → compose Nothing is sent until a call is real, new, allowed, and matched — one text per missed call.
Fig 2. The catch gate. A missed-call webhook is verified by signature, de-duplicated with a conditional write so repeat rings make one job, checked against quiet hours and the STOP list, and matched to a customer — only then is a text-back job enqueued.

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.
All posts