Skip to main content

Partner Module — GHL Affiliate Manager integration (FUND-2039 → FUND-2042, FUND-2046)

Status: generally available to every location since PR #1552 (August 19, 2026). Operator/admin surfaces and their shared API are always available; session and location authorization still apply. Scheduled cache sync requires GHL_AFFILIATE_SYNC_ENABLED; installed-location discovery is primary and GHL_AFFILIATE_SYNC_LOCATION_IDS is additive emergency coverage. This is an internal engineering note and is not part of the Mintlify docs site (docs/docs.json). Ticket map: FUND-2039 = Phase 1 (sync + cache), FUND-2040 = Phase 2 (linking + admin UI + feature flag), FUND-2041 = Phase 3 (workflow enrollment bridge), FUND-2042 = Phase 4 (PDF report + scheduled email), and FUND-2046 = location-scoped operator access.

What it does

The Partner Module gives operators a location-scoped /partners surface inside the CRM; staff/admin actors use the same surface plus admin-only cards, gated server-side by isAdminActor (FUND-2046 folded the former separate /admin/partners pages into /partners). It shows the CRM’s Affiliate Manager partners (e.g. the “Funding Partners” campaign), which CRM leads/clients each partner referred, and support enrolling new partners. The admin cards also configure periodic performance reports. The detail overview uses the same segmentReferrals partition as its tabs: Total referred = Applicants (not yet clients) + Clients. Contacts without a pipeline marker stay in Applicants. Client conversion is a separate KPI (clients / total referred), not a Referred → Applicants → Clients funnel. Client classification retains the existing completed-agreement and downstream client/processing/funding-plan evidence; funding is not required for conversion. Signing is the normal business transition, but the aggregate client status must not claim a verified signature when classification came from other records. The module reads affiliates, campaigns, commissions, payouts, and customer relationships from the CRM API. The September 2026 API supports direct affiliate creation and enrollment in an existing campaign. The authenticated enrollment bridge uses it unless staff has configured an explicit workflow override. Campaign creation still uses the existing setup automation; ordinary hosted form signups continue to use their native CRM enrollment workflow.
September 2026 update (FM-2528). The Affiliate Manager API gained a write surface (affiliates, status, referral ids, suspension, customers, commissions, payout methods) and new reads. The Phase 1 rebuild inventory, read-contract verification status, and gap report live in partner-affiliate-manager-rebuild.md. Campaign create/update/delete remain undocumented and still go through the worker.
Terminology. User-facing copy says CRM, never GHL/GoHighLevel (Locked Decision). Backend code and this internal doc say GHL. “GHL API version” below refers to the Version request header.

Critical identity distinction

Two unrelated “partner” concepts share the word. Do not cross the streams: Everything in the Partner Module keys on ghlAffiliateId. analyticsScheduledReports.partnerId stores a ghlAffiliateId, not a prospectingPartners._id (convex/prospecting/affiliateLinking.ts L8-L10).

Architecture

Scheduled sync kill switch

Partners is generally available with no pilot allowlist, Labs opt-in, or UI rollout flag. PR #1552 completed the rollout; the leftover build-time UI switch has been retired. Session, location, admin, and enrollment-token authorization continue to govern access. GHL_AFFILIATE_SYNC_ENABLED must be "true" for scheduled sync. Installed-location discovery is primary. GHL_AFFILIATE_SYNC_LOCATION_IDS is an additive emergency list for locations discovery misses. The cron is registered but no-ops while scheduled sync is disabled.

Session actor auth pattern

Marketplace-iframe auth means ctx.auth.getUserIdentity() is undefined in these routes. The module uses the repo’s actor pattern instead:
canAccessPartnerModuleLocation (convex/lib/partnerModuleAccess.ts) allows a normal operator to access only the verified session location and allows staff/admin actors to switch locations. Unauthorized internal calls return null, which the route maps to 403. The shared affiliate-partners route and the admin-only partner-reporting route are POST-only with operation-dispatch bodies.

Phase 1 — Sync + cache (FUND-2039)

Cache schema

Four tables in convex/schemas/commissions.ts. Shapes were originally doc-inferred; live-verified 2026-07-12 against the FM validation location oE9ILHco0XXZUu79wAA0 (read-only probe — see “Live verification” below). Every normalized column except the keys is optional and rawData remains the source of truth.

Live verification (2026-07-12, location oE9ILHco0XXZUu79wAA0)

