n8n 與 Twilio 未接來電回覆簡訊:為何 Dial action 優於狀態回調(附免費工作流程 JSON)
Missed-call text back in n8n and Twilio: why the Dial action beats the status callback (free workflow JSON)
作者提供以 n8n 和 Twilio 建立未接來電回覆簡訊的工作流程,透過 <Dial> 的 action callback 判斷來電是否未接。一般狀態回調可能把轉接後由手機語音信箱接聽的來電標為 completed,導致自動化不觸發;流程以短 timeout 配合回調,並由 n8n 回傳 TwiML。
A "missed-call text back" is simple to describe: when a call to a business number isn't answered, text the caller something like "Sorry we missed you, what can we help with?" Most tutorials build it on Twilio's call status callback and filter for no-answer.
That works until the business owner does the obvious thing and forwards the Twilio number to their mobile. If they don't pick up, the mobile's voicemail answers. From Twilio's point of view the forwarded call was answered, so the status is completed, and the automation never fires.
This post walks through an n8n workflow that avoids that problem by using the <Dial> verb's action callback with a short timeout. Twilio decides what counts as "missed," and n8n replies with TwiML in the same request. The full workflow JSON is linked at the end. It uses only core n8n nodes plus the official Twilio and Google Sheets nodes.
Architecture
Caller ──► Twilio number ──► TwiML Bin: <Dial timeout="12" action="…/webhook/missed-call">
│ rings your mobile
▼
Dial ends (completed | no-answer | busy | failed)
│ POST (form-encoded) to action URL
▼
n8n: Webhook ─► Settings ─► Respond to Webhook (TwiML) ─► IF missed? ─► IF business hours?
│ ├─► Daytime text ─┐
│ └─► After-hours ──┤
└─► NoOp (answered) ▼
Twilio: send SMS ─► Set row ─► Google Sheets append
1. Forward calls with a TwiML Bin
In the Twilio Console, create a TwiML Bin and set your number's A call comes in handler to it:
<Response>
<Dial timeout="12" action="https://YOUR-N8N-HOST/webhook/missed-call">
+1YOURMOBILE
</Dial>
</Response>
Two details matter here:
-
timeoutplus Twilio's buffer must be shorter than your voicemail delay. Twilio's docs note that it adds about five seconds to thetimeoutyou set, so12rings for roughly 17 seconds. Many mobiles send calls to voicemail after 20–25 seconds, but check yours. -
With an
actionURL, your response controls the rest of the call. Any verbs after<Dial>in the bin never run, because Twilio executes the TwiML returned by the action URL instead. That's why the n8n workflow has to respond with TwiML.
Twilio POSTs the action request as application/x-www-form-urlencoded. The fields we care about are DialCallStatus, From, To, CallSid and AccountSid.
2. Webhook node: respond later, from a node
Webhook node settings:
| Setting | Value |
|---|---|
| HTTP Method | POST |
| Path | missed-call |
| Respond | Using 'Respond to Webhook' Node |
n8n parses the form body into $json.body, so after the webhook an item looks roughly like this (values are placeholders):
{
"headers": { "x-twilio-signature": "…", "content-type": "application/x-www-form-urlencoded" },
"body": {
"AccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"CallSid": "CAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"From": "+15555550123",
"To": "+15555550100",
"DialCallStatus": "no-answer"
}
}
3. One Set node for everything the user edits
Put every user-editable value in a single Set node (v3.4) with Include Other Input Fields turned on, so body passes through:
businessName [Your Business Name]
bookingLink https://example.com/book
twilioNumber +15555550100
twilioAccountSid ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
openHour 8
closeHour 18
voiceMessage Sorry we missed your call. We'll text you in a moment.
Nothing downstream hardcodes a value, which also makes the workflow easy to share without leaking anything.
4. Answer Twilio immediately with TwiML
Twilio is waiting on the line, so respond before doing anything slow. Respond to Webhook with Respond With: Text, header Content-Type: text/xml, and this body expression:
{{ ['no-answer','busy','failed'].includes($json.body.DialCallStatus)
? '<Response><Say>' + $json.voiceMessage + '</Say><Hangup/></Response>'
: '<Response><Hangup/></Response>' }}
Respond to Webhook passes its input items through, so the workflow carries on after the HTTP response has gone back to Twilio. The caller hears the short message and hangs up while n8n sends the text.
If you'd rather offer voicemail in Twilio, swap the <Say> for <Say>…</Say><Record maxLength="60"/>. Because Twilio is now handling voicemail, nothing is lost by keeping the forward timeout short.
5. Gate the rest of the workflow
An IF node (v2) with four AND-ed conditions:
{{ $json.body.AccountSid }} // equals {{ $json.twilioAccountSid }}
{{ ['no-answer','busy','failed'].includes($json.body.DialCallStatus) }} // is true
{{ $json.body.From }} // starts with "+"
{{ $json.body.From }} // not equal to "+266696687"
- The status check drops
completed(answered) calls. - The two
Fromchecks drop withheld caller IDs. Twilio's changelog says these now arrive asanonymousin theFromfield. Older behaviour, still seen in forum posts, used the pseudo-number+266696687, so the workflow filters both. - The
AccountSidcheck stops casual noise from anyone who finds the URL. It is not authentication. An Account SID is an identifier, not a secret. For real protection, validate Twilio's signature (see Hardening below).
The false branch goes to a No Operation node, so answered calls end cleanly.
6. Business hours and the message
A second IF compares the current hour against the settings:
{{ $now.hour >= $json.openHour && $now.hour < $json.closeHour }}
$now uses the workflow time zone (Workflow → Settings), not necessarily the server's. Set it, or your "after hours" text goes out at lunchtime.
Each branch is a Set node that writes smsText, for example:
Hi, this is {{ $json.businessName }}. Sorry we missed your call! We're probably with a customer.
What can we help with? Reply here or book online: {{ $json.bookingLink }}. Reply STOP to opt out.
Both branches feed one Twilio node (Resource: SMS, Operation: Send):
From: {{ $json.twilioNumber }}
To: {{ $json.body.From }}
Message: {{ $json.smsText }}
Keep messages under 160 GSM-7 characters to stay in one segment. A curly apostrophe or an emoji switches the encoding to UCS-2 and drops the per-segment limit to 70 characters.
7. Log with paired items
After the Twilio node, $json is Twilio's API response (the message resource), not the call. Use n8n's paired-item lookup to reach back to earlier nodes when building the log row:
Time {{ $now.toFormat('yyyy-LL-dd HH:mm') }}
Caller {{ $('Missed call from Twilio?').item.json.body.From }}
Status {{ $('Missed call from Twilio?').item.json.body.DialCallStatus }}
Text sent {{ $('Send text to caller').item.json.body }}
Called back? No
That Set node has Include Other Input Fields off, so only these five columns reach Google Sheets → Append Row. The node uses Map Automatically against a Missed calls tab with matching headers.
Testing without making a phone call
Click Listen for test event on the Webhook node and POST a fake Dial result to the test URL:
curl -X POST "https://YOUR-N8N-HOST/webhook-test/missed-call" \
--data-urlencode "AccountSid=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
--data-urlencode "From=+15555550123" \
--data-urlencode "To=+15555550100" \
--data-urlencode "CallSid=CAtest" \
--data-urlencode "DialCallStatus=no-answer"
You should get the <Response><Say>… TwiML back. Use --data-urlencode, not -d. In form encoding, a bare + decodes to a space, so -d "From=+1555…" arrives as " 1555…" and fails the startsWith('+') check. Real Twilio requests encode it correctly. Hand-rolled tests are where this bites.
Then test end-to-end with real calls from a second phone:
- Answer the call. Expect no text.
- Let it ring out. Expect the text and a new row.
- Hang up after a couple of rings. See the caveat below.
- Call outside business hours. Expect the after-hours text.
Caveats worth knowing
-
Caller hangs up before the timeout. Twilio's
<Dial>docs say that if the initial caller hangs up, the session ends and the rest of the TwiML doesn't execute. Whether youractionURL still receives a request in that case is something to verify on your own number (test 3 above). If it doesn't, add the number's call status callback as a second trigger for the parent call and deduplicate onCallSidbefore texting. That catches callers who give up quickly, which are often the ones you most want to reach. - Landlines can't receive SMS. Twilio usually accepts the API request, and the message then fails delivery afterwards, which you'll see in Twilio's message logs. The row still gets logged, so you still call them back. If you validate numbers first with Twilio Lookup, set the Twilio node's On Error to Continue so a rejected number doesn't stop the log step.
- US texting compliance. Sending from a 10-digit US long code needs A2P 10DLC registration, and toll-free numbers need toll-free verification. Until that's approved, messages may be filtered. Keep the opt-out line in the text.
- Don't reuse these numbers for marketing. A one-to-one reply to someone who just called is expected. Bulk promotional texting has separate consent rules.
Hardening: validate X-Twilio-Signature
Twilio signs every webhook request. To check the signature:
- Take the full URL Twilio called.
- Append each POST parameter's name and value, sorted alphabetically by name, with no delimiters.
- Compute an HMAC-SHA1 of that string using your Auth Token as the key, and Base64-encode the result.
- Compare it with the
x-twilio-signatureheader.
The n8n Crypto node's HMAC action doesn't offer SHA1 in current versions, so use a Code node (Run Once for Each Item) right after the Webhook:
const crypto = require('crypto');
const url = 'https://YOUR-N8N-HOST/webhook/missed-call'; // exactly what Twilio calls
const authToken = 'YOUR_TWILIO_AUTH_TOKEN'; // see note below
const body = $json.body;
const data = url + Object.keys(body).sort().map(k => k + body[k]).join('');
const expected = crypto.createHmac('sha1', authToken).update(data, 'utf8').digest('base64');
return { json: { ...$json, signatureValid: expected === $json.headers['x-twilio-signature'] } };
Then add {{ $json.signatureValid }} is true to the IF node. On n8n Cloud, crypto is available in the Code node. On a self-hosted instance, you have to allow the built-in module first (NODE_FUNCTION_ALLOW_BUILTIN=crypto).
Two gotchas:
- The URL must be exactly what Twilio called. Behind a reverse proxy, make sure n8n's public webhook URL matches.
- Your Auth Token now lives in the workflow. That's fine for your own instance, but strip it before you share or export the workflow. On self-hosted n8n you can read it from an environment variable or n8n Variables instead of hardcoding it.
Get the workflow
- Workflow JSON (free, no credentials inside): https://smallbizautopilot.netlify.app/workflows/missed-call-text-back.n8n.json. Import it with Workflows → … → Import from File, fill in the Settings node, connect Twilio and Google Sheets, and follow the yellow sticky note.
- Plain-English version for non-developers, including Zapier and Make setups: https://smallbizautopilot.netlify.app/missed-call-text-back-automation/
If you've seen how Twilio behaves when the caller hangs up during a <Dial> with an action URL, I'd like to hear about it in the comments. That's the one behaviour I'd most like more real-world reports on.
I write practical automation guides for small businesses at Small Business Autopilot. This post was drafted with AI assistance. The workflow was validated against current n8n node definitions, and the Twilio behaviour described is from Twilio's <Dial> documentation.
— Jax, Small Business Autopilot
來源:DEV Community · dev.to