n8n · Twilio · OpenAI · Supabase · Cal.com
A multi-tenant n8n workflow that captures inbound leads from four channels, qualifies them over multi-turn SMS with GPT-4o-mini, books into Cal.com, logs to Google Sheets, and chases the leads that go quiet.
A missed call at a plumbing company is a lost job. The lead calls the next number on the list within minutes. Most small businesses answer hours later, or never — and a booking form on a website catches only the people patient enough to fill one in.
This closes that window. Every inbound signal — an unanswered call, a text, a form, a lead-notification email — turns into an SMS conversation within seconds, and that conversation runs until it produces a calendar booking or a clear no.
Missed call ──┐
Inbound SMS ──┤
Web form ─────┼──► Normalize ──► Resolve tenant ──► Authenticate ──► Send budget
Lead email ───┘ │ │ │
└─ unknown ──┬── forged │ within
▼ ▼
error_log ◄── capped GPT-4o-mini
│
┌──── booking ready ────┐ Twilio reply
│ │ │
Cal.com booking (keep asking) Persist the turn
│ │ │
└────► Google Sheets ◄──┘ 30-min sweep ──► re-engage
All four channels converge on one normalizer, so there is a single conversation engine rather than four parallel implementations. Tenant identity comes from the Twilio number the lead contacted, which is what makes one workflow serve many client businesses.
The model proposing a time is not enough to book. A booking fires only when there is both a confirmed start time and a collected email address — Cal.com rejects bookings without one. A confirmed intent missing either is downgraded and the bot keeps asking.
A failed booking still lands in the sheet as Booking Failed and
the thread stays active, rather than the lead silently disappearing.
Two of the intake endpoints are public and name their tenant in the request body. Left open, anyone who found the URL could pick both the business and the destination number, and have a text sent from that business's own phone number. That is not a billing problem — it is the client's liability, at statutory damages per message.
So nothing is taken on trust. Web-form leads carry a per-tenant secret.
Twilio's own webhooks are checked against X-Twilio-Signature,
recomputed and compared in constant time. Both gates fail closed: no secret, no
token, no send.
From field and a text sent from a client's number. It is on by
default and a tenant has to opt out of it deliberately.Authentication decides whether a request is real. It does not bound what happens when a real-looking one is wrong — a leaked secret, a runaway loop, a plain bug. So sends are capped too: per business, and more tightly per person. No single lead can be messaged more than four times an hour or ten times a day, whatever the rest of the system believes.
A sweep runs every 30 minutes and nudges leads who stopped replying.
| Event | Status | Next nudge |
|---|---|---|
| Lead replies | nurturing | +2h — ladder resets |
| Nudge 1 sent | nurturing | +24h |
| Nudge 2 sent | nurturing | +72h |
| Nudge 3 sent | dead | none |
| Booking created | booked | none |
A reply at any point resets the ladder, so a slow responder gets the whole sequence again instead of falling off after one late answer.
STOP, STOPALL, UNSUBSCRIBE,
CANCEL, QUIT, END, REVOKE and
OPTOUT mark the thread opted out and stop every future nudge.
START and UNSTOP restore it.
Opt-out is scoped to the lead and the tenant's number, so opting out
of one client never silences another. yes is deliberately not
filtered — it is the single most common way a lead confirms an appointment.
The workflow JSON is generated, not hand-edited. A build script is the source of truth, and a test suite runs the real node code against real payloads.
node tools/verify-all.js # rebuild, then run every check
That suite caught six defects static review missed. One was
$json used in three all-items Code nodes, which n8n only defines in
per-item mode — and one of those was the error handler, so failures would have
vanished instead of being logged. Another was a booking body that Cal.com
rejects only when the model happens to be terse, which would have read as
intermittent. The last two were the open endpoints above.