Read-only GET probes returned HTTP 200 on all three list endpoints — the scope is granted on this location (the earlier “token lacks affiliate-manager.readonly” note is stale for it). Confirmed:
  • Envelope: { <resource>: [...], meta: { count }, traceId }. meta.count is a total available for truncation checks (not yet consumed by the sync).
  • Ids: records use _id (not id); the mappers already prefer _id.
  • Amounts: whole USD dollars, not cents (commission amount:100, commissionAmount:20, commissionType:"percentage"; payout amount:20). The report/PDF/UI format them directly as currency — correct.
  • Referred contact: lives at commission.customer.contactId (the CRM contact), while commission.customer._id is the affiliate-manager customer-record id. Storing _id breaks the Phase 2 CRM join — fixed so the sync reads customer.contactId.
  • Campaign refs: commissions carry campaign:{id,name}; payouts carry a bare campaign NAME plus campaignId — the name→id canonicalization collapses them to one campaign row, as designed.
  • Pagination: skip+limit returns distinct pages (verified skip=0 vs skip=1).

API client — convex/lib/ghlAffiliateApi.ts

Raw fetch (no SDK — @gohighlevel/api-client@3.0.0 has zero affiliate-manager coverage). Version header v3; Bearer is the location access token. Constants: MAX_RETRIES=4, BACKOFF_BASE_MS=1000, transient 5xx set {500,502,503,504}. Error handling: 429 honors Retry-After (else exponential backoff); 401/403 returns { success: false, unauthorized: true } so the sync can classify “scope not granted yet” distinctly; 5xx and network rejections retry; the helper never throws. extractListItems defensively unwraps the common GHL list shapes. Pagination is skip + limit; the page loop lives in the sync, not here. There are no campaign endpoints (none exist upstream).

Sync action — convex/prospecting/affiliateSync.ts

runAffiliateSync is an internalAction ({ locationId?, actor?, dryRun? }). An explicit locationId requires a verified actor authorized for that location; scheduled calls omit both fields and use the deployment allow-list. Constants: SYNC_PAGE_LIMIT=100, MAX_AFFILIATE_SYNC_PAGES=25, UPSERT_BATCH_SIZE=100. Flow:
  1. Read GHL_AFFILIATE_SYNC_LOCATION_IDS; if empty → no-op, { enabled: false }.
  2. Per location: getFreshGhlAccessToken → page affiliates, commissions, payouts.
  3. On 401/403, fall back to an agency location-token exchange (POST /oauth/locationToken, Version header 2021-07-28) and retry.
  4. Build a campaign name→id map (from a DB seed query + embedded refs), map rows, derive ghlAffiliateCampaigns, then batch-upsert via internalMutations (upsertSyncedAffiliates/Campaigns/Commissions/Payouts) — skipped under dryRun.
Idempotency: every upsert keys on (locationId, ghl*Id) and patches the full row on match. upsertSyncedCampaigns deliberately strips enrollmentWorkflowId from the patch so the sync can never clobber the staff-set override. Cron: crons.interval("ghl affiliate manager sync", { hours: 1 }, …) (convex/crons.ts L104-L118).

Phase 2 — Partner ↔ lead/client linking (FUND-2040)

convex/prospecting/affiliateLinking.ts resolves “who referred this contact” at read time from the cache — there is no derived link table (plan decision D2). It joins on two referral sources:
  1. Commission edge — commission.contactId → commission.ghlAffiliateId. Exists only once money has moved.
  2. Referral-attribution edge — a reconciled partnerReferralEdges row (matchStatus: "matched"), built from the CRM contact’s own attribution (referral link/code) and therefore present before any commission. See “Referral attribution edges” below.
Both the paginated list (listAffiliatePartners, linkedContactCount) and the detail read fold matched edges into the same contact set, so a partner with referrals but no commissions no longer reads as 0. Money figures stay commission-only — an edge carries no amount. Consequences:
  • A referred contact is visible once either an attribution edge or a commission links it — a contact the CRM never tagged with a referral, and that produced no commission, still cannot be attached retroactively.
  • resolvePartnerForAffiliateContact (pure, unit-tested) picks the affiliate owning the earliest commission (ISO-string compare, deterministic tiebreak on ghlCommissionId) as the winner; multiple claimants set ambiguous: true and list conflictingAffiliateIds.
  • A self edge (a contact that is an affiliate’s own contactId) is tracked separately from the referral edge — a contact can be both.
  • Campaign ids are canonicalized (name→id) so rows written before the sync fix still resolve.
