Developers

Trigger AI Outbound Calls from GoHighLevel with Webhooks and the RizzDial API

How to fire a RizzDial AI outbound call from a GoHighLevel workflow: get the agent call endpoint, configure the Custom Webhook action, normalize phone numbers, and handle errors.

By James Hill ·

A lead submits a form in GoHighLevel and you want an AI agent on the phone with them right away. The shortest path is one workflow, one webhook action, one URL. Everything below was verified against the live RizzDial OpenAPI spec and HighLevel's help center on 2026-09-24.

Answer first: Starting a RizzDial AI call is two requests. First, GET /api/ai/agent/call-endpoint?agent_id=YOUR_AGENT_ID with a bearer token returns a per-agent URL. Second, anything that can POST JSON to that URL places the call, no bearer header needed, because the token in the path is the credential. In GoHighLevel, the thing doing the POST is the Custom Webhook workflow action, with phone_number in E.164 format and call_mode set to outbound.

The two-step model, and why it matters

Most of the confusion around this integration comes from assuming one URL does everything. It does not.

Step Request Auth Who calls it
Get the endpoint GET /api/ai/agent/call-endpoint Authorization: Bearer YOUR_TOKEN You, once, from your terminal
Place the call POST to the returned URL None. The path token is the credential GoHighLevel, or your relay, every time

That design is what makes a GoHighLevel workflow able to dial at all: the webhook action never has to hold an API token. It also means the endpoint URL is a live credential. Anyone who has it can make your agent dial, so keep it out of shared docs, screenshots and client-facing sub-accounts.

Step 1: get the agent's call endpoint

Existing RizzDial customers create a personal access token in the dashboard under Profile, Settings, Personal Access Tokens. Then:

curl -s "https://app.rizzdial.com/api/ai/agent/call-endpoint?agent_id=YOUR_AGENT_ID" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json"

The response tells you the URL, the method and the fields the endpoint accepts:

{
  "status": "success",
  "data": {
    "endpoint": "https://app.rizzdial.com/webhooks/ai/create-phone-call/eyJpdiI6...",
    "Method": "POST",
    "form_data": [
      "phone_number",
      "first_name",
      "last_name",
      "ghl_contact_id",
      "override_agent_id",
      "call_mode=outbound (outbound/inbound)",
      "local_presence_number=No (Yes/No)"
    ]
  }
}

The same URL is in the dashboard: open AI, AI Agents, then the agent's Actions menu, then Endpoints, and use Copy URL on the Outbound tab. The Endpoint docs page documents both tabs. Outbound rings the person. Inbound does not ring anyone; it registers a call and returns a dialSip address for your own phone system to bridge, which is not what a GoHighLevel workflow wants.

Before sending anything real, confirm three things: agent status Active, an outbound number assigned to the agent (Advanced tab, Phone Number Configuration), and call balance on the account. Each failure has its own error, covered below.

Step 2: build the GoHighLevel workflow

Open Automation, Workflows, Create Workflow in the sub-account.

Trigger. Form Submitted fires when a selected HighLevel form is submitted, and Contact Created fires when a new contact record is added. Form Submitted is usually the better choice, because a form submission is evidence of who asked to be contacted and a contact record can arrive from an import.

Filters. Add an If/Else branch that continues only when your consent field or tag is present. Details below.

Timing. Add a Wait action if you do not want calls landing at 6am. HighLevel's Wait action has an Advance Window with Resume On (which days) and Resume Between Hours (the time range), evaluated in the workflow timezone.

The call. Add the Custom Webhook action, which HighLevel lists under the Send Data category in its Custom Webhook article. Configure it like this:

Field Value
Event CUSTOM
Method POST
URL the endpoint URL from step 1
Authorization None
Content-Type application/json
Body Raw Body, the JSON below
{
  "phone_number": "{{contact.phone}}",
  "first_name": "{{contact.first_name}}",
  "last_name": "{{contact.last_name}}",
  "ghl_contact_id": "{{contact.id}}",
  "call_mode": "outbound"
}

Insert each merge field with the dynamic value picker (the tag icon) rather than typing it, because availability depends on the trigger. HighLevel also ships an older, simpler Webhook (Outbound) action with a Method, a URL and key-and-value Custom Data pairs. It works for this, but Custom Webhook gives you the raw JSON body and headers, which you will want the moment you add a relay.

ghl_contact_id attaches the CRM contact to the call, which is what lets results land back on the right record. call_mode defaults to outbound if omitted, so sending it explicitly is about being readable later, not about behavior. Anything else you add passes through to the agent as a dynamic variable it can use mid-call, so "policy_renewal_date": "{{contact.policy_renewal_date}}" is a legitimate thing to send.

