The URL

POST https://app.hiambrose.com/api/team/<webhook_id>/webhook/ghl

The webhook_id is an immutable 8-character alphanumeric assigned at team creation. Find it on the team's Integrations tab. Slug-based URLs (/api/team/<slug>/webhook/ghl) also work for back-compat.

Agent-level /webhook/ghl is gone. Any GHL workflow still pointing at /api/agent/<anything>/webhook/ghl returns 404 with a migration message. Move to the team URL.

Request type & parameters

FieldTypeRequired
MethodPOSTyes
Content-Typeapplication/jsonyes

GHL's Webhook action sends the contact object plus a customData map (this is where you put your instruction + flags). Ambrose reads these fields:

Required for GHL: a contact id (contact_id) and an instruction (customData.instructions). Without an instruction the team falls back to a generic review/draft; for GHL workflows always send one.
Body fieldRequiredWhat it is
contact_id / contact.id / top-level idYesThe GHL contact. Resolution order: contact_idcustomData.contact_idcontact.id → top-level idemail lookup. (Nested workflow.id / location.id are never used as the contact.)
customData.instructions (or customData.instruction)YesThe command for the team — e.g. "Draft a renewal SMS and add it as a note."
email / contact.emailIf no idUsed to look up the contact when no id is present. Loads that contact's prior conversation, notes, fields & opportunities.
customData.syncNotrue to wait for the result in the HTTP response (use when a later workflow step needs the output). Default is fast-ack: Ambrose returns immediately and processes in the background.
customData.auto_create_fieldsNotrue to let the team create missing custom fields. Off by default.
any other contact fieldsNoname, phone, tags, and every contact custom field — all passed to the team as context.

In the GHL Webhook action you typically map {{contact.id}} and add a customData key instructions with your command. The instruction is read only from customData.instructions (or customData.instruction) — a top-level instruction is ignored. (Top-level contact_id is accepted for non-GHL callers like curl / Make / Zapier.)

What the team actually receives on every fire:
  • Full team persona — every team markdown file (SOUL / PERSONALITY / FLOW / NOTES / FAQ …) as the system prompt.
  • The skills you selected on the team's Tools tab — each skill's playbook is injected so the AI follows it.
  • The GHL CRM toolset — read conversation/notes/fields/pipelines/appointments + write notes/tags/fields, draft & send SMS/email, move pipeline stages, create tasks.
  • The complete inbound payload — every contact custom field GHL sends (both the top-level fields like ai_context_json, Contact Context JSON, business/AEO/social fields, AND anything under customData) is passed to the AI as context. Empty fields are skipped to keep the prompt tight.
  • The contact's prior conversation history — pulled live from GHL by contact id (oldest→newest), so replies are in-context.

The AI then performs the task in customData.instructions using all of the above.

Example body

GHL sends the contact fields at the top level and your task inside customData:

{
  "contact_id": "{{contact.id}}",
  "email": "{{contact.email}}",
  "full_name": "{{contact.name}}",
  "location": { "id": "{{location.id}}" },
  "ai_context_json": "…",        // GHL also sends every contact custom field at the top level
  "customData": {
    "instructions": "Draft a renewal SMS in our voice, add it as a note, and tag 'renewal-drafted'.",
    "sync": false
  }
}

How it works end to end

1
A GHL workflow trigger fires (new contact, tag added, opportunity moved, form submission, calendar booking, …).
2
The workflow has a Webhook action that POSTs the contact payload to the team URL.
3
Ambrose reads the contact + recent conversation + notes + custom fields + opportunities (whatever the team prompt asks for) via the ghl spoke.
4
The team runs its prompt with that context.
5
Specific output fields in the JSON response are flattened into top-level keys (tier, note, tag_add, tag_remove, sms_draft, …) so the GHL workflow can pick them up as variables.
6
Subsequent GHL workflow steps use those variables (e.g. "if tier = HOT, route to Jordan's calendar").

The two operating modes

Mode 1 — Default (review & draft)

If you do not provide an explicit instruction, the team reads the contact and returns a triage: a tier + a one-line reason + suggested next actions. Nothing is written to GHL unless the team prompt explicitly says so.

Mode 2 — Operator instruction

If the webhook body includes customData.instructions, the team treats it as a direct command. The instruction-router has ~20 tools available:

ReadWrite
read_conversationadd_note
read_notesadd_tags
read_opportunitiesremove_tags
read_custom_fields_schemaupdate_contact_native
read_pipelinesupdate_custom_fields
read_tagscreate_custom_field (off by default)
read_appointmentsgenerate_sms / send_sms
read_calendarsgenerate_email / send_email
move_pipeline_stage
create_task

Example 1 — Lead Qualifier

Trigger in GHL: New contact added to "Web leads" tag.

Workflow webhook body sent to Ambrose:

{
  "contact_id": "abc123",
  "location_id": "loc_xxx",
  "first_name": "Maria",
  "last_name":  "Lopez",
  "email":      "maria@example.com",
  "phone":      "+12145551234",
  "custom_fields": { "interest": "ICHRA", "budget": "350" }
}

Team prompt (the Lead Qualifier from the prompts page) returns:

{
  "tier":   "HOT",
  "reason": "ICHRA inquiry + budget > $300/mo with explicit week-out timeline.",
  "actions": [
    { "type": "add_tags",         "tags": ["hot_lead", "ichra"] },
    { "type": "add_note",         "body": "Qualified HOT by Ambrose — ICHRA + budget $350." },
    { "type": "create_task",      "title": "Jordan: book ICHRA discovery", "due_in_hours": 24 }
  ]
}

The handler flattens tier to a top-level field, executes the actions, and the GHL workflow's next step branches on {{webhook_response.tier}} == "HOT".

Example 2 — Operator instruction

Trigger: Tag added: "renewal_pending". Workflow webhook body includes an instruction:

{
  "contact_id": "xyz789",
  "customData": {
    "instructions": "Draft a renewal email in our brand voice. Include current plan, last year's claim count, and propose a 15-min review call. Add the draft as a contact note and tag 'renewal_drafted'."
  }
}

The team uses the instruction-router to: read_conversation + read_notes + generate_email + add_note + add_tags. No SMS or email actually sends — the draft is parked as a note for human review.

Example 3 — Calendar booked → send confirmation

{
  "contact_id": "lmn456",
  "appointment": {
    "calendar_id": "cal_xyz",
    "start_time":  "2026-05-29T15:00:00Z"
  },
  "customData": {
    "instructions": "Send a calendar-confirmation SMS in my brand voice. Include the time in their local zone, link to a 2-question prep form, and a reminder to test their phone audio. Stop here."
  }
}

Uses: generate_sms → send_sms. add_note with the sent body.

Response: fast-ack vs sync

By default the webhook is fast-ack: it validates the payload, queues the job, and returns immediately (so GHL's 30-second step timeout never trips). The AI runs in the background and writes its results straight into GHL (notes, tags, custom fields). If a later workflow step needs to branch on the AI's output, send customData.sync: true — then the call waits and returns the full result below (capped by GHL's 30s timeout; use only for low-volume flows).

In sync mode the response exposes:

{
  "ok":     true | false,
  "tier":   "...",        // if your prompt returned one
  "actions_taken": [...], // every write that ran
  "custom_fields_updated": [...],
  "errors": [...],        // any write that failed
  "raw":    { ... }       // the team's full output
}

In fast-ack (default) mode the response is a small acknowledgement — read outcomes from the contact's updated fields/notes, or check the team's Logs tab.

Safety rails

  • Webhooks are open by design — GHL does not let you set arbitrary auth headers. Use URL secrecy (the unguessable webhook_id) and signature verification if you need stronger protection.
  • Auto-create-off by default — the team cannot create new custom fields unless you flip a per-team flag.
  • Outbound SMS / email defaults to draft mode. Switch a team to auto-send only after you have watched it for a few days.
  • Every webhook fire is logged in the team's Logs tab with the inbound body, tool calls, and response.

Connecting in GHL

1
In Ambrose → team detail → Integrations → copy the GHL webhook URL.
2
In GHL → Workflows → pick the workflow → add a Webhook action.
3
URL = the copied URL. Method = POST. Body = use the workflow's {{contact.*}} variables, and add a customData key instructions with your command.
4
Save + activate the workflow. Test by triggering it on a test contact.
5
Open the team's Logs tab to see the inbound fire + the team's response.

HIPAA

The ghl spoke is scrubbed by default. Per-sub-account you can flip a BAA flag in the GHL integration card (only if you have a signed BAA covering that sub-account's data).