The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"name": "[DEV] sarah / outbound-caller-bulletproof",
"description": null,
"active": true,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "initiate-call-v2",
"responseMode": "responseNode",
"options": {},
"authentication": "headerAuth"
},
"id": "trigger-webhook",
"name": "Webhook: Initiate Call",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
240,
304
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// ============================================\n// BULLETPROOF INPUT VALIDATION & SANITIZATION\n// ============================================\n\nconst rawData = $input.item.json;\nconst input = rawData.body || rawData;\n\n// Validation result object\nconst validation = {\n isValid: true,\n errors: [],\n warnings: [],\n sanitized: {}\n};\n\n// ============================================\n// 1. REQUIRED FIELD VALIDATION\n// ============================================\n\n// Phone number validation\nif (!input.phone) {\n validation.isValid = false;\n validation.errors.push('Missing required field: phone');\n} else {\n // Sanitize phone number - remove all non-digit chars except +\n let phone = String(input.phone).replace(/[^\\d+]/g, '');\n \n // Ensure E.164 format\n if (!phone.startsWith('+')) {\n // Assume US number if no country code\n if (phone.length === 10) {\n phone = '+1' + phone;\n validation.warnings.push('Added +1 country code to phone number');\n } else if (phone.length === 11 && phone.startsWith('1')) {\n phone = '+' + phone;\n validation.warnings.push('Added + prefix to phone number');\n } else {\n validation.isValid = false;\n validation.errors.push('Invalid phone format. Use E.164 format: +1234567890');\n }\n }\n \n // Validate E.164 length (max 15 digits including country code)\n if (phone.length < 10 || phone.length > 16) {\n validation.isValid = false;\n validation.errors.push('Phone number length invalid. Must be 10-15 digits.');\n }\n \n validation.sanitized.phone = phone;\n}\n\n// Customer name validation\nif (!input.customer_name) {\n validation.isValid = false;\n validation.errors.push('Missing required field: customer_name');\n} else {\n // Sanitize name - remove potentially harmful characters\n const name = String(input.customer_name)\n .replace(/[<>{}\\[\\]\\\\]/g, '')\n .trim()\n .substring(0, 100); // Max 100 chars\n \n if (name.length < 1) {\n validation.isValid = false;\n validation.errors.push('Customer name cannot be empty');\n }\n \n validation.sanitized.customer_name = name;\n validation.sanitized.customer_first_name = name.split(' ')[0] || name;\n}\n\n// ============================================\n// 2. OPTIONAL FIELD SANITIZATION\n// ============================================\n\n// Account number - alphanumeric only\nif (input.account_number) {\n validation.sanitized.account_number = String(input.account_number)\n .replace(/[^a-zA-Z0-9-_]/g, '')\n .substring(0, 50);\n}\n\n// Account type - whitelist allowed values\nconst allowedAccountTypes = ['standard', 'premium', 'enterprise', 'trial', 'vip'];\nif (input.account_type) {\n const accountType = String(input.account_type).toLowerCase();\n validation.sanitized.account_type = allowedAccountTypes.includes(accountType) \n ? accountType \n : 'standard';\n if (!allowedAccountTypes.includes(accountType)) {\n validation.warnings.push(`Unknown account_type '${input.account_type}', defaulting to 'standard'`);\n }\n} else {\n validation.sanitized.account_type = 'standard';\n}\n\n// Call purpose - sanitize text\nif (input.call_purpose) {\n validation.sanitized.call_purpose = String(input.call_purpose)\n .replace(/[<>{}\\[\\]\\\\]/g, '')\n .trim()\n .substring(0, 200);\n} else {\n validation.sanitized.call_purpose = 'general inquiry';\n}\n\n// Customer ID\nif (input.customer_id) {\n validation.sanitized.customer_id = String(input.customer_id)\n .replace(/[^a-zA-Z0-9-_]/g, '')\n .substring(0, 100);\n} else {\n validation.sanitized.customer_id = validation.sanitized.phone;\n}\n\n// ============================================\n// 3. DYNAMIC VARIABLES (pass-through with sanitization)\n// ============================================\n\nif (input.custom_variables && typeof input.custom_variables === 'object') {\n validation.sanitized.custom_variables = {};\n for (const [key, value] of Object.entries(input.custom_variables)) {\n // Sanitize key and value\n const safeKey = String(key).replace(/[^a-zA-Z0-9_]/g, '').substring(0, 50);\n let safeValue = value;\n \n if (typeof value === 'string') {\n safeValue = value.replace(/[<>{}\\[\\]\\\\]/g, '').substring(0, 500);\n } else if (typeof value === 'number' || typeof value === 'boolean') {\n safeValue = value;\n } else {\n safeValue = String(value).substring(0, 500);\n }\n \n validation.sanitized.custom_variables[safeKey] = safeValue;\n }\n}\n\n// ============================================\n// 4. FIRST MESSAGE OVERRIDE\n// ============================================\n\nif (input.first_message_override) {\n validation.sanitized.first_message_override = String(input.first_message_override)\n .replace(/[<>{}\\[\\]\\\\]/g, '')\n .trim()\n .substring(0, 500);\n}\n\n// ============================================\n// 5. REQUEST METADATA\n// ============================================\n\nvalidation.metadata = {\n request_id: `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,\n received_at: new Date().toISOString(),\n source_ip: rawData.headers?.['cf-connecting-ip'] || rawData.headers?.['x-forwarded-for'] || 'unknown',\n user_agent: rawData.headers?.['user-agent'] || 'unknown'\n};\n\n// ============================================\n// 6. AGENT OVERRIDE (new \u2014 added for Lead Intake / multi-agent support)\n// ============================================\nif (input.agent_id) {\n const aid = String(input.agent_id).replace(/[^a-zA-Z0-9_]/g, '').substring(0, 100);\n if (aid.startsWith('agent_')) {\n validation.sanitized.agent_id = aid;\n } else {\n validation.warnings.push('Ignored agent_id override that did not start with \"agent_\"');\n }\n}\nif (input.agent_phone_number_id) {\n const pid = String(input.agent_phone_number_id).replace(/[^a-zA-Z0-9_]/g, '').substring(0, 100);\n if (pid.startsWith('phnum_')) {\n validation.sanitized.agent_phone_number_id = pid;\n } else {\n validation.warnings.push('Ignored agent_phone_number_id override that did not start with \"phnum_\"');\n }\n}\n\nreturn {\n json: {\n validation,\n original_input: input\n }\n};"
},
"id": "validate-and-sanitize",
"name": "Validate & Sanitize Input",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
464,
304
]
},
{
"parameters": {
"conditions": {
"options": {
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "is-valid",
"leftValue": "={{ $json.validation.isValid }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
]
},
"options": {}
},
"id": "check-validation",
"name": "Validation Passed?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
688,
304
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={\n \"success\": false,\n \"validation_error\": true,\n \"call_attempted\": false,\n \"call_initiated\": false,\n \"call_failed\": false,\n \"client_data_injected\": false,\n \"error_code\": \"VALIDATION_ERROR\",\n \"message\": \"Input validation failed\",\n \"errors\": {{ JSON.stringify($json.validation.errors) }},\n \"warnings\": {{ JSON.stringify($json.validation.warnings) }},\n \"request_id\": \"{{ $json.validation.metadata.request_id }}\",\n \"timestamp\": \"{{ $json.validation.metadata.received_at }}\"\n}",
"options": {
"responseCode": 400
}
},
"id": "respond-validation-error",
"name": "Respond: Validation Error",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
912,
464
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// ============================================\n// BUILD ELEVENLABS API PAYLOAD\n// ============================================\n\nconst validation = $input.item.json.validation;\nconst s = validation.sanitized;\n\n// Build dynamic_variables object\nconst dynamic_variables = {\n customer_name: s.customer_name,\n customer_first_name: s.customer_first_name,\n account_type: s.account_type,\n call_purpose: s.call_purpose\n};\n\n// Add optional fields if present\nif (s.account_number) {\n dynamic_variables.account_number = s.account_number;\n}\n\n// Merge custom variables\nif (s.custom_variables) {\n Object.assign(dynamic_variables, s.custom_variables);\n}\n\n// Build the payload\nconst payload = {\n agent_id: s.agent_id || '<REDACTED:elevenlabs-agent-id>', // Wranngle Lead Qualifier\n agent_phone_number_id: s.agent_phone_number_id || '<REDACTED:elevenlabs-phone-id>',\n to_number: s.phone,\n conversation_initiation_client_data: {\n user_id: s.customer_id,\n dynamic_variables: dynamic_variables\n }\n};\n\n// Add first message override if specified\nif (s.first_message_override) {\n payload.conversation_initiation_client_data.conversation_config_override = {\n agent: {\n first_message: s.first_message_override\n }\n };\n}\n\nreturn {\n json: {\n payload,\n metadata: validation.metadata,\n sanitized: s,\n retry_count: 0,\n max_retries: 3\n }\n};"
},
"id": "build-payload",
"name": "Build ElevenLabs Payload",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
912,
208
]
},
{
"parameters": {
"method": "POST",
"url": "https://api.elevenlabs.io/v1/convai/twilio/outbound-call",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.payload) }}",
"options": {
"response": {
"response": {
"fullResponse": true,
"neverError": true
}
}
}
},
"id": "call-elevenlabs",
"name": "ElevenLabs: Initiate Call",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1120,
208
],
"continueOnFail": true,
"onError": "continueRegularOutput"
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// ============================================\n// CLASSIFY API RESPONSE & DETERMINE ACTION\n// ============================================\n\nconst response = $input.item.json;\nconst prevData = $('Build ElevenLabs Payload').item.json;\n\nconst result = {\n status: 'unknown',\n action: 'none',\n error_code: null,\n error_message: null,\n is_retryable: false,\n retry_delay_ms: 0,\n response_data: null,\n metadata: prevData.metadata,\n sanitized: prevData.sanitized,\n retry_count: prevData.retry_count,\n max_retries: prevData.max_retries,\n payload: prevData.payload\n};\n\n// Check for execution error (network, timeout, etc)\nif (response.error) {\n result.status = 'error';\n result.error_code = 'NETWORK_ERROR';\n result.error_message = response.error.message || 'Network request failed';\n result.is_retryable = true;\n result.retry_delay_ms = Math.min(1000 * Math.pow(2, prevData.retry_count), 30000); // Exponential backoff, max 30s\n result.action = prevData.retry_count < prevData.max_retries ? 'retry' : 'fail';\n return { json: result };\n}\n\nconst statusCode = response.statusCode || response.status;\nconst body = response.body || response.data || response;\n\n// ============================================\n// SUCCESS RESPONSES (2xx)\n// ============================================\nif (statusCode >= 200 && statusCode < 300) {\n if (body.success === true) {\n result.status = 'success';\n result.action = 'complete';\n result.response_data = {\n conversation_id: body.conversation_id,\n call_sid: body.callSid,\n message: body.message\n };\n return { json: result };\n }\n // API returned 200 but success=false\n result.status = 'api_error';\n result.error_code = 'API_REJECTION';\n result.error_message = body.message || 'API returned success=false';\n result.is_retryable = false;\n result.action = 'fail';\n return { json: result };\n}\n\n// ============================================\n// RATE LIMIT (429)\n// ============================================\nif (statusCode === 429) {\n result.status = 'rate_limited';\n result.error_code = 'RATE_LIMIT';\n \n // Check type of rate limit\n if (body.detail?.includes('too_many_concurrent_requests')) {\n result.error_message = 'Concurrent request limit exceeded';\n result.retry_delay_ms = 5000; // Wait 5 seconds\n } else if (body.detail?.includes('system_busy')) {\n result.error_message = 'ElevenLabs system busy';\n result.retry_delay_ms = 2000; // Shorter wait\n } else {\n result.error_message = body.detail || 'Rate limit exceeded';\n result.retry_delay_ms = 10000; // Default 10 seconds\n }\n \n result.is_retryable = true;\n result.action = prevData.retry_count < prevData.max_retries ? 'retry' : 'fail';\n return { json: result };\n}\n\n// ============================================\n// CLIENT ERRORS (4xx)\n// ============================================\nif (statusCode >= 400 && statusCode < 500) {\n result.status = 'client_error';\n \n switch (statusCode) {\n case 400:\n result.error_code = 'BAD_REQUEST';\n result.error_message = body.detail || body.message || 'Invalid request';\n result.is_retryable = false;\n break;\n case 401:\n result.error_code = 'UNAUTHORIZED';\n result.error_message = 'Invalid or missing API key';\n result.is_retryable = false;\n break;\n case 403:\n result.error_code = 'FORBIDDEN';\n result.error_message = 'Access denied - check permissions';\n result.is_retryable = false;\n break;\n case 404:\n result.error_code = 'NOT_FOUND';\n result.error_message = 'Agent or phone number not found';\n result.is_retryable = false;\n break;\n case 422:\n result.error_code = 'VALIDATION_ERROR';\n // Check for missing dynamic variables error\n if (body.detail?.includes('Missing required dynamic variables')) {\n result.error_message = body.detail;\n } else {\n result.error_message = body.detail || 'Request validation failed';\n }\n result.is_retryable = false;\n break;\n default:\n result.error_code = `HTTP_${statusCode}`;\n result.error_message = body.detail || body.message || 'Client error';\n result.is_retryable = false;\n }\n \n result.action = 'fail';\n return { json: result };\n}\n\n// ============================================\n// SERVER ERRORS (5xx)\n// ============================================\nif (statusCode >= 500) {\n result.status = 'server_error';\n result.error_code = `HTTP_${statusCode}`;\n result.error_message = body.detail || body.message || 'ElevenLabs server error';\n result.is_retryable = true;\n result.retry_delay_ms = Math.min(2000 * Math.pow(2, prevData.retry_count), 60000); // Longer backoff for server errors\n result.action = prevData.retry_count < prevData.max_retries ? 'retry' : 'fail';\n return { json: result };\n}\n\n// Unknown status\nresult.error_code = 'UNKNOWN_STATUS';\nresult.error_message = `Unexpected status code: ${statusCode}`;\nresult.action = 'fail';\nreturn { json: result };"
},
"id": "classify-response",
"name": "Classify API Response",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1344,
208
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"leftValue": "={{ $json.action }}",
"rightValue": "complete",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "complete"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"leftValue": "={{ $json.action }}",
"rightValue": "retry",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "retry"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"leftValue": "={{ $json.action }}",
"rightValue": "fail",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "fail"
}
]
},
"options": {
"fallbackOutput": "none"
}
},
"id": "route-action",
"name": "Route by Action",
"type": "n8n-nodes-base.switch",
"typeVersion": 3,
"position": [
1568,
208
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={\n \"success\": true,\n \"call_initiated\": true,\n \"call_id\": \"{{ $json.response_data.conversation_id }}\",\n \"conversation_id\": \"{{ $json.response_data.conversation_id }}\",\n \"call_sid\": \"{{ $json.response_data.call_sid }}\",\n \"client_data_injected\": true,\n \"initiated\": true,\n \"call_attempted\": true,\n \"call_failed\": false,\n \"retry_count\": {{ $json.retry_count }},\n \"retries_exhausted\": false,\n \"sanitized_phone\": \"{{ $json.sanitized.phone }}\",\n \"message\": \"Call initiated successfully\",\n \"customer\": {\n \"phone\": \"{{ $json.sanitized.phone }}\",\n \"name\": \"{{ $json.sanitized.customer_name }}\"\n },\n \"request_id\": \"{{ $json.metadata.request_id }}\",\n \"timestamp\": \"{{ $now.toISO() }}\"\n}",
"options": {
"responseCode": 200
}
},
"id": "respond-success",
"name": "Respond: Call Initiated",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1808,
112
]
},
{
"parameters": {
"resume": "timeInterval",
"amount": "={{ Math.ceil($json.retry_delay_ms / 1000) }}",
"unit": "seconds"
},
"id": "wait-for-retry",
"name": "Wait for Retry",
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [
1808,
208
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "payload",
"name": "payload",
"value": "={{ $json.payload }}",
"type": "object"
},
{
"id": "metadata",
"name": "metadata",
"value": "={{ $json.metadata }}",
"type": "object"
},
{
"id": "sanitized",
"name": "sanitized",
"value": "={{ $json.sanitized }}",
"type": "object"
},
{
"id": "retry_count",
"name": "retry_count",
"value": "={{ $json.retry_count + 1 }}",
"type": "number"
},
{
"id": "max_retries",
"name": "max_retries",
"value": "={{ $json.max_retries }}",
"type": "number"
},
{
"id": "last_error",
"name": "last_error",
"value": "={{ $json.error_message }}",
"type": "string"
}
]
},
"options": {}
},
"id": "increment-retry",
"name": "Increment Retry Counter",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
2032,
208
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={\n \"success\": false,\n \"call_initiated\": false,\n \"call_id\": null,\n \"client_data_injected\": true,\n \"initiated\": false,\n \"call_attempted\": true,\n \"call_failed\": true,\n \"retries_exhausted\": {{ $json.retry_count >= $json.max_retries }},\n \"retry_count\": {{ $json.retry_count }},\n \"error_code\": \"{{ $json.error_code }}\",\n \"message\": \"{{ $json.error_message }}\",\n \"is_retryable\": {{ $json.is_retryable }},\n \"sanitized_phone\": \"{{ $json.sanitized.phone }}\",\n \"customer\": {\n \"phone\": \"{{ $json.sanitized.phone }}\",\n \"name\": \"{{ $json.sanitized.customer_name }}\"\n },\n \"request_id\": \"{{ $json.metadata.request_id }}\",\n \"timestamp\": \"{{ $now.toISO() }}\"\n}",
"options": {
"responseCode": "={{ ['RATE_LIMIT'].includes($json.error_code) ? 429 : ['UNAUTHORIZED', 'FORBIDDEN'].includes($json.error_code) ? 403 : ['VALIDATION_ERROR', 'BAD_REQUEST'].includes($json.error_code) ? 400 : ['NOT_FOUND'].includes($json.error_code) ? 404 : 500 }}"
}
},
"id": "respond-failure",
"name": "Respond: Call Failed",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1808,
352
]
}
],
"connections": {
"Webhook: Initiate Call": {
"main": [
[
{
"node": "Validate & Sanitize Input",
"type": "main",
"index": 0
}
]
]
},
"Validate & Sanitize Input": {
"main": [
[
{
"node": "Validation Passed?",
"type": "main",
"index": 0
}
]
]
},
"Validation Passed?": {
"main": [
[
{
"node": "Build ElevenLabs Payload",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond: Validation Error",
"type": "main",
"index": 0
}
]
]
},
"Build ElevenLabs Payload": {
"main": [
[
{
"node": "ElevenLabs: Initiate Call",
"type": "main",
"index": 0
}
]
]
},
"ElevenLabs: Initiate Call": {
"main": [
[
{
"node": "Classify API Response",
"type": "main",
"index": 0
}
]
]
},
"Classify API Response": {
"main": [
[
{
"node": "Route by Action",
"type": "main",
"index": 0
}
]
]
},
"Route by Action": {
"main": [
[
{
"node": "Respond: Call Initiated",
"type": "main",
"index": 0
}
],
[
{
"node": "Wait for Retry",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond: Call Failed",
"type": "main",
"index": 0
}
]
]
},
"Wait for Retry": {
"main": [
[
{
"node": "Increment Retry Counter",
"type": "main",
"index": 0
}
]
]
},
"Increment Retry Counter": {
"main": [
[
{
"node": "ElevenLabs: Initiate Call",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"callerPolicy": "workflowsFromSameOwner",
"availableInMCP": false
},
"tags": [
"DEV"
]
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
[DEV] sarah / outbound-caller-bulletproof. Uses httpRequest. Webhook trigger; 12 nodes.
Source: https://github.com/wranngle/n8n_showcase/blob/main/workflows/sarah/outbound-caller-bulletproof.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This n8n template provides enterprise-level version control for your workflows using GitHub integration. Stop losing hours to broken workflows and manual exports – get proper commit history, visual di
This flow creates dummy files for every item added in your *Arrs (Radarr/Sonarr) with the tag .
eek-Go v2 (Batch-Then-Review). Uses httpRequest. Webhook trigger; 75 nodes.
This workflow receives webhook requests from a content calendar and uses the X API v2 to publish text posts, threads, image/video posts, and polls, as well as delete existing posts and run a credentia
This workflow acts as a central API gateway for all technical indicator agents in the Binance Spot Market Quant AI system. It listens for incoming webhook requests and dynamically routes them to the c