Skip to main content

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).

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.js linkOps() builds two guarded atomic ops — $set on crm_links.$ when $elemMatch proves presence, $push when $not:$elemMatch proves absence — run ordered:true, so concurrent writers can't duplicate a (provider, object) row (d7cc53758). A blank external_id clears the row and nulls the legacy column. setLink wraps it; many:true is for the relink-all-sessions reconcile paths.
  • readLink still prefers the legacy field and falls back to the array. The flip to array-first is a one-line change in crmLinks.js that has not happened yet; do it only after the backfill has run in prod.
  • Backfill: app/lib/crmLinksBackfill.js via POST /migrations/backfill-crm-links + GET …/jobs/:id (dry-run default, background:true on real data). It walks company by company with {company_id, <col>: {$gt: ''}} so it rides the existing indexes — the first collection-wide $or was a COLLSCAN that timed out in prod — and queries the unindexed salesforce_lead_id only 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.js and are pushed onto a Redis-backed CrmSyncQueueService.
  • 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 only conversations/message.write; .mp3 URL suffix for the inline player).
    • KnoxGhlSyncJob (nightly 03:00) — pipeline-stage sync + new-visitor backfill.
    • Post-call extras: outcome tags per agent_keypost_call_tags (GhlPostCallTagsService, clobber-guarded) and a per-outbound-agent post_call_summary_field custom-field write.
  • Resilienceapp/lib/ghlResilience.js wraps 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 stored app_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 bookinggo_high_level.multi_calendar_enabled / multi_calendar_ids, public GET /go-high-level/:company_id/booking-calendars, widget picker, ms-ai calendar_id tool param only when >1 calendar (one-time POST /elevenlabs/sync-calendar-tools).
  • Refresh-token race fixbookCalendar warms 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_enabled adds 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_SALESFORCE plus SALESFORCE_CLIENT_ID, _SECRET, and _REDIRECT_URI all present (hooks.js) — the service throws on construction if any is missing, and an unconfigured server must still boot. _REDIRECT_URI is deliberately not derived from app.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 are api refresh_token offline_access, never full.
  • 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-credentials stores a tenant's own client id + encrypted secret; _credsFromDoc prefers those and reports source: 'customer' | 'knockknock'. Disconnect does not clear them.
  • Writes: buildLeadPayloadLastName and Company are 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 in FREEMAIL_DOMAINS[Unknown]. SOQL literals go through escapeSoql because emails/phones are visitor input. If the org rejects an optional field (picklist/validation), the optional set is dropped and the insert retried.
  • Upsert: upsertLead PATCHes sobjects/Lead/{external_id_field}/{value} only when an admin has set salesforce.external_id_field; HTTP 300 means an ambiguous match and nothing is written. Sforce-Duplicate-Rule-Header: allowSave=true unless allow_duplicates === false. The config knobs (lead_source, owner_id, external_id_field, allow_duplicates, custom_field_map) exist on CompanyModel with no UI.
  • SalesforceTokenHeartbeatJob: Salesforce enforces a 30-day refresh-token idle timeout, so quiet orgs die silently. The job pings /limits for orgs untouched for 14 days (25 per batch, read-only) and marks the connection expired so 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 (default 2025-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), tokens select:false, connection marker jobber.account_id. Routes: GET /jobber/install and POST /jobber/webhook public; install-url, status, verify, uninstall behind passport.
  • GraphQL client: gql() retries once after a 401-triggered refresh; unwrap() treats top-level errors as failures and maps extensions.code === 'THROTTLED' to RateLimited with retry_after_ms from 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 pass first: — an unbounded connection costs 100 nodes.
  • Webhooks: HMAC X-Jobber-Hmac-SHA256 over the raw body; Jobber wants an answer within 1s, so APP_DISCONNECT is one indexed update and work objects go to crmSync.jobberWebhook. Payloads are ids-only, so the helper re-queries the work object → client.id → matches the session/profile via crm_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 getContactStage shape with pipeline_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_create shipped true (the v1 plan said false); pipelines and 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.