Auto-text missed callers within 60 seconds: n8n workflow for service businesses (full JSON)

Sharing a workflow I built for service-based businesses. Missed calls are expensive when callers just move on to the next result.

**What it does:**

A missed-call webhook fires when the phone system registers no answer. The flow checks business hours, then texts the caller within 60 seconds – during hours with a “calling you back” message, after hours with the next-morning callback promise. Every missed call logs to a Google Sheet for the follow-up list.

Compatible with any phone provider that fires webhooks on missed calls: Twilio, Telnyx, OpenPhone, JustCall, RingCentral, Grasshopper.

**Nodes (7 in this version):**

  1. Webhook trigger (path: `/missed-call`, POST)
  2. IF: Has caller number? (guard against empty payloads)
  3. Set: Normalize Fields (extract caller_number across provider formats)
  4. IF: Business hours? (compares current hour to configurable 8am-5pm window)
  5. Twilio SMS – business hours message
  6. Twilio SMS – after-hours message
  7. Google Sheets – log caller, timestamp, hours flag, SMS sent

**Setup time: about 20 minutes** (fill in Twilio credentials, sheet ID, business name, hours range)

```json
{
“name”: “Missed Call Auto-Response”,
“nodes”: [
{
“id”: “1”,
“name”: “Call Missed Webhook”,
“type”: “n8n-nodes-base.webhook”,
“typeVersion”: 1,
“position”: [240, 300],
“parameters”: {
“httpMethod”: “POST”,
“path”: “missed-call”,
“responseMode”: “onReceived”,
“responseData”: “noData”
}
},
{
“id”: “2”,
“name”: “Has Caller Number?”,
“type”: “n8n-nodes-base.if”,
“typeVersion”: 1,
“position”: [460, 300],
“parameters”: {
“conditions”: {
“string”: [
{
“value1”: “={{ $json.caller_number || $json.From || $json.CallerNumber || ‘’ }}”,
“operation”: “isNotEmpty”
}
]
}
}
},
{
“id”: “3”,
“name”: “Normalize Fields”,
“type”: “n8n-nodes-base.set”,
“typeVersion”: 1,
“position”: [680, 260],
“parameters”: {
“values”: {
“string”: [
{
“name”: “caller_number”,
“value”: “={{ $json.caller_number || $json.From || $json.CallerNumber }}”
},
{
“name”: “call_id”,
“value”: “={{ $json.call_id || $json.CallSid || $json.call_uuid || ‘’ }}”
}
]
},
“options”: {}
}
},
{
“id”: “4”,
“name”: “Is Business Hours?”,
“type”: “n8n-nodes-base.if”,
“typeVersion”: 1,
“position”: [900, 260],
“parameters”: {
“conditions”: {
“number”: [
{
“value1”: “={{ new Date().getHours() }}”,
“operation”: “largerEqual”,
“value2”: 8
},
{
“value1”: “={{ new Date().getHours() }}”,
“operation”: “smaller”,
“value2”: 17
}
]
}
}
},
{
“id”: “5”,
“name”: “SMS During Hours”,
“type”: “n8n-nodes-base.twilio”,
“typeVersion”: 1,
“position”: [1120, 180],
“parameters”: {
“from”: “YOUR_TWILIO_NUMBER”,
“to”: “={{ $(‘Normalize Fields’).item.json.caller_number }}”,
“message”: “Hey, we missed your call just now. Giving you a ring back in a few minutes. - YOUR_BUSINESS_NAME”
},
“credentials”: {
“twilioApi”: { “id”: “1”, “name”: “Twilio account” }
}
},
{
“id”: “6”,
“name”: “SMS After Hours”,
“type”: “n8n-nodes-base.twilio”,
“typeVersion”: 1,
“position”: [1120, 360],
“parameters”: {
“from”: “YOUR_TWILIO_NUMBER”,
“to”: “={{ $(‘Normalize Fields’).item.json.caller_number }}”,
“message”: “Thanks for calling YOUR_BUSINESS_NAME. We are closed right now but will reach you first thing tomorrow morning.”
},
“credentials”: {
“twilioApi”: { “id”: “1”, “name”: “Twilio account” }
}
},
{
“id”: “7”,
“name”: “Log to Sheet”,
“type”: “n8n-nodes-base.googleSheets”,
“typeVersion”: 2,
“position”: [1340, 260],
“parameters”: {
“operation”: “append”,
“documentId”: “YOUR_GOOGLE_SHEET_ID”,
“sheetName”: “Missed Calls”,
“columns”: {
“mappingMode”: “defineBelow”,
“value”: {
“Timestamp”: “={{ $now.toISO() }}”,
“Caller Number”: “={{ $(‘Normalize Fields’).item.json.caller_number }}”,
“Call ID”: “={{ $(‘Normalize Fields’).item.json.call_id }}”,
“Business Hours”: “={{ $(‘Is Business Hours?’).item.json[‘Is Business Hours?’] }}”,
“SMS Sent”: “=true”
}
}
},
“credentials”: {
“googleSheetsOAuth2Api”: { “id”: “2”, “name”: “Google Sheets account” }
}
}
],
“connections”: {
“Call Missed Webhook”: { “main”: [[{ “node”: “Has Caller Number?”, “type”: “main”, “index”: 0 }]] },
“Has Caller Number?”: {
“main”: [
[{ “node”: “Normalize Fields”, “type”: “main”, “index”: 0 }],
[]
]
},
“Normalize Fields”: { “main”: [[{ “node”: “Is Business Hours?”, “type”: “main”, “index”: 0 }]] },
“Is Business Hours?”: {
“main”: [
[{ “node”: “SMS During Hours”, “type”: “main”, “index”: 0 }],
[{ “node”: “SMS After Hours”, “type”: “main”, “index”: 0 }]
]
},
“SMS During Hours”: { “main”: [[{ “node”: “Log to Sheet”, “type”: “main”, “index”: 0 }]] },
“SMS After Hours”: { “main”: [[{ “node”: “Log to Sheet”, “type”: “main”, “index”: 0 }]] }
},
“settings”: {
“executionOrder”: “v1”
}
}
```

**To adapt for other providers:** swap the Twilio node for the SMS provider of your choice (MessageBird, Vonage, AWS SNS) and adjust the payload field normalization in the Normalize Fields node. The webhook payload fields differ by provider – the normalization step handles `caller_number`, `From`, and `CallerNumber` which covers most of them.

If you want the full version with all 11 nodes, setup instructions for each phone provider’s webhook format, and a callback-list tracker tab pre-formatted, it is packaged up at: Whop: Start a Business, Learn a New Skill, & Build a Network | Whop

Happy to answer questions here about adapting the flow.

Welcome @cadence_flows!

The business-hours branching with the different message copy is a solid touch - callers get a response that matches the time they called rather than a generic “we’ll call you back.” The Google Sheets logging for follow-up is the right persistence layer for this.

One useful addition: a deduplication check at the top using Google Sheets lookup. If the same caller number appears in the sheet from within the last 30 minutes, skip sending another text. Service businesses with multiple unanswered calls in a row can otherwise end up spamming the same number.

Good catch. The 30-minute cooldown handles the back-to-back missed call case cleanly.

One addition worth pairing with it: a “contacted” status column alongside the timestamp. The cooldown prevents duplicate texts in a single call sequence, but without a status flag the workflow can still re-text someone who already responded and got a callback later in the day. With the column, the lookup becomes: if contacted=true, skip – regardless of how old the row is.

The dedup check also doubles as a lightweight CRM at that point. You know who got a text, who replied, and who is still uncontacted, all in the same sheet the workflow already writes to.