Phone numbers must be E.164

phone_number should be E.164: a plus sign, the country code, then the number, for example +12137771235. Contact phone values in a sub-account often arrive in mixed formats from imports, hand entry and third-party forms, which is how ten-digit values and parentheses end up in the field.

So {{contact.phone}} is usually right and sometimes not. RizzDial adds +1 to a bare ten-digit number and strips common punctuation, but anything it cannot parse comes back as "Phone number not found", and a non-US number without its country code cannot be guessed. If the sub-account holds imported or hand-typed data, normalize before you dial. A relay is the most reliable place to do that, as shown below.

Consent, timing and the legal part

Add the consent check as a workflow filter, and check it again in your own code if you add a relay. Two filters are worth having: a tag or field recording that this person asked to be called, and a do-not-call flag that stops the branch outright.

The FCC's February 2024 declaratory ruling treats AI-generated voices as "artificial" voices under the TCPA, which means an AI agent dialing a consumer sits inside TCPA restrictions rather than beside them. RizzDial honors a do-not-call flag on the contact record; it does not check your list against the federal DNC registry or litigator lists, and that screening is yours. Our TCPA and FCC compliance rundown covers the landscape, and none of this is legal advice.

Separate point, often conflated: A2P 10DLC registration governs SMS and MMS, not voice. If the same workflow also texts, work through the A2P 10DLC checklist.

The relay option: one place that owns the URL

Point three workflows in four sub-accounts at the raw endpoint and you now have twelve copies of a credential you cannot rotate quietly, no normalization, and no consent enforcement outside HighLevel. A small relay fixes all three. GoHighLevel posts to your relay with a shared header token; the relay owns the endpoint URL.

import os
import re
import time

import requests
from flask import Flask, jsonify, request

CALL_ENDPOINT = os.environ["RIZZDIAL_CALL_ENDPOINT"]
RELAY_TOKEN = os.environ["RELAY_TOKEN"]
DEFAULT_COUNTRY_CODE = "1"

app = Flask(__name__)
recent_calls = {}
REPEAT_WINDOW_SECONDS = 900


def to_e164(raw):
    if not raw:
        return None
    text = str(raw).strip()
    digits = re.sub(r"[^0-9]", "", text)
    if text.startswith("+"):
        return "+" + digits if len(digits) >= 11 else None
    if len(digits) == 11 and digits.startswith(DEFAULT_COUNTRY_CODE):
        return "+" + digits
    if len(digits) == 10:
        return "+" + DEFAULT_COUNTRY_CODE + digits
    return None


def dialed_recently(number):
    cutoff = time.time() - REPEAT_WINDOW_SECONDS
    last = recent_calls.get(number)
    return last is not None and last > cutoff


@app.post("/ghl/ai-call")
def ai_call():
    if request.headers.get("X-Relay-Token") != RELAY_TOKEN:
        return jsonify(error="forbidden"), 403

    body = request.get_json(silent=True) or {}
    phone = to_e164(body.get("phone_number"))
    if not phone:
        return jsonify(error="phone_number is not dialable", value=body.get("phone_number")), 400

    consent = str(body.get("consent", "")).strip().lower()
    if consent not in ("true", "yes", "1"):
        return jsonify(error="no phone consent on record"), 403

    if dialed_recently(phone):
        return jsonify(status="skipped", reason="dialed recently"), 200

    payload = {
        "phone_number": phone,
        "first_name": body.get("first_name", ""),
        "last_name": body.get("last_name", ""),
        "ghl_contact_id": body.get("ghl_contact_id", ""),
        "call_mode": "outbound",
    }

    upstream = requests.post(CALL_ENDPOINT, json=payload, timeout=20)
    data = upstream.json() if upstream.content else {}
    if upstream.status_code < 300 and data.get("status") == "success":
        recent_calls[phone] = time.time()
    app.logger.info("call attempt " + str({"to": phone, "code": upstream.status_code, "body": data}))
    return jsonify(data), upstream.status_code

Then the Custom Webhook action posts to https://relay.example.com/ghl/ai-call with header X-Relay-Token: YOUR_RELAY_TOKEN and the same JSON plus "consent": "{{contact.ai_call_consent}}".

Three things this buys you. The endpoint URL lives in one environment variable, so rotating it is one deploy. Bad phone data fails with a readable 400 instead of a dial. And the repeat-dial guard stops a contact who re-enters two workflows from getting two calls, which the dictionary above handles for a single process; use a shared store if you run more than one.

