Partner Module — GHL Affiliate Manager integration (FUND-2039 → FUND-2042, FUND-2046)
Status: implemented. Operator/admin surfaces and their shared API are gated byNEXT_PUBLIC_PARTNER_MODULE_ENABLED. Scheduled cache sync additionally 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 and the
internal MFM team a cross-location /admin/partners surface. Both show 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
surface also configures periodic performance reports.
It is built entirely on top of the CRM’s Affiliate Manager. The public Affiliate
Manager API is read-only — you cannot create affiliates, campaigns, or
enrollments through it (campaign create/get/update/delete return 404, per the
AGENTS.md learned fact). So the module reads affiliate/commission/payout data on
a schedule, derives the rest at query time, and does writes (enrollment) by
adding contacts to pre-built CRM Workflows, never by calling Affiliate Manager
write endpoints.
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
Feature flag and kill switch
Two independent switches, on purpose:NEXT_PUBLIC_PARTNER_MODULE_ENABLED(lib/partnerModuleFlag.ts) gates the operator/admin pages, shared affiliate-partners API, and reporting API. Only the exact string"true"enables them.NEXT_PUBLIC_*is inlined at build time, so flipping it requires a redeploy.GHL_AFFILIATE_SYNC_ENABLEDmust be"true"for scheduled sync. Installed-location discovery is primary.GHL_AFFILIATE_SYNC_LOCATION_IDSis an additive emergency list for locations discovery misses.
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). The referral edge is
commission.contactId → commission.ghlAffiliateId. Consequences:
- A referred contact with no commission row yet is invisible to the join (this limitation is surfaced in the admin UI copy).
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.
Data surface (all 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. - Admin UI:
app/admin/partners/page.tsx(list) andapp/admin/partners/[affiliateId]/page.tsx(detail), both feature-flagged and wired throughapp/admin/partners/useAffiliatePartners.ts.
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 — Workflow enrollment bridge (FUND-2041)
Because Affiliate Manager has no enroll API, staff pre-builds one GHL Workflow per campaign that performs the enrollment when a contact is added to it. The module’s job is to add the contact to the right workflow. Resolution order (locked,convex/lib/ghlWorkflowLookup.ts):
- The campaign’s staff-set
enrollmentWorkflowIdoverride, if present. - Name-convention match against
GET /workflows/: a published workflow namedPartner Enroll — {Campaign Name}(note the em-dash—, constantPARTNER_ENROLL_WORKFLOW_PREFIX). - A clear staff-facing error — never a silent failure. Error strings say “CRM”, never GHL (Locked Decision).
client.workflows.getWorkflow) and cached in
module memory per location (WORKFLOW_CACHE_TTL_MS = 10 min).
Enrollment is asynchronous: the CRM workflow creates the affiliate downstream, and
the next hourly sync pulls it into the cache.
enrollContactInCampaign returns
{ success, workflowSource: "override" | "name-match", … }.
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. Omitting enrollmentWorkflowId
clears the override and restores name matching.
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.
sendPartnerDefaultOne handles affiliates with no saved override row.
Enrollment execution IDs are durable and payload-bound. The bridge records
sideEffectStartedAt immediately before adding the contact to the workflow. If
the external outcome is ambiguous or completion cannot be recorded, later calls
fail safe with an unknown-outcome response and never replay the workflow add;
leases remain reclaimable only when a crash happened before side-effect start.
Enabling admin and sync
Enable scheduled sync before revealing the launch-flag-gated operator/admin surfaces:- 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. - Reveal the UI. Set
NEXT_PUBLIC_PARTNER_MODULE_ENABLED=trueand redeploy Next.js (build-time inline). - (Optional) Enrollment. For each campaign, either publish a workflow named
Partner Enroll — {Campaign Name}or set anenrollmentWorkflowIdoverride via the admin UI. - (Optional) Reporting. Turn on
partnerReportingSettings(admin UI) 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 Setup & health 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.

