Skip to content
VC
Case 4 of 33 · E-commerce / Support

Missed calls: auto-SMS with the real ship date instead of a canned reply

A cloud-telephony webhook → customer and order lookup across two Shopify stores → an SMS carrying the actual fulfillment status. Plus a "Missed calls" page in the CRM: who called, what's in their orders, and a ready-to-send reply.

Industry
E-commerce, US customers
Stack
Python · telephony webhooks · Shopify API
Timeline
~2 weeks
Outcome
~200 calls/mo with no human reply → auto-reply
01 · Pain Point

The customer calls during their business day — it's the middle of the night for support

A US outdoor-gear brand, two Shopify stores, and a support team several time zones away. The customer dials in the middle of their working day — which is evening or night for the team. Over a 90-day window the call journal holds 739 inbound calls: 496 of them arrived inside the advertised intake window, and 310 of those went unanswered.

Missed calls were not collected anywhere. The morning started with a wall of notifications in the telephony app: a median of 5 overnight, up to 25 on peak days. Working through that feed by hand is a job of its own, and nobody was doing it systematically.

The single most common reason for calling was "where is my order". In other words, people called precisely because they couldn't see a status: an expensive product, a long production lead time, and silence in both the emails and the account page.

The built-in telephony auto-reply made it worse: night callers received a daytime "we'll call you back shortly", and on one of the lines the business-hours toggle was switched off entirely — so the canned message went out to everyone, always.

02 · Solution

Webhook → Shopify order → an SMS with the real date

A call.completed webhook receiver on the CRM side: signature verification, filtering out irrelevant event types, protection against processing the same call twice (otherwise the customer gets two callbacks), and a journal entry. From there the caller's number resolves to a customer and their orders across both stores — and the reply gets assembled.

01
Call webhook

call.completed from cloud telephony: HMAC signature, event-type filter, dedup by call id

02
Customer lookup

Caller number → customer and their orders across two Shopify stores

03
Reply ladder

From "the real ship date" down to a neutral fallback

04
SMS delivery

Quiet hours, per-number cooldown, rate limiting, draft mode

05
CRM queue

"Missed calls" page: who called, their orders, ready-made reply, "handled" checkbox

The key finding: the order has no ship date

The central question of the whole pipeline is where to get a date you're not ashamed to send a customer. It turned out that a calendar ship date does not exist anywhere on the Shopify order. Four other candidate sources were checked and rejected — none of them carries the date the customer was actually promised.

The only carrier of that promise is the name of the shipping tier chosen at checkout: that's where the buyer saw a timeframe and agreed to it. The date is derived from that string. This is the kind of project where two weeks of work hinge on one design decision — and you have to find it before writing code.

The reply ladder: from useful to fallback

The reply is assembled in steps, from maximally specific down to neutral, and no step is allowed to lie:

  • customer and order found, promised date still in the future → state the date
  • promised date already passed → do NOT repeat it; say plainly that we're checking with the warehouse
  • order exists but no date can be derived → reference the order without inventing a promise
  • no customer matched the number → neutral reply, the call goes into the human queue

The "never repeat an overdue date" rule was validated against the historical export (over 11k orders): not a single old, stuck order gets handed a future date.

Safety rails on sending

An auto-SMS goes out under the business's name with no human in the loop. That makes the limiters more important than the generation itself:

  • quiet hours: sending only inside an 11:00–21:00 New York window — safe across all continental US time zones; Alaska and Hawaii are handled separately
  • per-number cooldown — a repeat call doesn't turn into a stream of messages
  • rate limiting for the pipeline as a whole
  • draft mode: everything is computed and logged but nothing leaves the system — plus a separate explicit flag for live sending

The "Missed calls" page in the CRM

The auto-reply covers first contact, but a human still has to work the calls. Instead of a notification feed there is one page: who called and when, the number as a tel: link, the matched customer, their orders with state ("unfulfilled · 82 days"), a ready-to-send reply, and a "handled" checkbox.

Degradation instead of failure is built in: a malformed queue line does not take the page down, and a read failure renders "queue could not be read". Had it said "no calls", the operator would calmly close the tab and walk away.

03 · Stack

Nothing extra — this thing has to survive the night unattended

Python stdlib

Webhook receiver and HMAC signature check without a web framework

Cloud telephony

Call webhooks, SMS API, voicemail transcripts

Shopify Admin API

Customer lookup by phone number and their orders across two stores

LLM generation

Reply text written for the specific order

systemd

Receiver service, auto-restart, journal

pytest

2,437–2,471 green tests across the calls and analytics pipeline: reply ladder, quiet hours, dedup, event parsing

PythonWebhooksHMACSMS APIShopify Admin APILLMsystemdpytest
04 · Results

Data first, code second

Actual scale
108 ~200

missed calls per month — the problem was 1.6× larger than the initial estimate

Channel in use
29%

of missed calls already carry a voicemail transcript — it tells you what the call was about

Cost of sending
62%

of messages became single-segment after a one-character fix

The work started with a telephony export. The analysis showed the problem was 1.6× larger than the initial estimate, that 29% of missed calls already had a voicemail transcript — today those transcripts tell us what the call was about — and that 36% of customer SMS never received a human reply. Three facts that appeared in no report.

Five breakages fixed along the way — three of them kept the pipeline dead

  • customer lookup crashed on a signature mismatch — every call was treated as "customer not found"
  • event parsing read the wrong nesting level and silently discarded every missed call
  • the caller's number was being read from a field the webhook does not contain
  • the auto-reply card took the whole calls page down — the page two operators start their morning on: the code was written against guessed field names, and the test stub was invented to match, so it confirmed the author's guess
  • the missed-call aggregates were written and covered by tests, and nothing ever called them: the manager's question "how much are we losing on missed calls" stayed open while the answer already sat in the code

