Skip to main content

Platform API

The Platform API lets an external platform (built for EzyMigrate) provision and operate Knock Knock sub-accounts under an agency: create companies, list AI agents, subscribe to outbound webhooks, and log users in. It lives in backend: app/controllers/PlatformApiController.js (~1200 lines), routes in app/routes/platformApiRoutes.js. In-repo design doc: backend/docs/platform-webhooks-plan.md (partly stale — describes a Mongo poller and per-company webhooks; the shipped code is BullMQ + agency-scoped webhooks).

Three trust zones

All three zones live in platformApiRoutes.js; the portal group uses an /agency/ prefix for webhook CRUD so it can't collide with the API-key routes.

1. Passport (portal) — super-admin / agency management:

RouteHandlerWho
PUT /platform-api/agencies/:id/accesssetAccess — flips agency.platform_api.enabled (does NOT mint a key)super-admin only
GET /platform-api/credentialsmasked key info (key_hint, timestamps, limits, sample payload)agency user + agency.manage_settings
POST /platform-api/rotate-keymints/rotates the API key — raw returned once, only the hash storedagency user
POST /platform-api/rotate-webhook-secretmints the HMAC signing secret (kk_whsec_…, encrypted at rest)agency user
GET /platform-api/webhook-config, GET /platform-api/webhook-deliveries, POST /platform-api/webhook-deliveries/:deliveryId/retrydelivery-log inspection + manual retryagency user
GET/POST /platform-api/agency/webhooks, PATCH/DELETE /platform-api/agency/webhooks/:webhookIdwebhook CRUD (portal variant)agency user

2. PublicPOST /platform-api/login (platformLogin): validates a code against company.platform_login.secret_key (32-byte hex, select: false, sparse-indexed) and returns { user, token } for the company owner — same shape as goHighLevelLogin. createCompany returns the matching login_url (<portal_url>/platform-login/<secret>).

3. API keymiddlewares/platformApiAuth.js sets request.agency:

RouteHandler
POST /platform-api/companiescreateCompany — email/name/credits/users, optional feature whitelist (unknown key = 400); capped by agency.initial_ai_credits + available_sub_accounts
GET /platform-api/companieslistCompanies — agency-scoped, paginated (max 100), field-whitelisted
GET /platform-api/companies/:companyId/agentsgetCompanyAgents — inbound/outbound agents + the POST /ai-outbound/webhook trigger contract (incl. state, max 8 KB, echoed back in call.completed)
GET/POST /platform-api/webhooks, PATCH/DELETE /platform-api/webhooks/:webhookIdwebhook CRUD (API-key variant; same handlers via #authAgencyId)

API-key auth (platformApiAuth)

x-api-key (fallback api-key) → sha-256 hash → lookup agency.platform_api.key_hash with enabled: true, deleted_at: null. Keys are kk_live_ + 32 random bytes hex (helpers/PlatformApiHelper.js); only the hash is stored (select: false), key_last_used_at stamped best-effort. The key can only act on its own agency — the tenant boundary is the agency row the hash resolves to.

Outbound webhooks

Two event types (PlatformWebhookModel.SUPPORTED_EVENTS): lead.captured and call.completed. Triggers are centralized:

  • lead.captured — fired in exactly one place, PersonResolverService.resolve(), whenever a validated email/phone lands (any source). CrmSyncHelper (and the chat/session controllers) carry explicit "do not fire here" notes — CRM sync deliberately does not emit platform webhooks. Idempotency suffix: user_session_id | sha1(name|email|phone)[:12].
  • call.completed — fired only by the cron PlatformCallCompletedWatcherJob (1 min): sweeps outbound inbound_outbound_calls ended 2 min–6 h ago without platform_delivery_processed_at, joins outbound_call_states by call_sid, enqueues, then stamps the guard field (not stamped if enqueue throws). Payload includes outcome classification (call_outcome / per-agent post_call_tags), transcript, recording URL, and the caller-supplied state.

Delivery pipeline

  • helpers/PlatformWebhookHelper.js — envelope {id, type, api_version: '2026-06-01', created_at, agency_id, company_id, data} serialized once; HMAC sha256(secret, "<ts>.<rawBody>") in X-KnockKnock-Signature: t=<ts>,v1=<hex> plus X-KnockKnock-Event / X-KnockKnock-Delivery (job id = idempotency key) / X-KnockKnock-Timestamp. SSRF-safe: URL validated at write time (https in prod, no localhost/private/.internal), DNS re-checked at delivery, and custom http/https agents pin every resolved address (defeats DNS rebinding). 10 s timeout, no redirects, 1 MB response cap.
  • services/PlatformWebhookQueueService.js — BullMQ queue platform-webhooks (global.platformWebhooks, visible in Bull Board at /admin/queues): 8 attempts, full-jitter exponential backoff (60 s base, 6 h cap), concurrency 5. 4xx (except 408/429) = permanent failure; a webhook auto-disables after 5 consecutive dead deliveries.
  • Models: PlatformWebhookModel (platform_webhooks — agency-scoped, events, endpoint auth encrypted select: false), PlatformWebhookDeliveryLogModel (platform_webhook_delivery_logs, 30-day TTL, no secrets stored), OutboundCallStateModel (outbound_call_states — caller state by call_sid, 7-day TTL, upserted by AiOutboundCallController.webhook).

Secrets at rest — SecretCrypto

helpers/SecretCrypto.js (AES-256-GCM, key from SECRET_ENCRYPTION_KEY, versioned blobs {v, iv, tag, ct}) encrypts secrets that must be replayed: the agency signing secret and webhook endpoint-auth credentials. It does not touch platform_login.secret_key / lead_api.secret (plaintext, select: false) or the API key (hashed, never decryptable). Don't reuse the legacy CryptHelper (non-AEAD, hardcoded key).

Lead API (company-scoped)

A separate, simpler key surface on userSessionRoutes.js behind middlewares/leadApiAuth.js — the company is resolved from the key (company.lead_api.secret, plaintext select: false); no company id is ever accepted from the caller.

RouteHandler
GET /api/v1/lead/visitorsgetLeads — paginated (max 500), since/type/sort, bots + playground excluded
GET /api/v1/visitors/countvisitorsCount — day boundaries in the company's timezone. Now key-required — breaking (was public with inline key lookup before commit 7b0bc12cf)
GET /api/v1/lead/:user_session_id/transcriptleadTranscript — merged AI-voice + chat + phone transcript; foreign id = 404, not-ready = 200 { ready: false }

Which API-key middleware is which

leadApiAuthplatformApiAuthapiKeyAuth
Headerx-api-key / api-keyx-api-key / api-keyx-api-key / api-key
Credentialcompany.lead_api.secret (plaintext, select: false)agency.platform_api.key_hash (sha-256) + enabledenv BACKEND_API_KEY (alias WEBHOOK_API_KEY)
Context setrequest.company (_id, timezone)request.agencynone
Tenant scopeone companyone agencyglobal (internal service-to-service)
Used byLead API routesPlatform API key zoneinternal routes (internalKnoxRoutes, migrationRoutes, callEventsRoutes, …)