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:
| Route | Handler | Who |
|---|---|---|
PUT /platform-api/agencies/:id/access | setAccess — flips agency.platform_api.enabled (does NOT mint a key) | super-admin only |
GET /platform-api/credentials | masked key info (key_hint, timestamps, limits, sample payload) | agency user + agency.manage_settings |
POST /platform-api/rotate-key | mints/rotates the API key — raw returned once, only the hash stored | agency user |
POST /platform-api/rotate-webhook-secret | mints 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/retry | delivery-log inspection + manual retry | agency user |
GET/POST /platform-api/agency/webhooks, PATCH/DELETE /platform-api/agency/webhooks/:webhookId | webhook CRUD (portal variant) | agency user |
2. Public — POST /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 key — middlewares/platformApiAuth.js sets request.agency:
| Route | Handler |
|---|---|
POST /platform-api/companies | createCompany — email/name/credits/users, optional feature whitelist (unknown key = 400); capped by agency.initial_ai_credits + available_sub_accounts |
GET /platform-api/companies | listCompanies — agency-scoped, paginated (max 100), field-whitelisted |
GET /platform-api/companies/:companyId/agents | getCompanyAgents — 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/:webhookId | webhook 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 cronPlatformCallCompletedWatcherJob(1 min): sweeps outboundinbound_outbound_callsended 2 min–6 h ago withoutplatform_delivery_processed_at, joinsoutbound_call_statesbycall_sid, enqueues, then stamps the guard field (not stamped if enqueue throws). Payload includes outcome classification (call_outcome/ per-agentpost_call_tags), transcript, recording URL, and the caller-suppliedstate.
Delivery pipeline
helpers/PlatformWebhookHelper.js— envelope{id, type, api_version: '2026-06-01', created_at, agency_id, company_id, data}serialized once; HMACsha256(secret, "<ts>.<rawBody>")inX-KnockKnock-Signature: t=<ts>,v1=<hex>plusX-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 queueplatform-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 encryptedselect: false),PlatformWebhookDeliveryLogModel(platform_webhook_delivery_logs, 30-day TTL, no secrets stored),OutboundCallStateModel(outbound_call_states— callerstatebycall_sid, 7-day TTL, upserted byAiOutboundCallController.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.
| Route | Handler |
|---|---|
GET /api/v1/lead/visitors | getLeads — paginated (max 500), since/type/sort, bots + playground excluded |
GET /api/v1/visitors/count | visitorsCount — 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/transcript | leadTranscript — merged AI-voice + chat + phone transcript; foreign id = 404, not-ready = 200 { ready: false } |
Which API-key middleware is which
leadApiAuth | platformApiAuth | apiKeyAuth | |
|---|---|---|---|
| Header | x-api-key / api-key | x-api-key / api-key | x-api-key / api-key |
| Credential | company.lead_api.secret (plaintext, select: false) | agency.platform_api.key_hash (sha-256) + enabled | env BACKEND_API_KEY (alias WEBHOOK_API_KEY) |
| Context set | request.company (_id, timezone) | request.agency | none |
| Tenant scope | one company | one agency | global (internal service-to-service) |
| Used by | Lead API routes | Platform API key zone | internal routes (internalKnoxRoutes, migrationRoutes, callEventsRoutes, …) |