Scan caps keep reads bounded: COMMISSION_SCAN_CAP=500, LINKED_CONTACT_ENRICH_CAP=100, CONTACT_COMMISSION_CAP=50, SELF_CANDIDATE_CAP=10, CAMPAIGN_SCAN_CAP=100.

Referral attribution edges (pre-commission linking)

The commission join above sees a partner only after money has moved. The CRM records the referral URL on the contact itself, so a partner-referred contact that has not produced a commission is otherwise invisible (live-verified 2026-08-10 on WYTz3L4dTiC4fpo8Lgtk: 61 affiliates, 0 commissions, 8 attributed contacts). The referral-attribution edge closes that gap.
  • Persisted, not query-time. Unlike the commission edge, this one is written to partnerReferralEdges (convex/schemas/commissions.ts, keyed by_location_contact, also by_location_affiliate and by_location_matchStatus). A row records the parsed referralToken, its source (original first-touch is authoritative, last is fallback only), a matchStatus (matched | unmatched | ambiguous), and — only on matched — the ghlAffiliateId.
  • Producer: reconcileLocationAttribution (convex/prospecting/partnerAttribution.ts, internalAction, location-gated by canAccessPartnerModuleLocation). It pages the location’s contacts, folds each into an edge via the pure buildEdgesForContacts (convex/lib/partnerAttributionEdge.ts), upserts matched/unmatched/ambiguous rows, and invalidates edges for contacts the sweep saw carrying no parseable token. It is bounded by MAX_ATTRIBUTION_CONTACT_PAGES and reports truncated when a very large account exceeds that per-run page cap. A truncated result returns an opaque nextCursor; the Partner list hook keeps it in browser session storage keyed by location and sends it on the next manual refresh, so repeated refreshes advance through the account rather than rescanning page one. This survives navigation and reloads without allowing a stale response from one location to affect another. A completed pass clears that location’s cursor. First-touch attribution can never be reassigned by a later touch.
  • Trigger: the reconcile runs on the manual /partners refresh only (syncLocation with trigger: "manual" in app/api/admin/affiliate-partners/route.ts), after the affiliate sync succeeds, with the verified actor/locationId. The route returns the affiliate-sync result plus an attribution outcome ({ ok, truncated, matched }); the page surfaces a warning toast on failure or truncation and never discards the completed sync. Automatic discovery/focus refreshes deliberately do not launch the scan, so a tab focus cannot trigger a full contact sweep.
  • Read: both listAffiliatePartners and getAffiliatePartnerDetail add matchStatus: "matched" edges to the linked-contact set (money figures stay commission-derived).
Data surface (all internalQuery, location-gated, return null for unauthorized actors):

Operator and admin surfaces

One-off migration

fund2040DedupAffiliateCampaignNames (convex/oneOffMigrations.ts, ~L3470) removes name-keyed duplicate ghlAffiliateCampaigns rows, canonicalizes ghlCampaignId on commissions/affiliates, and preserves enrollmentWorkflowId onto the canonical row. Run it per-location, dry-run first:

Phase 3 — Direct enrollment with workflow compatibility (FUND-2041)

The September 2026 API supports creating an affiliate and enrolling it in an existing campaign. The enrollment bridge now uses that API when the campaign has no staff-set workflow override. It verifies a unique CRM contact email, a live same-location campaign, and the existing affiliate identity before writing. The response must name the requested contact and campaign before it is accepted into the cache. An existing membership is a read-only success. The create endpoint updates an existing affiliate by email. The bridge reads and preserves the existing profile before adding membership. It never uses the replace-profile endpoint or moves an affiliate between campaigns. A staff-set enrollmentWorkflowId retains the existing workflow path, including its custom steps. Clearing that override selects direct API enrollment. CRM-hosted signup forms and native enrollment workflows are unchanged. The shared backend remains enrollContactInCampaign in enrollAffiliate.ts. The Marketplace adapter supplies an authenticated location-bound actor and a required execution ID. Uncertain POST outcomes are not retried or redirected to a workflow. API enrollment success has no workflowId/workflowSource; workflow enrollment retains those fields. Neither path sends a separate Funding Machine welcome message.

Staff-only path (no operator UI)

