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 requiresGHL_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 meansctx.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 inconvex/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.countis a total available for truncation checks (not yet consumed by the sync). - Ids: records use
_id(notid); the mappers already prefer_id. - Amounts: whole USD dollars, not cents (commission
amount:100,commissionAmount:20,commissionType:"percentage"; payoutamount:20). The report/PDF/UI format them directly as currency — correct. - Referred contact: lives at
commission.customer.contactId(the CRM contact), whilecommission.customer._idis the affiliate-manager customer-record id. Storing_idbreaks the Phase 2 CRM join — fixed so the sync readscustomer.contactId. - Campaign refs: commissions carry
campaign:{id,name}; payouts carry a barecampaignNAME pluscampaignId— the name→id canonicalization collapses them to one campaign row, as designed. - Pagination:
skip+limitreturns distinct pages (verifiedskip=0vsskip=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:
- Read
GHL_AFFILIATE_SYNC_LOCATION_IDS; if empty → no-op,{ enabled: false }. - Per location:
getFreshGhlAccessToken→ page affiliates, commissions, payouts. - On
401/403, fall back to an agency location-token exchange (POST /oauth/locationToken,Versionheader2021-07-28) and retry. - Build a campaign name→id map (from a DB seed query + embedded refs), map rows,
derive
ghlAffiliateCampaigns, then batch-upsert viainternalMutations (upsertSyncedAffiliates/Campaigns/Commissions/Payouts) — skipped underdryRun.
(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)
Query-time resolution, no link table
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:
- Commission edge —
commission.contactId → commission.ghlAffiliateId. Exists only once money has moved. - Referral-attribution edge — a reconciled
partnerReferralEdgesrow (matchStatus: "matched"), built from the CRM contact’s own attribution (referral link/code) and therefore present before any commission. See “Referral attribution edges” below.
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 onghlCommissionId) as the winner; multiple claimants setambiguous: trueand listconflictingAffiliateIds.- 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.
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 onWYTz3L4dTiC4fpo8Lgtk: 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, keyedby_location_contact, alsoby_location_affiliateandby_location_matchStatus). A row records the parsedreferralToken, itssource(originalfirst-touch is authoritative,lastis fallback only), amatchStatus(matched|unmatched|ambiguous), and — only onmatched— theghlAffiliateId. - Producer:
reconcileLocationAttribution(convex/prospecting/partnerAttribution.ts,internalAction, location-gated bycanAccessPartnerModuleLocation). It pages the location’s contacts, folds each into an edge via the purebuildEdgesForContacts(convex/lib/partnerAttributionEdge.ts), upserts matched/unmatched/ambiguous rows, and invalidates edges for contacts the sweep saw carrying no parseable token. It is bounded byMAX_ATTRIBUTION_CONTACT_PAGESand reportstruncatedwhen a very large account exceeds that per-run page cap. A truncated result returns an opaquenextCursor; 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
/partnersrefresh only (syncLocationwithtrigger: "manual"inapp/api/admin/affiliate-partners/route.ts), after the affiliate sync succeeds, with the verifiedactor/locationId. The route returns the affiliate-sync result plus anattributionoutcome ({ 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
listAffiliatePartnersandgetAffiliatePartnerDetailaddmatchStatus: "matched"edges to the linked-contact set (money figures stay commission-derived).
internalQuery, location-gated, return null for unauthorized actors):
Operator and admin surfaces
- Shared API handler:
app/api/admin/affiliate-partners/route.ts, re-exported atapp/api/affiliate-partners/route.tsfor the operator surface. It dispatchessyncLocation,listPartners,partnerDetail,referralForContact, and the Phase 3 operations; pagination is capped at 100. Normal operators are pinned to their session location. - Operator UI:
app/(main)/partners/page.tsxandapp/(main)/partners/[affiliateId]/page.tsx, with hooks inapp/(main)/partners/_lib/useAffiliatePartners.ts. - Admin access is on the same
/partnerssurface (FUND-2046): staff/admin actors (isAdminActor) additionally see the admin-only cards — Partner program setup, Partner reporting defaults, and diagnostics. The formerapp/admin/partners/*pages were removed; do not restore them.
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 theenroll,
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:
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 newdataSource: "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.
partnerReportingSettingsis a singleton (convex/schemas/analytics.ts),enableddefaultsfalse. 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
analyticsScheduledReportswithpartnerId(aghlAffiliateId) andoutputFormat(csv|pdf), indexedby_partner. getSettings/updateSettings/getPartnerSchedule/upsertPartnerSchedule/removePartnerScheduleback the admin API (app/api/admin/partner-reporting/route.ts) and cardsPartnerReportingSettingsCard.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:- Grant scope. Ensure each location’s install has
affiliate-manager.readonly. A sync401/403means the scope is not granted. - Enable the scheduled sync. Set
GHL_AFFILIATE_SYNC_ENABLED=trueon the Convex deployment. Installed-location discovery is primary;GHL_AFFILIATE_SYNC_LOCATION_IDSis 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 barelocationId. - (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.
- (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 thetrigger 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):
- Confirm the affiliate exists in the campaign but
contact.affiliate_referral_link/affiliate_referral_idare blank. That combination means the read side is fine and the workflow is the problem. - Open the workflow’s Enrollment History for that contact (CRM UI, or
ghl_get_workflow_executionsvia the GHL MCP server). No execution recorded is the usual answer. - 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.
- 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.
- 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.
- Last resort: an admin pastes the referral link into the card’s manual override, which is exposed only in the exhausted state.
Troubleshooting
Gotchas / invariants
- Cache shapes are live-verified (2026-07-12,
oE9ILHco0XXZUu79wAA0).amountunits are whole USD dollars (not cents).rawDataremains 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— onlysetEnrollmentWorkflowOverridedoes. partnerIdon schedules is always aghlAffiliateId, never aprospectingPartners._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 whatpage.tsxmentions — 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.tsnow 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
pendingAssetstrue forever, and the attempt ceiling bounded requests, not time — a stalled request never failed an attempt.tests/partnerReferralAssetsTermination.test.tsasserts 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.