Testing before you point it at leads

  1. Send one request by hand with a number you own, using the JSON from step 2 and curl -X POST -H "Content-Type: application/json". A 2xx response with {"status":"success","msg":"successfully make a call.","call_id":"..."} and a ringing phone means the RizzDial side is done.
  2. Swap the URL in the Custom Webhook action for a request-inspection URL and run HighLevel's Test Workflow in draft mode. Confirm the merge fields resolved, and that the phone arrived with a plus and a country code, not as (213) 777-1235.
  3. Put the real URL back, run the test again with your own contact record, and watch the phone ring.
  4. Check HighLevel's Execution Logs for the status code the action received, then confirm the call landed with GET /api/ai/call-history?per_page=5.

Errors, and what each one actually means

The endpoint returns status: error with a short message:

Code Message pattern Real cause Fix
403 "Agent is inactive." Agent status is Inactive Set status Active (dashboard, or PATCH /api/ai/agent/update/{id})
403 insufficient balance Account cannot place the call Top up call balance
404 "Agent not found" Wrong agent, or the endpoint URL was truncated Re-copy the URL from Endpoints
404 "Phone number not found" phone_number missing or not parseable as a phone number Check the merge field resolved; normalize to E.164
404 number not assigned No outbound number on the agent Assign one in Advanced, Phone Number Configuration, or POST /api/ai/number/assign
422 "Failed to create call." or a provider message Request accepted, call still not created Check the phone value first, then call history
500 an error message Unexpected server error Retry later with backoff, then check call history before retrying again

The 404s burn the most time, because they look alike from HighLevel's side. A truncated URL is the more common of the two: copy it from the Endpoints panel, never from a chat message.

Reading the results back

Three routes, in ascending order of effort:

The per-agent GoHighLevel integration. In the dashboard, the agent's Integration tab syncs call data to GHL: recording upload, transcription, user sentiment and call summary into custom fields you map, plus call duration and direction, plus outcome tags such as Answered, No Answer, Voicemail and Appointment Set applied to the contact. Create the GHL custom fields first, then map each data point. See the Integration docs page and our GoHighLevel AI dialer integration guide.

The call history API. GET /api/ai/call-history returns records with call_id, call_duration, to_number, call_successful, hangup_reason, direction, call_summary, call_transcript, user_sentiment, call_recording, contact_id and appointment_date. Filters include agent_id, start_date and end_date (both Y-m-d), direction, user_sentiment, call_successful, to_number, hangup_reason, min_duration and max_duration, with per_page capped at 30.

curl -s "https://app.rizzdial.com/api/ai/call-history?agent_id=YOUR_AGENT_ID&direction=outbound&per_page=10" \
  -H "Authorization: Bearer YOUR_TOKEN"

A post-call webhook. Set a URL on the agent and RizzDial posts the call result to you as soon as the call is analyzed, which is the right shape if your system of record is not GoHighLevel. That is its own build: see sending post-call data to your CRM.

Frequently asked questions

Do I need a bearer token in the GoHighLevel action?

No. Set Authorization to None. The call endpoint is authenticated by the token inside its path, which is why the URL itself has to be treated as a credential.

Custom Webhook or Webhook (Outbound)?

Either places the call. Custom Webhook gives you a raw JSON body, custom headers and a choice of method, so pick it if you are sending nested data or posting to a relay that checks a header token. The older Webhook action's key-and-value Custom Data is enough for four flat fields.

What happens if the phone number has no country code?

A bare ten-digit number is treated as a US number and gets +1 added. Anything RizzDial cannot parse returns "Phone number not found" instead of dialing. International numbers need their country code, so send E.164 and normalize in a relay when your data is mixed.

Can one workflow dial a different agent than the endpoint belongs to?

Yes. Send override_agent_id in the body and that call uses the other agent, leaving the endpoint URL unchanged. Useful for language routing, or for a Spanish-speaking agent on a subset of leads.

How do I stop calls landing outside business hours?

Use a Wait action with the Advance Window set to your calling days and hours, in the workflow timezone, ahead of the webhook action. Do not rely on the trigger's timing; a form submitted at 11pm would otherwise dial at 11pm.

Does an AI call need A2P 10DLC registration?

No, that framework covers SMS and MMS. If the same workflow texts as well as calls, the messaging side needs its own approved brand and campaign.

Can I do this without writing any code?

Yes, if your phone data is already E.164 and only one workflow dials. At the second workflow or the second sub-account, the relay pays for itself, and driving the API from your own code is worth comparing.


The RizzDial API is available to RizzDial customers, with an OpenAPI 3.0.3 spec covering 224 endpoints and a Swagger UI at app.rizzdial.com/api/docs. Get API access or book a call.


About RizzDial

RizzDial is the AI outbound sales workspace for teams on GoHighLevel. Power dialing, AI voice agents, SMS automation, and CRM workflows in one platform. Book a demo.