# An AI Voice Agent Is Just Two Webhooks: Wiring Retell to n8n in Production

> Source: <https://dev.to/nabeelbaghoor/an-ai-voice-agent-is-just-two-webhooks-wiring-retell-to-n8n-in-production-2afj>
> Published: 2026-07-24 21:45:22+00:00

A voice agent that answers the phone is the easy 20 percent. The interesting 80 percent is everything the call is supposed to trigger: check the calendar, write the CRM contact, send the confirmation text, tag the hot lead. New developers building their first voice agent almost always try to cram that logic into the voice prompt, and it works in the demo and falls apart in production.

I build AI voice agents for US clients, and the question I get most from other devs is some version of "okay, the agent talks, but how does it actually *do* anything?" In my stack the answer is always the same: Retell handles the conversation, n8n handles everything the conversation triggers. This is the exact integration I ship, including the parts that break once real calls start hitting it.

Almost everyone overcomplicates this. The entire integration is two arrows:

Get those two directions right and everything else is composition. The reason I keep the logic in n8n and not in the prompt is the same reason you do not hardcode business rules into your view layer: if you bake booking logic into the voice prompt, you have married the platform. Keep it in n8n and the voice layer becomes swappable. Switching or A/B testing platforms later is a change to one webhook, not a rewrite.

This is where most of the work lives. Retell can call your webhook on lifecycle events and on custom functions you define inside the agent.

**Set up the n8n side first.**

`localhost`

will not work. For local dev I tunnel with ngrok and swap the URL for the real one before going live.`200`

. For mid-call functions this response **Point Retell at it.** In the agent settings, set the webhook URL to the n8n production URL. Retell now POSTs a JSON body on each event. The payload carries an `event`

field (`call_started`

, `call_ended`

, `call_analyzed`

) and the full `call`

object with the transcript, metadata, and any dynamic variables you passed in.

A habit that pays off: put a **Switch** node right after the webhook that branches on `{{ $json.event }}`

. Each event wants different handling. `call_ended`

is usually where I write the contact and transcript to the CRM. `call_analyzed`

is where I read Retell's post-call analysis (sentiment, whether a booking happened, custom extraction fields) and route on it.

**The mid-call custom function.** This is the powerful part. Inside the agent you define a **custom function**, say `check_availability`

or `book_appointment`

. When the caller asks about a Tuesday slot, the agent calls that function, which hits your n8n webhook mid-conversation, waits, and speaks the result back:

``` php
Caller: "Do you have anything Tuesday afternoon?"
  -> Retell fires custom function check_availability -> n8n webhook
  -> n8n queries Google Calendar / GHL calendar
  -> n8n responds { "slots": ["2pm", "3:30pm"] }
  -> Agent: "I have 2pm or 3:30 on Tuesday, which works?"
```

The constraint that will bite you here is **latency**. The caller is sitting in silence while n8n runs. Keep mid-call workflows lean: one lookup, a fast response, no chained API calls that eat four seconds. If a step is genuinely slow, have the agent say "give me one moment" and design around it. Anything non-blocking, like sending the confirmation SMS or writing the CRM note, does not belong in the mid-call path. Move it to the `call_ended`

branch.

The reverse direction uses Retell's REST API. The most common use is placing an outbound call, which is the backbone of any missed-call callback system.

In n8n, an **HTTP Request** node:

`from_number`

(your registered Twilio number), the `to_number`

(the lead), the `agent_id`

, and a `retell_llm_dynamic_variables`

object carrying anything the agent should know before it dials: the caller's name, what they enquired about, the business name.Those dynamic variables are how you personalize a call without editing the agent. The prompt references `{{customer_name}}`

, n8n fills it at call time. One agent, infinitely reusable across contacts. If you have ever templated an email in code, it is the same idea pointed at a phone call.

Here is how the two directions combine in a real missed-call callback:

``` php
Missed call in GHL -> GHL webhook -> n8n
  -> n8n HTTP Request -> Retell create-phone-call (lead name + context)
  -> Retell dials the lead, agent talks
  -> mid-call: agent calls book_appointment -> n8n webhook -> GHL calendar -> responds
  -> call_ended -> Retell webhook -> n8n
  -> n8n writes transcript + outcome to the GHL contact, sends SMS confirmation
```

Every arrow in that diagram is one of the two webhook directions above. Nothing exotic. Just discipline about where each piece of logic lives.

`call_ended`

.`call_id`

so a duplicate event does not create a duplicate contact. Treat webhooks as at-least-once, like you would any queue.Once the two directions are solid, everything else is just adding nodes: swap the CRM, fire a Slack alert when a caller sounds angry, branch on the post-call analysis to tag hot leads. The voice agent stays dumb and simple, and the intelligence lives in n8n where you can see it, test it, and change it without ever touching the prompt. That separation is the whole trick, and it is why I have never had to rebuild a client's system just because they wanted to try a different voice platform.

I write up the rest of this stack, platform choice, the testing pipeline, and what these agents cost to run, over on [my blog](https://nabeelbaghoor.com/blog). If you are wiring your first one together, start with the two arrows and resist the urge to be clever inside the prompt.
