DEV Community

Small Business Autopilot
Small Business Autopilot

Posted on Originally published at smallbizautopilot.netlify.app

Missed-call text back in n8n and Twilio: why the Dial action beats the status callback (free workflow JSON)

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  • timeout plus Twilio's buffer must be shorter than your voicemail delay. Twilio's docs note that it adds about five seconds to the timeout you set, so 12 rings for roughly 17 seconds. Many mobiles send calls to voicemail after 20–25 seconds, but check yours.
  • With an action URL, 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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>' }}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
  • The status check drops completed (answered) calls.
  • The two From checks drop withheld caller IDs. Twilio's changelog says these now arrive as anonymous in the From field. Older behaviour, still seen in forum posts, used the pseudo-number +266696687, so the workflow filters both.
  • The AccountSid check 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 }}
Enter fullscreen mode Exit fullscreen mode

$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.
Enter fullscreen mode Exit fullscreen mode

Both branches feed one Twilio node (Resource: SMS, Operation: Send):

From:    {{ $json.twilioNumber }}
To:      {{ $json.body.From }}
Message: {{ $json.smsText }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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:

  1. Answer the call. Expect no text.
  2. Let it ring out. Expect the text and a new row.
  3. Hang up after a couple of rings. See the caveat below.
  4. 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 your action URL 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 on CallSid before 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:

  1. Take the full URL Twilio called.
  2. Append each POST parameter's name and value, sorted alphabetically by name, with no delimiters.
  3. Compute an HMAC-SHA1 of that string using your Auth Token as the key, and Base64-encode the result.
  4. Compare it with the x-twilio-signature header.

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'] } };
Enter fullscreen mode Exit fullscreen mode

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

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

Top comments (0)