CRM & External Integration
backend is the integration hub. The dominant CRM is
GoHighLevel (GHL); HubSpot, Salesforce, Jobber, Slack, Stripe/Xero, Twilio,
and several enrichment providers are also wired in. Each integration is a
service under backend/app/services/<Name>Service.js with config in
app/configs/ — but since 2026-09 tenant CRM access goes through the CRM
port described first, and touching a provider service directly is an
allowlisted exception.
The CRM port (crm global)
app/services/CrmService.js is the port; app/services/crm/ holds
capabilities.js, canonical.js, errors.js, resolveProvider.js, and
adapters/{Ghl,Hubspot,Salesforce,Jobber}Adapter.js. It's loaded as the
crm global (hooks.js app.loadService('crm', …)), and adapters are
stateless — they resolve their provider service off the global at call time
(SERVICE_GLOBAL: ghl→goHighLevel, hubspot→publicHubspot,
salesforce→salesforce, jobber→jobber).
Calling it. crm.run({ company, capability, requested, call }) picks a
provider and returns an envelope { ok, provider, data, unsupported, error }.
A capability the provider lacks comes back as unsupported, never a throw.
Helpers: forCompany, connected/isConnected, supports, providersWith,
resolveProvider, and passthrough('ghl') — the escape hatch for GHL-only
surfaces, which throws for any other provider.
Provider precedence (crm/resolveProvider.js): ghl → hubspot → salesforce → jobber, each with its own connection test (GHL location_id, HubSpot
access_token, Salesforce instance_url and not expired, Jobber
account_id). REQUIRED_FIELDS is the projection callers must select — the
Salesforce tokens are select:false, so a lean company doc without them reads
as disconnected. Naming a requested provider that isn't connected returns
null rather than falling through.
Capability matrix (crm/capabilities.js, 12 keys): the adapter method
vocabulary is getContact, findByEmail/Name/Phone, createContact,
updateContact, getEnrichment, createNote, listPipelines,
getContactStage, searchDeals. HubSpot has no phone/name search and no
deal-by-contact; Salesforce is Leads-only (no get/update/enrichment/notes/
pipelines/stage/deals); Jobber has no enrichment/pipelines/deals but does
derive contact_stage. Nox's CRM read tools therefore read GHL or HubSpot
only.
The GoHighLevel boundary. capabilities.js GHL_ONLY splits GHL's
non-portable surface into BY_DESIGN (auth, marketplace, calendar, tags,
workflows, conversations, aggregate) and NOT_YET_PORTED (opportunities,
custom fields, upsertContact, searchDormantContacts).
test/crm-registry.spec.js enforces it: every public GoHighLevelService
method must be ported or classified (not both, no stale names), calendar
methods must not appear in GhlAdapter, and only the 23 files in
GHL_DIRECT_ALLOWED may reference global.goHighLevel — a new direct caller
fails the suite, as does a stale allowlist entry. Add through the port.
Sync fan-out. CrmSyncHelper is now a registry-driven loop: every
connected provider receives each lead (CrmSyncHelper.js ~220–250), and
visitor-stated name/email/phone are written onto matched contacts, not just
created ones (014f45457). Chat transcripts and AI-call summaries post as
notes wherever note_create is true (GHL, HubSpot, Jobber).
crm_links — one id array instead of a column per provider
UserSessionModel and PersonProfileModel carry
crm_links: [{ provider, object, external_id }] with one multikey index
{company_id, 'crm_links.provider', 'crm_links.external_id'}. The legacy flat
columns (ghl_contact_id, hubspot_contact_id, salesforce_lead_id) are
dual-written; Jobber is the first provider with no legacy column
(provider→object: salesforce→lead, jobber→client).
app/lib/crmLinks.jslinkOps()builds two guarded atomic ops —$setoncrm_links.$when$elemMatchproves presence,$pushwhen$not:$elemMatchproves absence — runordered:true, so concurrent writers can't duplicate a(provider, object)row (d7cc53758). A blankexternal_idclears the row and nulls the legacy column.setLinkwraps it;many:trueis for the relink-all-sessions reconcile paths.readLinkstill prefers the legacy field and falls back to the array. The flip to array-first is a one-line change incrmLinks.jsthat has not happened yet; do it only after the backfill has run in prod.- Backfill:
app/lib/crmLinksBackfill.jsviaPOST /migrations/backfill-crm-links+GET …/jobs/:id(dry-run default,background:trueon real data). It walks company by company with{company_id, <col>: {$gt: ''}}so it rides the existing indexes — the first collection-wide$orwas a COLLSCAN that timed out in prod — and queries the unindexedsalesforce_lead_idonly on tenants with a Salesforce connection.BATCH = 500,bulkWrite ordered:false, idempotent. Applied in prod 2026-09-05.
Scheduling port
app/services/scheduling/ mirrors the pattern for calendars:
resolveProvider.js, normalize.js, errors.js,
adapters/{Ghl,Calendly,Google,Jobber}Adapter.js, exposed as
POST /scheduling/{calendars,availability,book,meetings,reschedule,cancel}.
The ladder is GHL with calendar_id → Calendly → Google → GHL-connected →
Jobber → custom_calender — the file says of itself that it is the sixth
copy of a ladder that also lives in five other places (ms-communication's
CompanyContextHelper/MessageController/CallsController carry the Jobber
rung too). Widget booking_type is 'ghl' | 'google' | 'calendly' | 'jobber'.
Platform CRM vs tenant CRM
PlatformCrmService.js is Knock Knock's own CRM accounts — signup and
lifecycle tagging, certification and partner funnels — not a tenant
integration. It fans out upsertContact, syncCompany, and
updateContactEmail to every provider enabled in configs/platformCrm.js that
is connected and implements the verb (PlatformGhl on a private-integration
token, PlatformHubspot); one provider throwing is logged and returns false
without stopping the others. It returns {} without writing on dev and
staging, is loaded behind SERVICE_PLATFORM_CRM, and is deliberately not
gated on tenant GHL OAuth credentials.
CRM smoke test
POST /migrations/crm-smoke (+ jobs poll), re-exposed as
POST /admin/identity/maintenance/crm-smoke for the admin panel's Identity
Maintenance card. app/lib/crmSmoke.js drives the deployment's own HTTP
routes — what ms-ai sees — discovering its own tenants with bounded,
indexed, maxTimeMS(20_000) queries and firstAlive() token probes (a dead
token skips a check, it doesn't fail the run). It asserts api-key enforcement,
envelope provider names, GHL/HubSpot read shapes, dry-run backfill writes
nothing, apply-then-rerun idempotence, and the write path — which refuses to
touch a customer tenant unless an explicit ghl_company_id is passed. Our own
tenants (owner @knockknockapp.ai) outrank linked ones in discovery.
Boot order: the scheduler loads last
SchedulerService constructs all 62 jobs and starts ticking in its
constructor, so any job whose constructor reads a global defined later in
hooks.js crashed the schedular pod only — API pods never construct jobs.
KkTokenSyncJob → AgentKPushService → NoxGhlActionService reading crm was
the first casualty (1e57eefab); the real fix (7d5409b19) makes
app.loadService('scheduler', …) the last statement of boot(). Keep it
there, and reproduce schedular-only crashes locally with SERVICE_CRON=true.
GoHighLevel (GHL)
UI copy now says "LeadConnector" (frontend-wide rename); code/config keys remain
go_high_level/ghl.
The largest integration (GoHighLevelService.js, ~61 KB). It syncs contacts,
posts call/chat outcomes as notes, updates pipeline stages, triggers workflows,
and handles calendar/SMS/voice.
Mechanics:
- Inbound webhooks — contact updates / pipeline changes arrive via
goHighLevelRoutes.jsand are pushed onto a Redis-backedCrmSyncQueueService. - Outbound — cron jobs post activity back to GHL:
OutboundCallGhlSyncJob(1 min) — call transcripts → contact notes.AiCallGhlTranscriptSyncJob(5 min) — AI call transcripts + the recording attached to GHL Conversations as a playable inbound message (sendCallRecording, needs onlyconversations/message.write;.mp3URL suffix for the inline player).KnoxGhlSyncJob(nightly 03:00) — pipeline-stage sync + new-visitor backfill.- Post-call extras: outcome tags per
agent_key→post_call_tags(GhlPostCallTagsService, clobber-guarded) and a per-outbound-agentpost_call_summary_fieldcustom-field write.
- Resilience —
app/lib/ghlResilience.jswraps calls for retry/backoff. - v2 app — v2 is now the default for new installs
(
GO_HIGH_LEVEL_NEW_INSTALL_APP_VERSION=v2); token refresh stays per storedapp_version. Public marketplace-directory installs are stateless (install code handed to the SPA, agency + sub-account two-step,needs_mapping), and disconnect now fully uninstalls the app GHL-side (location, company-level, and agency installs). See GoHighLevel.
Recent (2026-07/08):
- Multi-calendar booking —
go_high_level.multi_calendar_enabled/multi_calendar_ids, publicGET /go-high-level/:company_id/booking-calendars, widget picker, ms-aicalendar_idtool param only when >1 calendar (one-timePOST /elevenlabs/sync-calendar-tools). - Refresh-token race fix —
bookCalendarwarms the OAuth token once (getCompanyClient) before fanning out parallel GHL calls (GHL refresh tokens are single-use; a race could persist a stale token) and parallelizes contact + calendar fetches to fit ms-ai's 20s booking budget. - Chat-start-URL note — super-admin
leadconnector_chat_start_url_enabledadds the chat's starting page URL as a note on sync (CrmSyncHelper).
Nox also writes to GHL through its own action layer
(ms-ai knox-ghl-sync.service.ts) — see Nox Assistant.
Calendar bookings (unified)
Every booking source — Calendly, GHL, Google Calendar, Jobber, generic — records through
backend/app/services/BookingRecordService.js
(record({ company_id, user_session_id, booking, event_name, end_time })), which
stamps the new booking fields on UserSessionModel (bookings[]:
booking_type/booking_date/booking_time/Calendly invitee ids for webhook
dedupe, had_booking) and PersonProfileModel. Don't write bookings ad hoc.
Calendly custom questions: CalendlyController._getLeadContext(user_session_id)
gathers lead context — session phone, Apollo/Snitcher company name, and a digest
of the visitor's own chat messages — to fill Calendly custom questions at
booking. Never throws; booking must not fail on context gathering.
HubSpot
HubspotService.js / PublicHubspotService.js — contact enrichment, deal-stage
sync, form submissions. Inbound via hubspotRoutes.js + webhookRoutes.js. The
port binds HubSpot to the publicHubspot global only; HubSpot writes were
restored in the fan-out refactor (042ff03cd) after the port scaffold had
dropped them.
Salesforce (Leads only, 2026-08)
SalesforceService.js + crm/adapters/SalesforceAdapter.js,
SalesforceController.js, salesforceRoutes.js, configs/salesforce.js.
- Gating:
SERVICE_SALESFORCEplusSALESFORCE_CLIENT_ID,_SECRET, and_REDIRECT_URIall present (hooks.js) — the service throws on construction if any is missing, and an unconfigured server must still boot._REDIRECT_URIis deliberately not derived fromapp.base_url. - OAuth: PKCE + HMAC-signed single-use state (10-minute handshake TTL).
Sandbox (
test.salesforce.com) and production (login.salesforce.com) are different token issuers — a token minted at one can't be refreshed at the other, so the environment is chosen at connect time and stored. Scopes areapi refresh_token offline_access, neverfull. - Bring-your-own app: credentials come from an External Client App (the
legacy Connected App path is restricted from Spring '26).
POST/DELETE /salesforce/app-credentialsstores a tenant's own client id + encrypted secret;_credsFromDocprefers those and reportssource: 'customer' | 'knockknock'. Disconnect does not clear them. - Writes:
buildLeadPayload—LastNameandCompanyare the only required-on-create fields. LastName ladder: explicit → last token → email local part →Unknown(a derived LastName equal to FirstName blanks FirstName). Company ladder: name → email domain unless inFREEMAIL_DOMAINS→[Unknown]. SOQL literals go throughescapeSoqlbecause emails/phones are visitor input. If the org rejects an optional field (picklist/validation), the optional set is dropped and the insert retried. - Upsert:
upsertLeadPATCHessobjects/Lead/{external_id_field}/{value}only when an admin has setsalesforce.external_id_field; HTTP 300 means an ambiguous match and nothing is written.Sforce-Duplicate-Rule-Header: allowSave=trueunlessallow_duplicates === false. The config knobs (lead_source,owner_id,external_id_field,allow_duplicates,custom_field_map) exist onCompanyModelwith no UI. SalesforceTokenHeartbeatJob: Salesforce enforces a 30-day refresh-token idle timeout, so quiet orgs die silently. The job pings/limitsfor orgs untouched for 14 days (25 per batch, read-only) and marks the connectionexpiredso the card says Reconnect. Nothing flows back from Salesforce — no webhooks, no polling.
Jobber (beta, 2026-09)
JobberService.js + crm/adapters/JobberAdapter.js +
scheduling/adapters/JobberAdapter.js, JobberController.js,
JobberWebhookHelper.js, jobberRoutes.js, configs/jobber.js. Plan doc:
backend/docs/jobber-integration-plan.md.
- Gating/env:
SERVICE_JOBBER+JOBBER_CLIENT_ID,JOBBER_CLIENT_SECRET,JOBBER_REDIRECT_URI(defaults to{BASE_URL}/jobber/install);JOBBER_API_HOST,JOBBER_GRAPHQL_VERSION(default2025-04-16),JOBBER_HANDSHAKE_TTL. Env must exist in both backend and schedular configmaps (done in PROD 2026-09-09). - OAuth: PKCE, signed single-use state parked in
JobberOauthHandshakeModel, rotating refresh tokens serialised behind_withRefreshLock(a concurrent refresh would burn the token), tokensselect:false, connection markerjobber.account_id. Routes:GET /jobber/installandPOST /jobber/webhookpublic;install-url,status,verify,uninstallbehind passport. - GraphQL client:
gql()retries once after a 401-triggered refresh;unwrap()treats top-levelerrorsas failures and mapsextensions.code === 'THROTTLED'toRateLimitedwithretry_after_msfrom the cost-bucket deficit — throttles arrive as HTTP 200. Limits are 2,500 requests per 5 minutes plus a 10,000-point cost bucket refilling 500/s; always passfirst:— an unbounded connection costs 100 nodes. - Webhooks: HMAC
X-Jobber-Hmac-SHA256over the raw body; Jobber wants an answer within 1s, soAPP_DISCONNECTis one indexed update and work objects go tocrmSync.jobberWebhook. Payloads are ids-only, so the helper re-queries the work object →client.id→ matches the session/profile viacrm_links$elemMatch. Topics:QUOTE_SENT,QUOTE_APPROVED,JOB_CREATE,JOB_CLOSED,INVOICE_CREATE,PAYMENT_CREATE(also moves the funnel contact to won). Registration is per-app in the Jobber Developer Center, one URL per topic — not per account through the API. - Stage ladder (Jobber has no pipeline object): Invoice paid (won) →
Invoiced → Job completed → Job scheduled → Quote approved → Quote sent →
Quote drafted → Assessment scheduled → Request received; archived
quote/request = lost. Furthest wins, emitted in the GHL/HubSpot
getContactStageshape withpipeline_id: 'jobber'. - Booking = a Request with a scheduled Assessment (
createRequest,editAssessment,deleteAssessment,listScheduledItems). Jobber has no free-slot API, so availability is working hours minus the schedule:SLOT_MINUTES = 60,LOOKUP_WINDOW_DAYS = 60, default 09:00–17:00. - Plan caveats: a draft Jobber app is capped at 5 paying accounts;
going beyond needs marketplace review (APP_DISCONNECT handling, token
rotation on, Manage App URL, 384px logo, gallery).
note_createshippedtrue(the v1 plan said false);pipelinesand Jobber enrichment are deliberately not built.
Slack
SlackService.js — notifications and message relay; ms-communication also does
Slack thread sync for chats (OAuth + event subscriptions with raw-body signature
verification).
Payments & accounting
Stripe (StripeService.js, checkout/billing) and Xero (XeroService.js, invoice
sync). Revenue reporting depends on paid_at being stamped on paid invoices and
currency stored per invoice.
Telephony
Twilio (calls/SMS/video, mostly via ms-communication) and ElevenLabs (AI voice,
via ms-ai).
Enrichment providers
Clearbit, Apollo, Rb2b — B2B company/person data feeding the
identity graph. Tracked via ApolloRecordModel,
ClearbitSessionModel, Rb2bService.js. HappierLeads was deleted
(d8a283a1b, 2026-09-09) — pixel, service, and webhook; only readers of the
stored happier_leads_data blob remain.
LeadContactSyncJob — the job that pushed enrichment-guessed contacts into
GHL/HubSpot — is disabled globally (commented out of SchedulerService).
Trade / field-service (Simpro + Tapi)
SimproService.js (connect, cost-centre mapping, in-call AI job creation, outbound
invoice-chasing) and TapiService.js (IMAP work-order emails → ms-ai field
extraction → Simpro). Phone search in Simpro must be digits-only.
SimproInvoiceChaseJob and TapiPollJob run on short cadences.
Recent: job creation now applies an AI-picked trade tag — ms-ai
POST /agents/pick-job-tags picks one best tag from the build's tag catalogue,
and the Tapi extractor accepts the tag vocabulary (an "AI-Booked" marker exists
but is disabled). ms-ai POST /elevenlabs/sync-simpro-tool keeps the voice
agent's job-booking tool config synced.
Webhooks (generic)
webhookRoutes.js + WebhookModel/WebhookLogModel/WebhookEventModel support
customer-registered outbound webhooks (HubSpot, Zapier, custom). Dualhook handles
WhatsApp onboarding (HMAC-verified). Post-call webhooks now ship
call_sentiment/transcript_text/a signed recording_url, delivery waits for
the ElevenLabs analysis with a PostCallWebhookFallbackJob exactly-once fallback,
and the new_user_identified event is revived — see
Webhooks.
ManyReach (Agent K beta)
ManyReachService.js pushes kk-tokenized prospects into a tenant's ManyReach
cold-email campaigns (routes under /knox/agent-k/manyreach/*, gated by
company.agent_k_beta_enabled); a CSV token-minting path covers any other
external sender. See ManyReach.
The sync queue pattern
Most CRM work flows through Redis-backed queues + cron sweepers rather than
synchronous calls, so a slow or failing CRM doesn't block request handling and work
is retried. Watch CrmSyncQueueService (which now also drains Jobber webhooks)
and the *GhlSyncJob family when debugging sync gaps.