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.
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
| Field | Type | Required |
|---|---|---|
| Method | POST | yes |
| Content-Type | application/json | yes |
GHL's Webhook action sends the contact object plus a customData map (this is where you put your instruction + flags). Ambrose reads these fields:
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 field | Required | What it is |
|---|---|---|
contact_id / contact.id / top-level id | Yes | The 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) | Yes | The command for the team: e.g. "Draft a renewal SMS and add it as a note." |
email / contact.email | If no id | Used to look up the contact when no id is present. Loads that contact's prior conversation, notes, fields & opportunities. |
customData.sync | No | true 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_fields | No | true to let the team create missing custom fields. Off by default. |
| any other contact fields | No | name, 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.)
- 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 undercustomData) 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
ghl spoke.tier, note, tag_add, tag_remove, sms_draft, …) so the GHL workflow can pick them up as variables.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:
| Read | Write |
|---|---|
| read_conversation | add_note |
| read_notes | add_tags |
| read_opportunities | remove_tags |
| read_custom_fields_schema | update_contact_native |
| read_pipelines | update_custom_fields |
| read_tags | create_custom_field (off by default) |
| read_appointments | generate_sms / send_sms |
| read_calendars | generate_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
{{contact.*}} variables, and add a customData key instructions with your command.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.