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) still work for back-compat, but they are team-only: an agent must use its webhook_id URL.

Agents have their own GHL webhook too. Use POST /api/agent/<webhook_id>/webhook/ghl with the agent's own 8-character webhook_id from its Integrations tab. The handler is identical for both kinds, it loads the node's Description and persona files and injects its selected skills either way. The URL's kind segment must match the id's owner, so an agent id posted to /api/team/… is refused rather than silently resolved.

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_id → customData.contact_id → contact.id → top-level id → email 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).

Telling it what to do

A webhook can carry instructions the same way a routine carries a prompt, and the instruction sent with the request wins over everything else. For a GHL workflow, put it in customData.instructions. With none sent, Ambrose falls back to the instruction stored on the node, then to the node's Description. See Telling a webhook what to do.