The campaign auto-enrolls its own affiliates, so the operator-facing enroll card and workflow-override field are gone, and so are the enroll, setEnrollmentWorkflow, and listCampaigns operations on app/api/admin/affiliate-partners/route.ts — nothing a browser can reach calls these functions. The Convex functions themselves stay: enrollContactInCampaign is the backend of POST /api/marketplace/partner-enrollment, and setEnrollmentWorkflowOverride is the staff emergency path. Run either from the Convex dashboard or the CLI:
Both are admin-gated internally (isAdminActor), so a non-admin actor returns null no matter where the call comes from. Clearing enrollmentWorkflowId clears the override and selects direct API enrollment.

Phase 4 — PDF partner report + scheduled email (FUND-2042)

Reuses the analytics reporting engine with a new dataSource: "partner".

Report data — convex/analytics/partnerReport.ts

run (internalAction) requires a ghlAffiliateId equals <id> scope filter (PARTNER_SCOPE_FILTER_FIELD = "ghlAffiliateId"). _scan fetches the partner’s commissions + payouts and enriches referred contacts. Output columns are PARTNER_COLUMNS (convex/analytics/columns.ts) — a rowType of lead or payout plus contact/commission/payout fields. dataSource: "partner" is excluded from the operator ReportBuilder via OPERATOR_DATA_SOURCES (convex/analytics/types.ts).

Scheduling — convex/analytics/partnerReporting.ts

Two send paths, resolved at send time (locked): a per-partner override schedule wins; otherwise the global default sends if enabled; otherwise skip.
  • partnerReportingSettings is a singleton (convex/schemas/analytics.ts), enabled defaults false. It holds the global default cadence/format and the cron bookkeeping (nextRunAt, lastSentAt, lastError) for the shared default window. Delivery-time fallbacks: 9:00 UTC, Monday, the 1st.
  • Per-partner overrides live on analyticsScheduledReports with partnerId (a ghlAffiliateId) and outputFormat (csv | pdf), indexed by_partner.
  • getSettings/updateSettings/getPartnerSchedule/upsertPartnerSchedule/ removePartnerSchedule back the admin API (app/api/admin/partner-reporting/route.ts) and cards PartnerReportingSettingsCard.tsx / PartnerReportScheduleCard.tsx. MAX_RECIPIENTS=20.

Delivery — convex/analytics/scheduleCron.ts + emailReportNode.ts

The existing "analytics scheduled report send" cron ({ minutes: 15 }, sendDueReports) is extended: after normal due schedules, it claims the global default window with a persisted cursor and lease, dispatches one bounded page per action invocation, and advances nextRunAt only after the final page. A failed page records the error, releases its lease, and leaves the window due for safe resumption. When outputFormat === "pdf", emailReportNode renders a real PDF via convex/analytics/pdfRenderer.ts (renderPartnerReportPdf, jsPDF, brand navy/gold) and attaches it to the CRM email. Column widths are measured from the header labels and cell values (bounded by MIN_COLUMN_WIDTH / MAX_COLUMN_WIDTH); a section whose columns do not fit across the landscape page wraps into continued bands that repeat the identity column (layoutColumnBands, FM-2644) instead of truncating every cell. sendPartnerDefaultOne handles affiliates with no saved override row. Enrollment execution IDs are durable and payload-bound. The bridge records sideEffectStartedAt immediately before the enrollment POST or workflow add. If the external outcome is ambiguous or completion cannot be recorded, later calls fail safe with an unknown-outcome response and never replay the enrollment side effect; leases remain reclaimable only when a crash happened before side-effect start.

Enabling admin and sync

The Partners surface is available to every location. Configure its integrations:
  1. Grant scope. Ensure each location’s install has affiliate-manager.readonly. A sync 401/403 means the scope is not granted.
  2. Enable the scheduled sync. Set GHL_AFFILIATE_SYNC_ENABLED=true on the Convex deployment. Installed-location discovery is primary; GHL_AFFILIATE_SYNC_LOCATION_IDS is additive emergency coverage. A signed-in operator or admin can also use Refresh on /partners; explicit-location refreshes require the verified session actor and do not trust a bare locationId.
  3. (Optional) Enrollment. Use the authenticated Marketplace enrollment adapter for direct API enrollment. Staff can set an explicit workflow override for custom automation. Native CRM signup workflows remain independent.
  4. (Optional) Reporting. Turn on partnerReportingSettings (admin card on /partners) for global sends, and/or add per-partner override schedules.