The first three were invisible from the outside: the service ran, logs were written, no SMS went out. The fourth was obvious to everyone at once — the calls page stopped opening. The fifth stayed quiet: the number was computed and shown to nobody.

How we prove the webhook door is open

Silence in the pipeline reads two ways: there really were no calls, or events stopped arriving. While those two states look identical, any report about a quiet night is worth nothing. So the door itself gets its own checks:

  • The receiver spent 18 days running code almost three weeks old with a restart counter of zero, and a file hash on disk matching the repository did nothing to disprove it: the comparison that counts is process start time against file modification time. The consequence was exactly what this case is about — looking up the customer by phone number went out to another server, an empty response made the function silently return nothing, and the call turned into "customer not found". Before the restart the code was diffed byte for byte against the repository and 732 green tests were run across the pipeline
  • The "were there any calls" sensor leaned on a verification endpoint that returns HTTP 400 with no data array: a naive parser prints "0 calls", and the failure is visually indistinguishable from proof that no calls came in. It surfaced through a control window where calls certainly existed: the same endpoint reported "0". A different endpoint is now the working sensor, and the webhook door is checked four independent ways, none of which leans on the queue
  • The tripwire was written down in advance: "no events at all by 18:00 Monday — go into the webhook settings". Going there was never needed: the calendar hypothesis held exactly — Friday 4 events, Saturday 0, Sunday 3, Monday 6 — and 41 hours of silence was explained by the weekend
  • The watcher on the provider's email reply read the very message it was waiting for, wrote it into state and woke nobody: the unsubscribe filter searched for its phrase across the whole body, and the ticket system appends the entire thread underneath. Fixed by parsing only the new part of the message. The same look turned up a second thing: the watcher's log held 50 lines and every one of them was an error, because a successful run printed nothing — "ran and stayed quiet" and "not running at all" looked the same

It is the same rule the queue page follows: "couldn't read" is never rendered as "empty".

A separate find was a one-character detail with a direct effect on the bill: an em dash in the templates pushed messages outside the basic SMS alphabet and into a two-byte encoding, so 100% of messages took at least two segments. Replacing the em dash with a hyphen makes 62% of them single-segment.

The business owner switched live sending on and the first messages were delivered. Every missed call now lands in the CRM queue and gets a reply carrying the real fulfillment status.

When customers actually call

The pipeline had run long enough for the webhook journal to answer the question of which hours to keep the line staffed. The measurement runs off the journal on disk, with no call to anyone's API: dedup by call id, the last event carrying a final status wins, and the customer's time zone comes from a phone-number parsing library. That is 739 inbound calls over 90 days, with the time zone resolved for every one. Counting in our own time was off the table: eight in the morning on the East Coast is five in the morning on the West Coast, and the conclusion would have come out backwards.

  • the peak sits at 11:00 in the customer's local time; the 09:00–12:00 window carries 38% of calls
  • 82% of calls land in business hours; the evening carries 5% and the night 1% — the popular "they call in the evening" theory failed to hold
  • Monday runs nearly twice as heavy as Friday
  • 496 of the 739 calls arrived inside the advertised intake window, and 310 of those were missed
  • in the daily report, 7 missed calls out of 9 fell inside the advertised intake hours

Hence the management number the whole exercise was for: moving closing two hours later, to 17:00, would have caught 133 calls over 90 days — about 44 a month. Holidays are computed by formula, because a table of dates is the kind of thing everyone forgets to extend.

Our own mistake here cost an extra round: the first version of the time-zone lookup leaned on a hand-built table of area codes and immediately put one of the cities in the wrong zone. A zone error silently shifts the entire peak and leaves no trace on the chart, so the table was thrown out and number parsing handed to a library — a libphonenumber port. The caveat that an area code can diverge from where a person lives is flagged in the report as unverified.

The "Yesterday" card on the calls page

A summary of the last 24 hours now sits next to the queue: how many auto-replies went out, how many did not, and for what reasons. The card is assembled from a complete set of sources, and the check rides on that: the full call renders "not sent 2" with two reasons, the trimmed one renders "not sent 0". The gap shows immediately that the sources have been stubbed.

05 · Where it fits

Where else the same methodology applies

This is the standard task of "an event in a communication channel → pull the customer's context from the system of record → answer with a fact from the record". The same architecture applies anywhere inquiries arrive outside business hours:

  • Online stores with long lead times — "where is my order" gets closed by a status from the system of record
  • Service and repair shops — missed call → SMS with the work-order stage and the real readiness date
  • Clinics and salons — a call outside opening hours → appointment confirmation or the next open slot from the schedule
  • Any business whose customers sit in another time zone — a night shift replaced by a pipeline that knows the facts about the order
  • Any inbound channel — email, messenger, web form: only the event source changes, the reply ladder and the safety rails stay
What's reused on subsequent projects
  • Webhook receiver with signature verification, event-type filtering and duplicate-processing protection
  • A reply ladder with an explicit ban on quoting an overdue date — dry-run against the historical export before go-live
  • The sending safety-rail set: quiet hours in the customer's time zone, per-number cooldown, draft mode, an explicit live-mode flag
  • A queue page with ready-made reply text and degradation instead of failure: "couldn't read" is never rendered as "empty"
Similar challenge?

If missed calls are piling up — measure first, automate second

I start with an export from your telephony: how many calls are actually missed, what people call about, and what's already sitting in channels nobody reads. Then comes the auto-reply pipeline built on facts from your system of record. In this case: about two weeks from data analysis to the first delivered messages.

Ready to start?

The 9,900 ₽ audit — with a concrete report and quote

I'll tell you what to deploy in your business first, what the payback looks like, and whether you need AI for the task at all (sometimes you don't).

Or just send your question — I reply within 2 hours