FA©26
14 July 2026·38 min readRetell AIGoHighLeveln8nvoice agentsSupabase

Retell AI and GoHighLevel in Production: Dynamic Variables, Transfers, CRM Sync, and Retry-Safe Webhooks

Why did the caller hear one appointment time while HighLevel saved another?

Because the spoken value and the persisted value came from different sources of truth.

A common Retell and GoHighLevel workflow looks harmless. The agent asks for a preferred time. A custom function checks a HighLevel calendar. The function returns available slots. The agent confirms one. Another function creates the appointment. After the call, a webhook updates the contact and opportunity.

The trouble begins when each step carries a display string instead of a canonical slot identifier. The availability function returns Tuesday at 10:30 AM. The model repeats it. The booking function parses that text in a server timezone, converts it, and sends another timestamp to HighLevel. The caller and CRM now have two interpretations of the same phrase.

I never let the model create the appointment from a free-form time string after availability has been resolved. The availability service returns a stable slot token tied to tenant, calendar, timezone, start timestamp, duration, and an expiry. The agent can speak the friendly label, but the booking function submits the token.

{
  "slots": [
    {
      "slot_token": "[SIGNED OPAQUE TOKEN]",
      "spoken_label": "Tuesday, August 11 at 10:30 AM Central Time",
      "starts_at_utc": "2026-08-11T15:30:00Z",
      "timezone": "America/Chicago",
      "calendar_id": "[HIGHLEVEL CALENDAR ID]",
      "expires_at": "[TIMESTAMP]"
    }
  ]
}

The token is signed or stored server-side. When the agent calls book_appointment, the backend validates that the slot belongs to the active tenant and caller, has not expired, and still exists. It then writes the exact UTC timestamp represented by the token.

const request = bookingSchema.parse(input.args);
const slot = await slotStore.resolve(request.slotToken);

if (slot.tenantId !== context.tenantId) {
  throw new Error("SLOT_TENANT_MISMATCH");
}

if (slot.expiresAt <= new Date()) {
  return {
    status: "slot_expired",
    spoken_message: "That time is no longer available. I will check again.",
  };
}

After HighLevel returns the created appointment, the function response contains the provider appointment ID, confirmed UTC time, and one final spoken label generated from the saved timestamp. The agent repeats that response. It does not reuse the earlier candidate label.

{
  "status": "confirmed",
  "appointment_id": "[HIGHLEVEL APPOINTMENT ID]",
  "confirmed_start_utc": "2026-08-11T15:30:00Z",
  "spoken_confirmation": "You are confirmed for Tuesday, August 11 at 10:30 AM Central Time."
}

[DIAGRAM] Draw availability lookup returning a canonical slot token and friendly label, Retell speaking the label, the booking function accepting only the token, HighLevel saving the UTC timestamp, and the final spoken confirmation being generated from the saved appointment.

The rule is strict: the agent may discuss a proposed value. It may only confirm a value returned from the successful write.

What are you really integrating when you connect Retell to HighLevel?

You are connecting at least six different state machines.

Retell owns call setup, agent selection, conversation state, custom functions, transfers, recordings, transcripts, and post-call analysis. HighLevel owns contacts, custom fields, conversations, calendars, appointments, opportunities, workflows, users, and location-level authorization. n8n or Make may own orchestration. Supabase or Postgres owns correlation, idempotency, and cross-platform state. Your telephony path owns caller identity and transfer behavior. The client's operating rules decide what a “qualified lead” or “booked appointment” actually means.

A platform integration page can connect accounts. It cannot define those business contracts for you.

For an inbound call, Retell can invoke a phone-number-level inbound webhook before the call object exists. The current Retell inbound call webhook documentation says the webhook has a 10-second timeout and can be retried up to three times when it does not receive a successful response. The request includes the inbound and destination numbers and can accept a response that selects an agent or version, sets dynamic variables and metadata, applies per-call overrides, or rejects the call.

That hook is a routing decision under a hard time budget. It is not the place to run a long n8n branch, search multiple CRM objects, call an enrichment service, and ask a model to summarize the account.

During the call, Retell custom functions can call your APIs. Retell's current custom function documentation lists a default timeout of 120,000 milliseconds and optional retries through max_retry. The retry count can be set from 0 to 5. Retell warns that retries repeat the request and should only be enabled for idempotent endpoints. The timeout applies per attempt, so a high timeout plus retries can produce an unacceptable caller wait even if it is technically allowed.

After the call, Retell sends call-event webhooks such as started, ended, and analyzed events. HighLevel may send its own contact, opportunity, task, appointment, or conversation events. Those streams can arrive at different times and repeat.

HighLevel authorization adds another state machine. Its current documentation distinguishes Private Integration Tokens for internal, limited use from OAuth 2.0 for public or multi-account integrations with webhooks and custom modules. OAuth access tokens are documented as valid for one day, with refresh tokens valid for one year unless used and then replaced by another one-year refresh token. Current V2 rate limits are documented as 100 requests per 10 seconds and 200,000 per day for each Marketplace app per location or company resource. See the HighLevel authorization guide and OAuth FAQ.

The integration needs a canonical call record that joins all of this:

create table voice_calls (
  call_id text primary key,
  tenant_id uuid not null,
  location_id text not null,
  retell_agent_id text,
  retell_agent_version integer,
  direction text not null,
  caller_e164 text,
  ghl_contact_id text,
  correlation_id uuid not null unique,
  call_status text not null,
  business_outcome text,
  appointment_id text,
  opportunity_id text,
  started_at timestamptz,
  ended_at timestamptz,
  analyzed_at timestamptz,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

The value of the architecture is not that data moves between two products. It is that every product can be reconciled to one business outcome.

What does a broken voice-to-CRM workflow cost?

A broken form integration can wait in a queue. A broken voice integration makes the caller wait, speak again, or act on wrong information.

The direct cost is a failed call outcome. The agent cannot find the contact, cannot retrieve the right context, offers an unavailable appointment, transfers to the wrong number, or reports success when the CRM write failed.

The next cost is false success. Retell may complete the conversation and generate a transcript. n8n may show successful HTTP nodes. HighLevel may have a contact update. None of those proves that the caller received the promised business result.

Take a qualification call. The agent asks six questions and calls a function that updates custom fields after each answer. The third update times out after HighLevel accepts it. Retell retries the function because max_retry is enabled. The second request updates again and triggers a HighLevel workflow twice. The workflow creates two opportunities and sends two internal alerts. The call itself sounds normal.

Another cost is client churn. Agencies sell answered calls, qualified opportunities, appointments, or reduced response time. A voice agent that produces ambiguous records forces staff to listen to recordings and manually verify every booking. The client ends up paying for software and human cleanup.

There is also task and API cost. Every Retell retry can repeat n8n executions, Supabase writes, HighLevel API calls, and downstream workflows. HighLevel rate limits are scoped per app and location or company resource, so one noisy sub-account can use its available burst capacity and create delays inside that tenant. A retry loop should not become a client-wide outage.

Security failures carry another cost. A public custom-function endpoint that trusts a JSON body can be called by anyone. A HighLevel webhook handler that accepts an unsigned payload can create contacts or move opportunities. Retell documents X-Retell-Signature verification against the raw request body and a five-minute timestamp check for manual verification. HighLevel's current webhook guide says X-GHL-Signature uses Ed25519 and that the legacy X-WH-Signature is scheduled for deprecation on September 1, 2026. Both integrations require raw payload handling before JSON parsing.

Finally, debugging voice incidents is expensive. You need the audio, transcript, agent version, dynamic variables, function invocation, function response, HighLevel request, HighLevel response, webhook delivery, and final CRM state. Without correlation, the team searches phone numbers and timestamps across dashboards.

I record outcome evidence separately from execution status:

{
  "call_id": "[RETELL CALL ID]",
  "correlation_id": "[UUID]",
  "expected_outcome": "appointment_confirmed",
  "spoken_confirmation": "[TRANSCRIPT EXCERPT REFERENCE]",
  "provider_write": {
    "system": "highlevel",
    "appointment_id": "[ID]",
    "start_utc": "[TIMESTAMP]",
    "status": "confirmed"
  },
  "consistency_check": "pass",
  "manual_review_reason": null
}

[REAL NUMBER] Insert actual call volume, function failure rate, duplicate CRM writes, booking mismatches, transfer failures, manual review time, and client-reported incidents. Label unavailable figures as unverified.

The expensive failure is not a red node. It is a green call whose promised outcome does not exist.

Why does the obvious direct integration break?

Because it treats real-time conversation as a sequence of independent app actions.

The obvious build starts with a Retell tool pointed directly at an n8n webhook. n8n searches HighLevel by phone number, updates the contact, creates an appointment, and returns a sentence. Retell speaks the sentence. After the call, another n8n workflow writes the summary.

That can work in a demo. It breaks when identity, time, retries, or state become ambiguous.

Phone number matching is not identity. Shared business numbers, family phones, recycled numbers, formatting differences, caller-ID blocking, forwarded calls, and multiple locations can all produce the wrong contact. A query that returns the first matching contact is not a tenant-safe lookup.

Dynamic variables are not a database. Retell supports per-call values in prompts, begin messages, tools, transfer numbers, and webhook URLs. For outbound calls, current documentation says retell_llm_dynamic_variables values must be strings. Missing variables remain in raw curly-brace form unless defaults or defensive prompt logic handle them. If the integration passes null, empty string, and missing keys without a contract, the spoken prompt can expose {{customer_name}} or silently omit critical context.

Custom functions are not transactions. Retell can retry a failed custom function, and the endpoint may continue running even when the caller interrupts. Retell's docs state that an interrupted function request is not cancelled and can still complete, save response variables, and perform side effects. If the function creates an appointment or sends a message, interruption is not rollback.

HighLevel contacts and appointments are not immediately consistent with every downstream workflow. A successful API response can trigger automation that changes the same record. A later webhook may reflect a newer state than the function response. Blindly applying events in arrival order can move the record backward.

The direct pattern also couples voice latency to CRM latency. The Retell function waits while n8n starts, refreshes a HighLevel token, follows multiple branches, performs API calls, and formats a response. The default 120-second custom-function timeout is not a target. A caller will notice far earlier.

Direct secrets create another weakness. Teams often place a HighLevel Private Integration Token or OAuth access token inside an n8n credential and use a generic HTTP Request node. That may be acceptable for one internal sub-account. It is not a strong multi-tenant architecture. Public or multi-account work needs per-location OAuth state, token rotation, scopes, and revocation handling.

The failure mechanism is shared mutable state without one owner. Retell, n8n, and HighLevel each know part of the truth. None can enforce the full business invariant.

Weak path:
Retell function
  -> n8n webhook
  -> search contact
  -> create appointment
  -> return text
  -> later webhook writes summary

Missing controls:
verified caller and tenant
idempotency key
canonical slot token
state transition check
provider outcome reconciliation
signature verification at ingress

[DIAGRAM] Draw the direct Retell-to-n8n-to-HighLevel path and mark every unowned boundary: caller identity, tenant selection, credential refresh, duplicate function call, uncertain appointment outcome, delayed webhook, and stale summary update.

The direct integration breaks because every node assumes the prior node's success means more than it actually does.

What architecture actually works for a production voice agent?

I use a thin real-time API in front of slower orchestration.

The API is usually a Next.js service, Express service, or small Node application. It verifies Retell signatures, resolves tenant and call context, enforces schemas, reads and writes Postgres, and exposes narrow function endpoints. It can invoke HighLevel through a tenant-aware adapter. n8n handles asynchronous follow-up, enrichment, notifications, and operator-visible workflows.

The architecture has four lanes.

The first lane is inbound call admission. Retell calls the phone-number inbound webhook. The API verifies the signature, resolves the destination number to a tenant, performs a fast contact and routing lookup, and returns agent selection, metadata, and dynamic variables within the 10-second Retell timeout. It does not wait for enrichment.

The second lane is live function execution. Retell calls narrow endpoints such as find_available_slots, book_slot, qualify_lead, or request_transfer. Each endpoint verifies the raw body, resolves the active call, validates arguments, applies idempotency, and returns a small JSON object the agent can speak or map to response variables.

The third lane is event ingestion. Retell call events and HighLevel webhooks arrive at verified endpoints, are written to durable inbox tables, and receive fast acknowledgements. Workers normalize and process them. The inbox deduplicates events and preserves raw payloads for replay within retention policy.

The fourth lane is asynchronous operations. n8n consumes normalized jobs to send internal alerts, attach summaries, create follow-up tasks, perform enrichment, and update reporting. A slow Slack post cannot hold the caller's booking response.

Inbound call
  -> signed ingress API
  -> tenant and contact resolution
  -> Retell agent plus variables

Live function
  -> signed function API
  -> call state and idempotency
  -> HighLevel adapter
  -> canonical response

Event webhooks
  -> signed event ingress
  -> Postgres inbox
  -> worker and reconciliation
  -> n8n asynchronous workflows

The database holds normalized state:

create table voice_function_calls (
  function_call_key text primary key,
  call_id text not null references voice_calls(call_id),
  function_name text not null,
  argument_hash text not null,
  status text not null check (status in (
    'started', 'succeeded', 'failed', 'outcome_unknown'
  )),
  provider_action_id text,
  response jsonb,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

The function key can use the Retell tool-call identifier when available, or a deterministic combination of call ID, function name, business target, and operation version. Do not hash only the full arguments and assume repeated identical arguments always mean one action. A caller may legitimately request two different appointments in one call after cancelling the first. Business semantics decide the key.

The HighLevel adapter owns OAuth token refresh, scopes, rate-limit headers, location routing, and API errors. Retell functions never construct HighLevel URLs directly.

interface HighLevelAdapter {
  findContact(input: FindContactInput): Promise<ContactMatchResult>;
  listSlots(input: ListSlotsInput): Promise<CanonicalSlot[]>;
  createAppointment(input: CreateAppointmentInput): Promise<AppointmentResult>;
  upsertOpportunity(input: UpsertOpportunityInput): Promise<OpportunityResult>;
}

[DIAGRAM] Draw Retell, signed Next.js ingress, Postgres or Supabase, the HighLevel adapter, Redis or queue workers, and n8n. Separate the real-time call lane from the asynchronous post-call lane.

This architecture adds code. It also gives every critical invariant one place to live.

How do you resolve the right tenant and contact before the call starts?

Start with the number the caller reached, not the caller's phone number.

The Retell inbound webhook includes from_number and to_number. The destination number maps to the client tenant, HighLevel location, default Retell agent, timezone, calendar set, business hours, and routing policy. That mapping must be unique and controlled.

create table voice_numbers (
  e164 text primary key,
  tenant_id uuid not null,
  highlevel_location_id text not null,
  retell_agent_id text not null,
  retell_agent_version integer,
  default_timezone text not null,
  routing_policy_id uuid not null,
  active boolean not null default true
);

If a number maps to no tenant, fail safely. Do not search all HighLevel locations for the caller. If it maps to multiple tenants, treat that as configuration corruption.

After tenant resolution, normalize the caller number to E.164 when possible. Search only inside the mapped HighLevel location. A phone match can return zero, one, or multiple contacts. Each state needs a policy.

Zero matches means anonymous caller context. The agent should use a generic greeting and collect identifiers. Do not create a contact before the caller has provided enough data unless the client explicitly wants every caller recorded.

One strong match can supply first name, account status, assigned user, open opportunity, appointment context, and do-not-contact flags. Pass only fields the agent needs.

Multiple matches are ambiguous. The agent can ask for another identifier, such as email or postal code, through a controlled verification flow. It should not choose the most recently updated contact because that is convenient.

Blocked or missing caller ID requires a different path. The tenant is still known from the destination number, but contact resolution waits until the caller identifies themselves.

Retell allows the inbound webhook response to set metadata and dynamic variables, select an agent or version, and apply per-call overrides. Use metadata for internal identifiers and dynamic variables for values the prompt or tools need.

{
  "call_inbound": {
    "override_agent_id": "[TENANT AGENT ID]",
    "override_agent_version": 12,
    "dynamic_variables": {
      "greeting_name": "Jordan",
      "business_name": "Example Dental",
      "business_timezone": "America/Chicago",
      "contact_match_state": "single_verified"
    },
    "metadata": {
      "tenant_id": "[INTERNAL UUID]",
      "highlevel_location_id": "[LOCATION ID]",
      "highlevel_contact_id": "[CONTACT ID]",
      "correlation_id": "[UUID]"
    }
  }
}

Retell says the inbound call object does not yet exist when this webhook runs, so the request does not contain a call ID. Generate a correlation ID and return it in metadata. When call_started arrives, join the call ID to the correlation ID.

The inbound endpoint must stay fast. Cache the phone-number mapping and a small contact context projection. If HighLevel is unavailable, return the default agent with contact_match_state = unavailable rather than holding the call through repeated CRM requests. The agent can verify the caller later.

Do-not-call and consent behavior need client-specific legal review. Do not infer that a CRM flag has the same meaning for inbound service calls, outbound marketing calls, SMS, and recording consent. Encode separate fields and approved policies.

const context = await resolveInboundContext({
  destinationNumber: payload.call_inbound.to_number,
  callerNumber: payload.call_inbound.from_number,
  deadlineMs: inboundDeadline.remaining(),
});

Tenant resolution is the first security boundary. Contact matching comes after it.

How should dynamic variables be shaped and validated?

Treat them as a versioned input schema, not a bag of prompt substitutions.

Retell's current dynamic variables documentation says variables can appear in prompts, begin messages, tool configuration, transfer settings, and webhook URLs. For outbound calls, values in retell_llm_dynamic_variables must be strings. Numbers, booleans, and structured values need explicit serialization and casting. Missing variables remain as raw {{variable_name}} text, while an empty string is treated as a supplied value.

That behavior can create embarrassing calls. A missing customer_name may produce “Hello {{customer_name}}.” An empty transfer_number can remove a value the flow expected. A JSON object passed without serialization is invalid.

Define required, optional, defaulted, and forbidden variables per agent version.

const callVariableSchema = z.object({
  schema_version: z.literal("voice_context_v3"),
  business_name: z.string().min(1),
  business_timezone: z.string().min(1),
  greeting_name: z.string(),
  contact_match_state: z.enum([
    "none",
    "single_verified",
    "multiple",
    "blocked_caller_id",
    "unavailable",
  ]),
  current_customer: z.enum(["true", "false", "unknown"]),
  transfer_route_key: z.string().min(1),
  appointment_context_json: z.string(),
});

Everything becomes a string at the Retell boundary. Keep typed values internally and serialize once.

function toRetellVariables(context: TypedCallContext): Record<string, string> {
  return {
    schema_version: "voice_context_v3",
    business_name: context.businessName,
    business_timezone: context.timezone,
    greeting_name: context.greetingName ?? "",
    contact_match_state: context.contactMatchState,
    current_customer: String(context.currentCustomer),
    transfer_route_key: context.transferRouteKey,
    appointment_context_json: JSON.stringify(context.appointmentContext ?? {}),
  };
}

Do not put access tokens in dynamic variables. Retell supports variables in tool headers and URLs, which makes it tempting to pass a HighLevel token into the call. That exposes a rotating tenant credential to the conversation configuration and request traces. Retell should call your signed endpoint. Your endpoint should own HighLevel authorization.

Avoid personal data the agent does not need. A voice agent booking an appointment may need first name, service category, timezone, and existing appointment status. It does not need the entire CRM record or internal notes. Smaller context reduces accidental disclosure and prompt confusion.

Version the schema with the agent. If agent version 12 expects appointment_context_json and the inbound service still sends version 2 variables, fail the deployment test. Do not rely on prompt fallbacks for contract mismatch.

agent_id: "[RETELL AGENT ID]"
agent_version: 12
context_schema: voice_context_v3
required_variables:
  - schema_version
  - business_name
  - business_timezone
  - contact_match_state
  - transfer_route_key
optional_variables:
  - greeting_name
  - appointment_context_json

For values that can change during the call, decide whether they are authoritative. A lead_score calculated before the call is context. A confirmed_appointment_id created during the call is outcome state. Store outcome state in Postgres and return it through function responses. Do not use dynamic variables as the only record.

Retell custom functions can map JSON response fields into response variables. Use that for conversation continuity, but persist the same value server-side first.

Dynamic variables should make the conversation personal. They should not make business state implicit.

How do you build custom functions that return fast and repeat safely?

Make each function narrow, signed, idempotent, and honest about uncertainty.

Retell custom functions can use POST, GET, PUT, PATCH, or DELETE. POST is the default. Current documentation says the default timeout is two minutes, automatic retries default to zero, and max_retry can be set up to five. Retries apply to network errors, timeouts, and 4xx or 5xx responses, with exponential backoff and jitter. Retell warns that every retry repeats the request.

I usually set a much smaller application target than the platform timeout. The caller should hear a useful response quickly. If a provider operation cannot complete in that target, the function returns a pending or fallback state and moves the long work out of the live turn.

A function request must be verified using X-Retell-Signature and the raw body. Retell's secure webhook guide documents HMAC-SHA256 verification using the webhook-enabled API key and recommends rejecting stale timestamps outside five minutes when verifying manually.

export async function POST(request: Request): Promise<Response> {
  const rawBody = await request.text();
  const signature = request.headers.get("x-retell-signature");

  if (!signature || !(await verifyRetell(rawBody, signature))) {
    return Response.json({ error: "unauthorized" }, { status: 401 });
  }

  const payload = retellFunctionEnvelopeSchema.parse(JSON.parse(rawBody));
  const result = await executeFunction(payload);
  return Response.json(result, { status: 200 });
}

The function name describes one action. manage_lead is too broad. find_available_slots, book_selected_slot, get_existing_appointment, and request_human_transfer are testable.

Arguments use enums and stable IDs where possible. The model should not provide tenant ID, HighLevel location ID, or contact ID as free-form arguments. Those come from signed call metadata and the server-side call record.

{
  "type": "object",
  "required": ["slot_token"],
  "properties": {
    "slot_token": {
      "type": "string",
      "description": "The exact slot_token returned by find_available_slots"
    }
  }
}

Idempotency depends on the action. A slot lookup is safe to repeat. Appointment creation is not. Store a claim before the HighLevel call.

insert into voice_function_calls (
  function_call_key,
  call_id,
  function_name,
  argument_hash,
  status
)
values ($1, $2, 'book_selected_slot', $3, 'started')
on conflict (function_call_key) do nothing
returning function_call_key;

If the claim already exists and succeeded, return the stored response. If it is in progress, return a bounded “still processing” response or wait briefly. If its outcome is unknown, reconcile with HighLevel before retrying.

Do not return raw provider errors to the model. Normalize them:

{
  "status": "temporarily_unavailable",
  "reason_code": "HIGHLEVEL_RATE_LIMITED",
  "spoken_message": "I cannot confirm that right now. I can have the team follow up with you."
}

Retell converts function results to strings for the LLM, while JSON responses can populate response variables. Keep responses small. Retell currently caps function results at 15,000 characters by default. A CRM record dump wastes context and increases the chance the model says something it should not.

The interruption edge case matters. Retell states that when a user interrupts while a custom function is running, the request is not cancelled. It can still finish and perform its side effect even though the agent skips the immediate follow-up message. That is another reason the backend, not the spoken turn, owns outcome state.

// [WORKFLOW JSON: n8n asynchronous follow-up triggered only after the custom function service commits a normalized action event. This proves Retell retries or caller interruption cannot create duplicate downstream workflows.]

[DIAGRAM] Draw a custom function request passing signature verification, call lookup, schema validation, idempotency claim, HighLevel adapter, result persistence, and a small Retell response. Show repeated requests returning the stored result.

A voice function is an API endpoint under human latency. Build it like one.

How do you create appointments without lying to the caller?

Separate availability, reservation, creation, and confirmation.

Availability is a snapshot. By the time the caller chooses a slot, another person or workflow may have taken it. The agent must never present a prior availability result as a guaranteed booking.

The find_available_slots function returns canonical tokens with a short validity window. The book_selected_slot function rechecks availability or attempts the provider write with the exact token. Only a successful HighLevel appointment response produces status: confirmed.

There are four possible outcomes.

Confirmed means HighLevel returned an appointment ID and the saved time matches the token. The agent can state the final confirmation.

Conflict means the slot is no longer available. The agent should apologize briefly and call availability again. Do not ask the model to invent a nearby time.

Rejected means required data or policy is missing. The response tells the agent what to collect, such as email or service type.

Outcome unknown means the HighLevel request may have succeeded, but the integration did not receive a definitive result. A network timeout after sending the create request is not the same as failure. The agent should not repeat the create blindly and should not claim confirmation.

switch (result.status) {
  case "confirmed":
    return confirmedResponse(result.appointment);
  case "conflict":
    return retryAvailabilityResponse();
  case "rejected":
    return missingDataResponse(result.fields);
  case "outcome_unknown":
    await enqueueAppointmentReconciliation(result.operationId);
    return {
      status: "pending_verification",
      spoken_message:
        "I could not verify the final confirmation. The team will confirm the time shortly.",
    };
}

Use an operation key that can be searched in HighLevel. If the appointment API supports a field, note, or custom value for external correlation, store it. Otherwise search by contact, calendar, and exact timestamp during reconciliation. Be careful, because those fields may not be unique when a contact legitimately has multiple appointments.

HighLevel can trigger workflows when an appointment is created. Your custom function should not separately send the same confirmation unless the client has chosen one owner. Duplicate confirmation systems drift.

Timezone handling belongs in code. Store UTC and the IANA timezone. Avoid raw abbreviations such as CST because they are ambiguous and affected by daylight-saving time. The spoken label includes the zone when the caller and business may differ.

The agent should repeat date, time, timezone, service, and location from the created appointment response. That is the final verification step.

{
  "status": "confirmed",
  "appointment": {
    "id": "[ID]",
    "starts_at_utc": "[TIMESTAMP]",
    "timezone": "America/Chicago",
    "service_code": "initial_consultation",
    "location_label": "Downtown office"
  },
  "spoken_confirmation": "[GENERATED FROM SAVED FIELDS]"
}

After the call, a reconciliation worker compares Retell's post-call outcome with HighLevel state. If the transcript says confirmed but no appointment exists, create a manual review task. Do not create the appointment from the transcript without checking whether the caller's intent and current availability still permit it.

The agent should be confident only after the provider is definitive.

How should transfers work when routing data changes mid-call?

Use route keys and server-side resolution, not phone numbers scattered through prompts.

Retell dynamic variables can populate transfer phone numbers and warm-transfer instructions. That is useful, but the caller's route may change during the conversation. A sales lead can become support. A caller can reveal a different location. Business hours can close during a long call. The assigned HighLevel user can change.

Pass a stable transfer_route_key, such as billing_after_hours or location_42_new_patient, not the final number as business logic. The transfer function resolves the route at the moment of transfer.

create table voice_transfer_routes (
  tenant_id uuid not null,
  route_key text not null,
  timezone text not null,
  business_hours jsonb not null,
  primary_destination text,
  after_hours_destination text,
  fallback_destination text,
  active boolean not null default true,
  primary key (tenant_id, route_key)
);

The function returns the current target and a transfer mode approved for the client. The agent configuration can then use the returned variable or a controlled transfer tool.

Do not let the model choose arbitrary phone numbers from CRM text. A prompt injection in a contact note or a transcription error should not redirect a call. The tool accepts only an allowed route key.

{
  "type": "object",
  "required": ["route_key", "reason_code"],
  "properties": {
    "route_key": {
      "type": "string",
      "enum": [
        "new_sales",
        "existing_customer_support",
        "billing",
        "emergency_human_review"
      ]
    },
    "reason_code": {
      "type": "string",
      "enum": [
        "CALLER_REQUESTED_HUMAN",
        "AGENT_CANNOT_COMPLETE",
        "POLICY_REQUIRES_HUMAN"
      ]
    }
  }
}

Warm transfers need outcome tracking. Did the destination answer? Did the agent hand off? Did the caller return to the AI? Did the call drop? A Retell call-ended record alone may not describe the business handoff the client expects. Store transfer attempts as separate rows.

create table voice_transfer_attempts (
  transfer_id uuid primary key,
  call_id text not null,
  route_key text not null,
  destination_fingerprint text not null,
  attempted_at timestamptz not null,
  outcome text not null,
  provider_details jsonb
);

Use a fingerprint or masked destination in normal logs. Full phone numbers may be necessary in protected operational data but should not appear in every n8n execution.

For assigned-user routing, HighLevel may contain a user phone that is missing, malformed, or not approved for transfer. Normalize and validate the number, then fall back to a queue. Do not fail the entire call because one CRM user field is empty.

When routing depends on live CRM state, cache carefully. A short cache can keep the call fast. Invalidate it when routing configuration changes. For emergency routes, favor current database reads.

[DIAGRAM] Draw the agent selecting an allowed route key, the server resolving business hours and current destination, the transfer attempt being recorded, and fallback behavior when the primary destination does not answer.

The transfer number is configuration. The decision to transfer is policy. Keep both out of free-form model control.

How do you process Retell and HighLevel webhooks without duplicate writes?

Verify, persist, acknowledge, then process.

Retell's secure webhook documentation requires verification against the raw request body using X-Retell-Signature. HighLevel's current webhook guide recommends X-GHL-Signature with its Ed25519 public key and says the legacy RSA header is scheduled to be removed on September 1, 2026. If your framework parses and reserializes JSON before verification, signatures can fail because whitespace or key order changes.

I expose separate ingress routes and store the raw body plus normalized metadata after verification.

create table integration_webhook_inbox (
  provider text not null,
  event_key text not null,
  tenant_id uuid,
  event_type text not null,
  occurred_at timestamptz,
  received_at timestamptz not null default now(),
  raw_payload jsonb not null,
  status text not null default 'pending',
  attempt_count integer not null default 0,
  last_error text,
  primary key (provider, event_key)
);

Choose the event key from a provider webhook ID when one exists. When it does not, derive a key from stable fields and event version. Be honest about collision risk. A hash of the full payload can treat the same logical event with a different timestamp as new. A key of call_id + event_type can incorrectly deduplicate legitimate repeated events. The event contract decides.

Retell call lifecycle events do not all mean the same thing. call_ended means the conversation ended. call_analyzed can arrive later with post-call analysis. If call_ended creates a final opportunity outcome and call_analyzed later writes older fields, arrival-order processing can corrupt the record.

Use event-specific patches and state transitions. The ended event can set call end time and enqueue immediate follow-up. The analyzed event can set analysis fields without overwriting appointment state already committed by a live function.

HighLevel webhooks can echo your own writes. A function updates a contact, HighLevel sends ContactUpdate, and an n8n workflow interprets it as a new external change. Include source markers where available and make consumers idempotent. At minimum, compare the changed fields and current state before acting.

if (
  event.type === "ContactUpdate" &&
  event.changedFields.every((field) => NON_ACTIONABLE_FIELDS.has(field))
) {
  return { status: "ignored_non_actionable_update" };
}

Do not make the webhook response depend on HighLevel or Retell being available. The inbox is the acceptance boundary. A worker can retry downstream processing with backoff.

Webhook ordering is not guaranteed across providers. A HighLevel appointment webhook can arrive before the Retell function response is stored. Reconciliation should join on provider IDs and correlation keys, not assume one sequence.

// [WORKFLOW JSON: n8n worker that claims one verified webhook inbox row, applies an event-specific state transition, records outbound actions in an outbox, and marks the row complete. This proves retries do not repeat CRM side effects.]

A webhook is evidence that something happened. It is not permission to rerun the whole business workflow.

How do you keep OAuth, signatures, and tenant secrets out of n8n?

Put security-sensitive protocol work in a small code boundary.

n8n is excellent for visible orchestration. I use it for post-call tasks, notifications, reporting, enrichment, and operator workflows. I avoid making it the only place that verifies raw webhook signatures, rotates multi-tenant OAuth tokens, decrypts tenant secrets, or enforces row-level authorization.

Raw-body verification can be awkward once a platform has parsed the request. A Next.js route or Express middleware can read the exact bytes, verify, then store the event. n8n receives a normalized internal job signed by your own service.

HighLevel OAuth needs per-location or company token state. Access tokens last one day according to the current FAQ. Refresh tokens rotate. A dedicated adapter serializes refresh per installation, stores scopes, observes rate-limit headers, and returns typed results. n8n calls your adapter with an internal tenant ID and operation, not a bearer token.

interface InternalHighLevelRequest {
  tenantId: string;
  operation:
    | "find_contact"
    | "list_calendar_slots"
    | "create_appointment"
    | "upsert_opportunity";
  payload: unknown;
  idempotencyKey: string;
  correlationId: string;
}

The adapter maps tenant ID to the correct HighLevel location and credential. It rejects attempts to pass another location ID in the payload.

if ("locationId" in parsedPayload) {
  throw new Error("LOCATION_ID_MUST_NOT_BE_CLIENT_SUPPLIED");
}

Private Integration Tokens are appropriate for controlled internal use where the HighLevel documentation's limitations fit. I would not use one agency-wide static token as the hidden credential behind a service sold to many client locations. OAuth gives installation-level authorization, scopes, and revocation semantics required for a public integration.

Secrets are encrypted and accessible only to the adapter runtime. n8n stores an internal service credential with narrow permission. The service validates the caller identity and allowed operation.

Sign internal jobs too. A private URL is not authentication. Use mTLS, a signed service token, or another controlled mechanism supported by your infrastructure. Include timestamp and request ID to block replay.

{
  "service": "voice-orchestrator",
  "tenant_id": "[UUID]",
  "operation": "post_call_followup",
  "correlation_id": "[UUID]",
  "issued_at": "[TIMESTAMP]",
  "expires_at": "[TIMESTAMP]"
}

HighLevel rate-limit headers belong in adapter telemetry. The current FAQ lists daily and interval headers. Store remaining capacity and slow non-urgent work before the tenant reaches pressure. Live call functions should have priority over nightly enrichment.

[DIAGRAM] Draw public Retell and HighLevel webhooks terminating at signed code endpoints, encrypted OAuth storage behind a HighLevel adapter, and n8n receiving only normalized internal events and operation requests.

The visual workflow should express business steps. It should not be your only cryptographic boundary.

What breaks under concurrent calls, retries, and delayed analysis?

Shared contacts become the collision point.

Two callers can use the same phone number. One caller can place two calls at once. An outbound campaign can contact the same lead while an inbound call is active. A Retell function retry can overlap with the original request. HighLevel workflows can update the same opportunity during the call. Post-call analysis can arrive after a human already corrected the record.

Do not lock the entire contact for the duration of a call. That would block legitimate work and create long leases. Lock specific transitions.

For appointment creation, lock or claim tenant + calendar + slot token + contact + booking version. For opportunity stage changes, use optimistic concurrency based on current stage and update timestamp. For call summary attachment, upsert by call ID.

insert into call_business_actions (
  action_key,
  call_id,
  action_type,
  target_id,
  status
)
values ($1, $2, 'appointment_create', $3, 'started')
on conflict (action_key) do nothing;

Retell custom-function retries can happen on any 4xx or 5xx when enabled. That means returning 400 for a permanent validation error can still be retried by Retell if max_retry is above zero. The endpoint should remain idempotent, and the flow should avoid retry settings on side-effecting functions unless the recovery behavior is designed.

HighLevel OAuth refresh can race across function calls. The adapter needs single-flight token refresh per installation. A live voice call should not receive multiple 401s because three workers used a retired refresh token.

HighLevel rate limits can create priority inversion. A batch n8n job consumes burst capacity while a live appointment function waits. Put operations in classes. Delay enrichment and reporting when remaining interval capacity is low. Do not delay a verified live booking behind a non-urgent contact tag update.

Delayed analysis creates stale writes. Suppose call_analyzed labels the lead “qualified,” but a human moved the opportunity to disqualified after listening to the call. The analysis worker should not overwrite the stage without checking current state and source priority.

const sourcePriority = {
  human_operator: 100,
  confirmed_provider_action: 90,
  live_agent_function: 80,
  post_call_analysis: 50,
  inferred_summary: 30,
};

Store field-level provenance where important. A lead_status value should record who or what set it and when.

Caller interruption creates another race. Retell says a custom function continues after interruption. The agent may move to another topic while the function creates the appointment. Later it may call booking again because it did not speak the first result. The idempotency key and call state prevent a duplicate.

Unknown outcomes need a dedicated queue. Do not treat timeout as failure. Reconcile HighLevel by provider ID or stable business key before retrying.

Under a Retell or HighLevel outage, the system should degrade by capability. The agent can collect details and promise a follow-up when booking is unavailable. It should not state that the entire business is closed or create guessed data.

[DIAGRAM] Draw two concurrent calls for one contact, one HighLevel batch job, a token refresh, a delayed call_analyzed event, and field-level conflict checks. Show live booking taking priority while stale analysis is rejected.

Concurrency does not only duplicate records. It can make a later, weaker source overwrite a confirmed human or provider outcome.

How do you test the call as a distributed transaction?

Test what the caller hears, what each platform stores, and what happens when one step is uncertain.

A Retell simulation is useful for conversation behavior. It does not prove HighLevel state, OAuth refresh, webhook signatures, or external retries. An API integration test proves payloads. It does not prove the agent speaks the final saved value. You need both.

I define scenario fixtures:

scenario: appointment_conflict_after_selection
caller:
  phone: "[TEST NUMBER]"
  timezone: America/Chicago
preconditions:
  contact_match: single_verified
  initial_slots:
    - "[SLOT TOKEN A]"
fault:
  take_slot_after_agent_offers_it: true
expected:
  first_booking_status: conflict
  agent_behavior: requests_new_availability
  appointment_count: 1
  spoken_time_matches_saved_time: true
  manual_review: false

Major scenarios include no contact, multiple contacts, blocked caller ID, missing dynamic variable, HighLevel 401 followed by token refresh, HighLevel 429, Retell function retry, function timeout after provider acceptance, caller interruption during a function, slot conflict, transfer no-answer, duplicate webhooks, delayed analysis, and an invalid signature.

For each call, capture a timeline:

{
  "correlation_id": "[UUID]",
  "events": [
    { "at": "[TIMESTAMP]", "type": "inbound_context_resolved" },
    { "at": "[TIMESTAMP]", "type": "retell_call_started" },
    { "at": "[TIMESTAMP]", "type": "availability_returned" },
    { "at": "[TIMESTAMP]", "type": "booking_request_started" },
    { "at": "[TIMESTAMP]", "type": "highlevel_appointment_created" },
    { "at": "[TIMESTAMP]", "type": "retell_confirmation_spoken" },
    { "at": "[TIMESTAMP]", "type": "retell_call_ended" },
    { "at": "[TIMESTAMP]", "type": "retell_call_analyzed" },
    { "at": "[TIMESTAMP]", "type": "reconciliation_passed" }
  ]
}

The key assertion is cross-system. Extract or manually mark the spoken confirmation. Compare it with the HighLevel appointment fields and the canonical action record. The test fails if any differ.

Load tests should preserve tenant isolation. Run concurrent calls across several test tenants and many calls against one tenant. Verify that rate limiting, OAuth refresh, and queues are partitioned correctly. Do not publish an unverified calls-per-minute benchmark. Measure your deployment.

Fault injection matters. Delay the HighLevel response beyond your application target. Drop the network after sending the appointment request. Return a valid empty contact search. Send the same Retell function request twice. Deliver call_analyzed before your worker processes call_ended. Rotate the HighLevel access token during the call.

// [WORKFLOW JSON: n8n test coordinator that launches a Retell test call, injects a HighLevel adapter fault, waits for call and webhook events, queries Supabase and HighLevel, and produces a cross-system PASS or FAIL report. This proves the spoken and persisted outcomes agree.]

A call passes when the human experience and system state agree under failure, not only when the agent sounds natural.

What does this production architecture cost you?

It costs a code service, a database model, queue operations, test infrastructure, and stricter releases.

The direct n8n build has fewer components. A production design introduces signed ingress endpoints, OAuth storage, tenant mappings, event inboxes, action claims, reconciliation workers, and dashboards. Someone owns deployments and on-call response.

Latency work becomes explicit. You need request deadlines, database indexes, caches, and provider-specific timeout policy. A slow query that would be tolerable in a back-office automation is audible on a call.

The system stores more operational data. Call IDs, provider IDs, correlation IDs, payloads, and event logs need retention, access control, and privacy rules. Recordings and transcripts can contain sensitive information. Do not copy them into every workflow log.

A queue introduces eventual consistency. The post-call summary may appear in HighLevel seconds or minutes after the call, depending on event timing and load. The client needs to understand which fields are live-confirmed and which are post-call enriched.

Strict idempotency can also block a legitimate repeat action when the key is poorly designed. If a caller books, cancels, and books again in one call, a key of call_id + book_appointment is too broad. Business versions and provider state must be represented.

OAuth adds install and support paths. Tokens expire, scopes differ, and clients uninstall or reinstall. A Private Integration Token is simpler for one controlled location, but it does not fit every agency product.

Testing voice flows takes time. You need simulation, API tests, real calls, timezone cases, and human review of speech. A test suite that never listens to the call can miss the most important mismatch.

The cost model should use actual data:

build:
  realtime_api: "[ACTUAL HOURS]"
  highlevel_adapter: "[ACTUAL HOURS]"
  event_processing: "[ACTUAL HOURS]"
  test_suite: "[ACTUAL HOURS]"
operations:
  database_and_queue: "[ACTUAL COST]"
  logs_and_monitoring: "[ACTUAL COST]"
  incident_review: "[ACTUAL HOURS]"
provider_usage:
  retell: "[ACTUAL]"
  highlevel: "[PLAN OR API COST IF APPLICABLE]"
  n8n: "[ACTUAL]"

[REAL NUMBER] Insert measured function latency, call outcome rate, webhook delay, duplicate actions blocked, manual reviews, infrastructure cost, and support time. Do not claim a conversion lift without a controlled measurement.

The architecture is more expensive than a direct connector. It is cheaper than asking staff to distrust every appointment the agent books.

What would I do differently before launching the next voice agent?

I would define the business outcome schema before writing the prompt.

For each intended outcome, I would specify the provider proof. “Qualified” needs reason codes and required fields. “Appointment confirmed” needs a HighLevel appointment ID and exact saved time. “Transferred” needs a transfer attempt and outcome. “Follow-up promised” needs an owned task.

I would build tenant and number mapping before connecting the phone. A destination number should resolve to exactly one tenant, location, timezone, agent version, and routing policy.

I would keep the inbound webhook thin from day one. It has a documented 10-second timeout and retries. I would not point it at a large n8n workflow that refreshes credentials and calls several providers.

I would create narrow function endpoints with schemas and idempotency before adding them to the Retell agent. Function names and descriptions influence when the model calls them. The API contract should exist before prompt iteration.

I would keep HighLevel OAuth behind one adapter. Every caller would log location, operation, token generation, rate-limit headers, and correlation ID without logging token values.

I would include dynamic-variable missing-value tests. Retell's raw curly-brace behavior makes this easy to catch before a caller hears it.

I would make post-call analysis advisory by default. It can propose lead status or next action, but it should not overwrite confirmed appointment or human-reviewed state without a transition rule.

I would create a manual review queue with evidence. An operator should see the relevant transcript segment, tool call, provider response, current CRM state, and recommended action. Listening to the full call should be optional, not the only debugging method.

I would pin an agent version for production and record it on every call. “Current agent” is not enough when behavior changes during an incident window.

create table voice_agent_releases (
  tenant_id uuid not null,
  retell_agent_id text not null,
  retell_agent_version integer not null,
  context_schema_version text not null,
  released_at timestamptz not null,
  released_by text not null,
  status text not null,
  primary key (tenant_id, retell_agent_id, retell_agent_version)
);

I would also rehearse degraded mode. When HighLevel is unavailable, the agent collects information, states that confirmation is pending, and creates a durable follow-up job. It does not keep calling the same function until the caller hangs up.

The best voice prompt cannot repair an integration whose outcome is undefined.

What remains hard after the CRM and call logs agree?

Human language is still not a transaction protocol.

A caller can say yes, then qualify it. They can choose Tuesday, interrupt, ask about price, and later refer to “the earlier one.” They can provide an email that sounds like another. They can agree to a transfer but hang up during the ring. A transcript can make intent look cleaner than it was in the moment.

The architecture closes the technical loop from the opening failure. The agent speaks only the timestamp returned from the created HighLevel appointment. The slot token preserves timezone and calendar identity. The function claim prevents retries from creating a duplicate. Retell and HighLevel webhooks enter verified inboxes. Reconciliation confirms that the call outcome and CRM record match.

That gives you evidence. It does not eliminate ambiguity in the caller's intent.

For high-value or sensitive actions, the final rule may still be a confirmation sentence with explicit fields, a DTMF confirmation, an SMS link, or human review. The right boundary depends on the business and legal context.

I can look at one call trace or architecture diagram, but the hardest part most teams miss is deciding which exact sentence from the caller is enough authority for the irreversible CRM action that follows.