Your Claude Code dashboard owns every form, opt-in, landing page, and the sales-call screens. GoHighLevel owns only the workflows and the CRM data. This guide shows the GHL workflows to build node-for-node, and the exact integration contract that wires your dashboard to them.
One rule decides where everything lives: if a human looks at it, it's in your dashboard; if it's automation or CRM data, it's in GHL. The two never overlap. Your dashboard writes data into GHL and pulls data out of GHL through the API, and starts workflows by firing webhooks. GHL runs the automation and (optionally) webhooks results back.
This is the big component. Because all forms are third-party, your dashboard is now responsible for every write that a native GHL form used to do for free. Build to this contract and the workflows behave exactly as drawn. All calls are GHL API 2.0 (V1 is end-of-support).
| Use case | What your dashboard does | GHL API / mechanic | When |
|---|---|---|---|
| ① Opt-in / capture (public landing pages) | |||
| Create the lead | Upsert contact: first, last, email, phone, services, city, source, consent | POST /contacts/upsert (dedupe by email/phone) | On opt-in submit |
| Record source | Write Source / Lead Source field | Same upsert payload (custom field) | On opt-in submit |
| Capture consent | Store TCPA/A2P opt-in + timestamp on a field | Custom field on the contact | On opt-in submit |
| Start the dispatcher | Kick off Assignment Of Lead | Inbound Webhook trigger, or POST add-to-workflow | Last, after the writes |
| ② Lead claim (rep dashboard) | |||
| Find the right contact | Typeahead search so the claim ties to the real contact | GET /contacts/search | As rep types |
| Assign the user ★ | Set the contact's assigned user to the claiming rep | PUT /contacts/{id} assignedTo: userId | On claim |
| Assign the lead ★ | Also set the Assigned Sales Rep custom field + add the rep-name tag | Same PUT (custom field) + POST tag | On claim |
| Map rep → GHL user | Translate "John" to the GHL user ID | GET /users/?locationId (cache it) | Dashboard load |
| Start New Lead | Begin nurture/dial (this also cancels the round-robin) | Inbound Webhook / add-to-workflow | On claim |
| ③ Sales-call forms (rep dashboard) | |||
| Load the client | Search + pull the contact into the call screen | GET /contacts/search + GET /contacts/{id} | Call start |
| Save call data | Write City/Location + the 3 levers (Send Contract & Invoice, Sales Call Email, Disqualified) | PUT /contacts/{id} (custom fields) | On submit |
| Save notes | Sales-call notes onto the contact | POST /contacts/{id}/notes | On submit |
| Move the pipeline | Create/update opportunity, stage, value, packed name | POST /opportunities | On submit |
| Book follow-up | Put the follow-up date on a GHL calendar | POST /calendars/events/appointments | On submit |
| Start Sales Call workflow | Fire Sales Call [Part I] — submit LAST | Inbound Webhook | After all writes |
| ④ Platform / cross-cutting (always) | |||
| Render dropdowns | Reps, sources, pipeline stages, calendars, services | GET users / pipelines / calendars / custom fields | Dashboard load |
| Field-ID parity | Write to the exact field IDs the workflows read | From the discovery-script output | Build time |
| Auth | Hold a Private Integration token server-side (per location) | Bearer token, never in the browser | Always |
| Reliability | Write data first, fire workflow last; retry/queue webhooks | Idempotency keys; log failures | Every submit |
| Sync back (optional) | Keep the dashboard live when GHL changes something | GHL outbound Webhook action → your app | On GHL events |
The workflows below live inside GHL. Build each node top to bottom. The blue node is the Inbound Webhook your dashboard fires; everything after it is standard GHL actions. The callout beside each card explains what ▸, why ★, customize ✦, and where the API touches 🔌.
Goal: clean the new contact, broadcast a claimable lead, and guarantee assignment within minutes (a rep claims it in your dashboard, or GHL round-robins it). Your dashboard starts this by firing its Inbound Webhook after the opt-in upsert.
Your landing-page form upserts the contact via the API, then calls this webhook URL with the contact ID. That starts the workflow. This replaces the old "Form Submitted: New Lead Form" trigger.
Add trigger → Inbound Webhook → copy the URL → paste into your dashboard's "create lead" handler as the final step.
Clears any old Do-Not-Disturb so a returning lead can be texted/emailed again.
Wipes contract lost — they're a fresh lead again.
Stamps new lead for stage + segmentation.
Condition: Tags Includes contract won.
Don't drip a "new lead" sequence at an existing client.
Email the team, drop them out. Call them instead.
Use Send Internal Notification → Email (not Slack).
Action Type Title case, Select Field First Name. Turns "JOHN" or "john" into "John".
So your first text doesn't scream "Hey JOHN". Cleans the name before any message uses it.
Same action, Select Field Last Name. The order matters: this one is #2, the first is #1.
Save the formatter output back onto the contact. Use these exact values — the dots and the double curly braces matter:
First name → {{text_formatter.1.result}} Last name → {{text_formatter.2.result}}
{{text_formatter.1.result}} pulls #1's output (First Name); {{text_formatter.2.result}} pulls #2's (Last Name). The number matches the formatter's order in the workflow — not an underscore, a dot.
Each branch updates Contact Source + alerts the team.
Sources differ per business. Default: Facebook Ads + SEO; add Google Ads, referral, etc.
Tells the whole team a lead is up for grabs. Keep the in-app Banner; send the same body as an Internal Email (not Slack).
🔔 New {{contact.city}} {{contact.source}} Lead
🔔 You have received a new {{contact.city}} {{contact.source}} lead Name: {{contact.name}} Email: {{contact.email}} Phone: {{contact.phone}} Service: {{contact.services}} Claim Lead: {{trigger_link.xxxxxxxx}}
Third-party model: point it at your dashboard's claim screen (carrying the contact's email). Native model: drop in the GHL {{trigger_link.…}}. Either way the rep clicks it to claim the lead.
Tracks the lead. Pack the name: {{contact.name}} | {{contact.city}} | {{contact.services}}.
Top-rep head start before the team gets re-pinged.
Grace window. Still unclaimed → auto-assign next.
Nobody claimed → round-robin so the lead never dies.
This fallback stays a GHL action. Your dashboard doesn't touch it — but a dashboard claim that starts New Lead removes the contact here before the 3-min timer, so it's skipped.
automatically assigned = the switch New Lead reads. Auto = SMS only. A dashboard claim writes the rep-name tag instead, not this one.
Hands off to New Lead (Section 2) for the call + nurture.
| What happened | How it's set | Tag written | Effect in New Lead |
|---|---|---|---|
| A rep claims in your dashboard | Dashboard sets assignedTo + Assigned Sales Rep field, then starts New Lead (which removes from this workflow) | [rep name] | Manual → auto-dials the rep |
| Nobody claims (3-min elapsed) | GHL round-robin Assign User fires | automatically assigned | Auto → SMS only, no call |
No GHL trigger — your dashboard starts it on claim (via webhook), or Assignment Of Lead adds it on auto-assign. Goal: lock in the right rep, dial when a human's available, nurture until they reply.
Cancels the round-robin timer so a claimed lead isn't reassigned.
Someone already claimed it — round-robining again would steal it.
Workflow contact Is NOT active in Sales Call [Part I] AND Sales Call [Part II].
Don't text "book a call" right after they had one. None → END.
One branch per rep. Example branch John (Sales Rep #1):
IF Assigned user Includes John (Sales Rep #1) AND Assigned Sales Rep Is not empty → keep the existing rep (do nothing)
A returning client already has a rep attached, so we keep them with the rep who closed them. This block only checks whether a name is already on the contact.
One branch per rep. Example branch John (Sales Rep #1):
IF Assigned Sales Rep Is John (Sales Rep Name #1) AND Assigned user Is empty → Assign to user: John
Your claim already set assignedTo + the Assigned Sales Rep field. This block is the safety net that pins the lead to John inside GHL if the native owner was left empty. Add one branch per rep (John, Alex, Joe …).
Condition: Tags Includes automatically assigned. Each side fires its own Internal Notification, then both wait 1 minute. (In GHL the "no condition" branch is labeled "None" — that's your Manual path.)
{{user.first_name}} has been automatically assigned to the lead {{contact.name}}.
{{user.first_name}}, you manually claimed the lead {{contact.name}}. You're now the assigned rep — get ready, a call is on the way.
A beat so it's not robotic. On the manual path it also gives the rep a moment to get ready for the call. Then add the source tag.
Auto-assigned = nobody's available, so the copy sends them to your calendar instead of saying "chat now." This is the message that saves the lead at 2 a.m.
Hey {{contact.first_name}}, it's {{user.first_name}} from {{location.name}}! I just saw that you filled out a form on our website looking for {{contact.services}} services. I'm not free at the moment, but I'd love to help! It would be great if you could schedule a call on my calendar at your earliest convenience: {{user.calendar_link}} 🙂
{{user.calendar_link}} is what lets the lead self-book when no one's available. Without it the auto path has no call to action.
Calls the rep's phone, plays the whisper, and on any keypress bridges the call to the lead. Call timeout: 60 seconds.
Hey {{user.first_name}}, {{contact.name}} has inquired about {{contact.services}} services. Please press any key on the keypad to be connected into a call with {{contact.first_name}}.
The rep gets a live lead on the line with zero dialing.
If the lead answers, Stop on Response removes them and this never sends. If they don't answer, this goes out. Manual copy says "chat now" because a rep is available.
Hey {{contact.first_name}}, it's {{user.first_name}} from {{location.name}}! I just saw that you filled out a form on our website looking for {{contact.services}} services. I'd love to help — are you free to chat now? If another time works better, you can book an appointment here: {{user.calendar_link}} 🙂
Wait 1 min → Email #1. Wait 1 day → Email #2. Another day → next, widening the gap.
Every email's one job: book a call. Add lead magnets; split-test once live.
This is the part most third-party builds get wrong. The New Lead workflow reads two different "owner" signals, so a claim has to write both.
PUT /contacts/{id} with assignedTo: <repUserId>. Read by the "Assigned user" conditions.POST the rep's tag (so it's NOT the auto-assigned path).Block #1 (keep-existing) checks Assigned user + Assigned Sales Rep is not empty — that's the returning-client case. Block #2 (assign-claimer) checks Assigned Sales Rep is [rep] + Assigned user is empty. If your claim only set the native owner, block #2 never fires and the lead can land unassigned in the field the rest of the system trusts. Write both, every time.
| Problem | Fix |
|---|---|
| Claim creates a duplicate contact | Use upsert / search-by-email before write. Never blind-create. |
| Lead lands unassigned in reports | You set assignedTo but not the Assigned Sales Rep field. Write both. |
| Workflow didn't start | You fired the webhook before the contact existed. Upsert first, webhook last. |
| Auto-assigned leads still get called | The fork reads the wrong tag — confirm automatically assigned. |
| Round-robin skips reps | Turn OFF "Only apply to unassigned" on the Assign User action. |
GHL already handles A2P texting, call bridging, deliverability, and scheduling. Rebuilding those is wasted effort. Build the front end; let GHL be the engine.
Either works. Inbound Webhook is simplest to wire and is what the diagrams show. Add-to-Workflow is handy when you already have the contact ID and want an explicit call.
Users (reps), custom fields, pipelines/stages, calendars, tags, and contacts — to render dropdowns and write to the right IDs. See the contract, group ④.
Yes — same pattern: search the contact, write fields/notes/opportunity/appointment, then fire the Sales Call workflow last. See the contract, group ③.
Optional enrichment for multi-region businesses. Removed to keep the teaching build fast; add back if you operate across regions.
Your dashboard talks to GHL using the hidden internal IDs behind your assets, not their names (a field called "Assigned Sales Rep" is really aZ8k2Lp9… under the hood). You have two ways to get them: paste the prompt below into GHL Ask AI (fastest, gets everything at once), or grab each one by hand from GHL using the click-paths below.
| Asset | Need it? | Why |
|---|---|---|
| Location ID | ✅ Yes ×1 | Every API call is scoped to the sub-account |
| Workflow IDs | ✅ Yes ×5 | Start workflows from the dashboard + the "not active in" checks |
| Custom Field IDs (+ key + options) | ✅ Yes ×7 | The dashboard writes to these exact fields |
| Pipeline ID + Stage IDs | ✅ Yes ×1 | Create/move the opportunity into the right stage |
| Calendar ID | ✅ Yes ×1 | Book the follow-up appointment via API |
| User IDs | ✅ Yes ×rep | assignedTo needs the user ID, not the name |
| Tag IDs | ❌ No | The API adds/removes tags by name — just keep the exact spelling |
| Trigger links | ❌ No | The claim now happens in your dashboard; the GHL claim link is retired |
| Private Integration token | 🔑 Auth | Not an ID — the runtime key your dashboard uses. Create once, store server-side |
Most IDs are the long string in your browser's address bar when you have that item open. The two GHL hides (field + stage IDs) are why Ask AI is the easy button.
| Asset | Click-path in GHL | Shows the ID? |
|---|---|---|
| Location ID | Settings → Business Profile, or read the address bar after /location/ | ✅ URL |
| Workflow ID | Automation → Workflows → open the workflow → it's the long string in the address bar | ✅ URL |
| Pipeline ID | Opportunities → open the pipeline → address bar | ◑ Pipeline in URL; stages hidden → Ask AI |
| Calendar ID | Calendars → Calendar Settings → open the calendar → address bar / booking link | ✅ URL |
| User ID | Settings → My Staff (Team) → open the person → address bar | ✅ URL |
| Custom Field ID | Settings → Custom Fields → open the field. You'll see the field key (e.g. contact.type_of_source) but usually not the raw ID | ❌ Hidden → Ask AI |
Replace every [bracketed] name with your real asset name, then paste the whole thing into GHL → Ask AI. It pulls only the assets you name, so it never truncates the way a full-account dump does. Anything it can't find by that exact name comes back as NOT FOUND so you know to fix the spelling.
Paste the results back to whoever's wiring the dashboard. With these IDs every row of the integration contract becomes a literal API call (upsert contact, set assignedTo + Assigned Sales Rep, create the opportunity at the New Lead stage, book the follow-up, start the workflow). Tag names you already have.
| Version | Date | Changes |
|---|---|---|
| 2.2 | Jun 29, 2026 | Added the copy-paste templates inside the actions: Title Case (Text Formatter ×2) + the exact {{text_formatter.1.result}} / {{text_formatter.2.result}} field values; the Banner Notification team-alert message; the Assigned User #1 (keep-existing) and #2 (assign-claimer) exact conditions using John; the Automatic vs Manual internal-notification messages; the external Automatic SMS, the Call whisper, and the no-answer SMS; and a hardened Stop on Response note. Each message has a Copy button. |
| 2.1 | Jun 29, 2026 | Added the Appendix — Pull your GHL IDs: what to look up vs. skip, the manual click-paths in GHL for each ID, and the copy-paste Ask AI lookup prompt (templated with fill-in asset names). |
| 2.0 | Jun 29, 2026 | Rebuilt for third-party architecture: all forms/pages in the Claude Code dashboard, GHL = workflow engine. Added the architecture split + full integration contract + assign-user deep dive. Trigger → Inbound Webhook; claim → API. Renamed rep example Jono → John. |
| 1.0 | Jun 29, 2026 | Initial native-GHL teaching build (simplified from the full snapshot). |