Skip to content

GoHighLevel pipeline tracking

A sales team uses GoHighLevel as their CRM. Every salesperson is a GHL user; every deal they work is a GHL opportunity that moves through pipeline stages until it's won, lost, or abandoned. The team wants SalesDash to show, per salesperson, how many deals reached each stage — how many demos got booked, how many quotes got sent, how many deals were won or lost — the total value of the deals at each stage, and how the pipeline converts from one stage to the next.

Flow metrics, not state metrics

The spec records one activity per (deal, stage) entry — every time a deal moves into a stage, a new activity for that stage is written and never removed. Metrics built on these activities count how many deals reached a stage, not how many deals are sitting in that stage right now. "Demos this week" is a meaningful number; "deals currently in demo stage" is not — that would need a different kind of integration (one that produces periods rather than activities).

The integration shape

One provider (a GHL workspace) with two sources, each feeding its own processor:

  • ghl_user poll source → User Processor — creates an agent_external_identity record for every GHL user, populated with the user's name and email so each identity can be linked to a SalesDash agent.
  • ghl_opportunity_stage_change webhook source → Opportunity Stage Change Processor — every time a deal moves to a new pipeline stage, GHL fires a webhook; the processor turns that into an activity.

Two processors, not one — and the reason is a quirk of GoHighLevel's webhook payloads. The stage-change webhook carries the assigned user's ID but not their name. If the Opportunity Stage Change Processor were the only place writing agent identities, every identity would land with the user's ID as both external_id and external_name (the operation's fallback when no name is supplied) — unrecognisable at link time. The User Processor pulls from the GHL user list, which does have the user's name and email, so identities arrive in the unlinked-identities list with their actual names visible.

A third processor for capturing the initial stage

GoHighLevel doesn't fire the stage-change webhook when an opportunity is created — only on subsequent stage transitions. So the initial stage (a fresh lead entering the pipeline) is missing from your stage-change activities. If you want to measure leads — and therefore conversion percentages from lead → demo → won — add a third processor on ghl_opportunity_create. See Variations to consider below.

User Processor

A small processor whose only job is to register every GHL user as an agent_external_identity in SalesDash. The spec on the ghl_user source model has just two instructions:

  1. upsert_project_external_identity with a literal external_id "Project". This creates a single project identity that every other identity in this integration is scoped under. See variations for whether you need this — many integrations do, some don't.
  2. upsert_agent_external_identity for each user, with:
    • external_idsource_model.id
    • external_namesource_model.firstName and source_model.lastName joined into one string with an implode_with_spaces expression
    • emailsource_model.email
    • project_external_identity — the project captured in step 1

That's the whole spec. No activity creation, no enrichments, no branching. After each poll cycle, every GHL user has an agent_external_identity record in SalesDash, populated with their name and email. New identities show up in Providers → Unlinked External Identities, where each one needs to be linked to a SalesDash agent — at which point activities written for that GHL user (whether by this processor or any other) start being attributed to that agent. See External Identities for the full picture of how linking works.

Opportunity Stage Change Processor

The substantive one. Every time an opportunity moves to a different pipeline stage, GHL fires a webhook to the ghl_opportunity_stage_change source. The webhook payload is small — barely more than the opportunity ID — so the processor uses enrichments to fetch the actual data it needs.

Enrichments

The source model is the stage-change event. Two enrichments hang off it, nested:

  • ghl_opportunity — fetches the full opportunity record (status, monetary value, assigned agent ID, last stage change timestamp).
  • ghl_pipeline_stage, nested under ghl_opportunity — fetches the stage record so the spec can read the stage's name rather than just its ID.

After the enrichments run, the spec sees a populated event.ghl_opportunity.* tree with everything the rest of the steps need.

Spec walkthrough

  1. upsert_project_external_identity — the same "Project" anchor as the User Processor.

  2. upsert_agent_external_identity — using event.ghl_opportunity.assignedTo (the GHL user ID of the assigned salesperson) as the external_id. Because the User Processor uses the same GHL user ID as external_id, this resolves to the same identity the User Processor populates — and once that identity has been linked to a SalesDash agent, the activity below gets attributed to that agent. Only external_id is set here; the User Processor is responsible for the user's name and email.

  3. define_variable — building the activity type by joining "GHL Opportunity:" with the stage name (event.ghl_opportunity.ghl_pipeline_stage.name) via implode_with_spaces. Captured into a variable so the next step can reference it. Example results: "GHL Opportunity: Discovery", "GHL Opportunity: Demo planned", "GHL Opportunity: Won".

  4. upsert_activity — writes the activity itself:

    • external_idevent.ghl_opportunity.id joined with event.ghl_opportunity.ghl_pipeline_stage.id via implode_with_spaces. This composition gives one activity per (deal, stage) pair, so the deal's full pipeline history is preserved as separate activities.
    • activity_type — the variable defined in step 3.
    • agent_external_identity / project_external_identity — the captured identities from steps 1–2.
    • momentevent.ghl_opportunity.lastStageChangeAt wrapped in parse_datetime. The string-to-datetime conversion is required because GHL hands back the timestamp as a string.
    • valueevent.ghl_opportunity.monetaryValue. Available to sum-style metrics.
    • contact_keyevent.ghl_opportunity.id. The opportunity ID is used as the contact-grouping key, which lets step 6 below back-propagate updates across every activity for the same deal.
  5. lock_record_field on the activity's moment field. If a deal is ever moved back to a pipeline stage it was previously in — say it goes from "Won" to "Negotiation" because something fell through — the existing activity for that (deal, stage) pair gets re-upserted on the next stage change. Without the lock, the activity's moment would update to the re-entry time. With it, the first time the deal entered that stage stays as the moment, which is what time-windowed metrics expect.

  6. update_contact_activities — back-propagates event.ghl_opportunity.monetaryValue to every existing activity matching this contact_key. Used when the deal value gets updated in GHL after earlier stage-change activities have already been written; without this step, the deal's old activities would keep showing the old (stale) value while only the latest activity reflects the current one.

When you can drop this step

Step 6 is here because deal values often change in CRM systems after earlier stage activities have already been written, and you want every activity in the deal's history to reflect the latest value. If your deals are immutable once created (the value doesn't change later), you can drop this step to simplify the spec. Worth verifying which case applies to your CRM before going live.

Variations to consider

When adapting this for your own integration:

  • Outcome-only tracking, instead of (or alongside) stage history. If you only care about a deal's latest outcome — won, lost, abandoned — without the per-stage history, compose the external_id from the deal's id and a literal " CLOSED" (joined with a concat expression). Pair it with an activity_type built from "Deal | " and the deal's status. The same activity gets overwritten as the deal's status changes, so the latest outcome is always what's on it.

    To get both stage history and outcome tracking, leave the stage-keyed upsert_activity from step 4 in place and add a second upsert_activity after it using the outcome-keyed shape. Each opportunity then produces two parallel activity streams — one with all the stages, one always reflecting the latest status — and metrics can count from whichever they want.

  • Skip the project anchor. If your tenant doesn't use the Projects feature, drop the upsert_project_external_identity step and the project_external_identity field on every other operation.

  • Add a creation processor to capture leads. GoHighLevel doesn't fire the stage-change webhook when an opportunity is created — only on subsequent stage transitions — so the initial stage is invisible to the Opportunity Stage Change Processor. If conversion metrics matter to your team (leads → demos → wins, for example), add a third processor on ghl_opportunity_create. The spec mirrors the Opportunity Stage Change Processor's — same identity stack, same activity composition — just bound to the create webhook source instead.