Referral assets: freshness and the missing-referral runbook

Referral assets (the sub-affiliate signup link and its embed) can only be built from the partner’s CRM referral ID, which the CRM exposes solely through the “Affiliate Enrollment → Save Referral ID & Link” workflow’s dynamic values. No public API returns it, so when that workflow does not run for a partner, this app has nothing to synthesize from and says so instead of inventing a link. How the app gets fresh without asking. Three cost tiers, all attributed in the Convex logs by the trigger field on [GHL affiliate sync] run start: The card’s states come from _lib/referralAssetsCardState.ts: pending (poll in flight — the only state that shows a spinner), waiting (nothing running, so it says the assets aren’t ready and offers “Check again”), exhausted (budget spent — “Check again” plus, for admins only, the manual link override), or ambiguous (the partner is in more than one campaign, so nothing is attributed and no poll runs). Diagnosing a partner whose assets never arrive (read-only, in this order):
  1. Confirm the affiliate exists in the campaign but contact.affiliate_referral_link / affiliate_referral_id are blank. That combination means the read side is fine and the workflow is the problem.
  2. Open the workflow’s Enrollment History for that contact (CRM UI, or ghl_get_workflow_executions via the GHL MCP server). No execution recorded is the usual answer.
  3. Check the trigger configuration against the path the affiliate was actually created through. Known causes, most common first: the affiliate was added by hand in the Affiliate Manager UI and the trigger only covers the form path; the trigger’s campaign filter does not match the campaign; the workflow was published after the affiliate enrolled.
Repairing it. The repair is upstream — make the workflow fire — and it is staged deliberately:
  1. Test with a synthetic affiliate/contact whether adding a contact directly to the workflow resolves the dynamic referral values outside the enrollment trigger. If it does, that is the cheap fix.
  2. Anything touching a real partner’s record (including remove/re-add re-enrollment) needs a snapshot of the affiliate record and its commission history, a written impact check, and Brock’s explicit approval first — re-enrollment can reset or duplicate attribution.
  3. Last resort: an admin pastes the referral link into the card’s manual override, which is exposed only in the exhausted state.
Fixing the trigger itself is a configuration change in the shared admin-account workflow and also needs Brock’s go-ahead.

Troubleshooting

Gotchas / invariants

  • Cache shapes are live-verified (2026-07-12, oE9ILHco0XXZUu79wAA0). amount units are whole USD dollars (not cents). rawData remains the source of truth for any field the normalizer does not project. Shapes on other locations should still be spot-checked before trusting their cache.
  • The sync never writes enrollmentWorkflowId — only setEnrollmentWorkflowOverride does.
  • partnerId on schedules is always a ghlAffiliateId, never a prospectingPartners._id.
  • The em-dash in Partner Enroll — {Campaign} is load-bearing (matched literally).
  • Enrollment is eventually consistent: the affiliate appears only after the CRM workflow runs and the next sync pulls it in.

Why PR #1375’s gates missed the operator plumbing and the endless spinner

The July 2026 hotfix (operator-only campaign card, finite create progress, terminating referral-assets poll, worker preflight) fixed symptoms PR #1375’s test suite was green on. Three gaps, kept here so the next page rework doesn’t repeat them:
  • Source-string gates, not render gates. The page tests (tests/partnersCampaignAutomationPlacement.test.ts) asserted what page.tsx mentions — component names and prop strings — which proves wiring, not visibility. The Partner program setup disclosure rendered for every viewer, and no test rendered the page as a non-admin to notice. tests/partnersOperatorSurface.test.ts now renders both roles.
  • Only the happy poll path terminated. The polling tests walked the attempt ladder to its terminal states, but every early-return path (no locationId, failed first read, cached “needs polling” contradicted by the fresh read) left pendingAssets true forever, and the attempt ceiling bounded requests, not time — a stalled request never failed an attempt. tests/partnerReferralAssetsTermination.test.ts asserts every exit path settles and a wall-clock budget exists.
  • The worker had no startup contract to test. Unit tests stubbed the MCP client, so a child env missing GHL_USER_ID (or holding a 403 key) was unreachable by any test; the failure surfaced one claimed request at a time as a redacted generic error. The preflight (runWorkerPreflight, tests/partnerCampaignWorkerPreflight.test.ts) makes the contract explicit and refuses to start on it.

File index