Webhooks
Audience: Engineering, support, management · Where in app: Settings → Webhooks · Plan availability: All plans (verify)
When Zapier isn't enough — because the customer wants to hit their own API, needs signed/verifiable payloads, or wants maximum throughput — native webhooks send events straight to any HTTP endpoint. Each delivery is HMAC-signed so the receiver can prove it came from Knock Knock, and every attempt is logged for auditing.
What it does
- Subscribes one or more endpoints to Knock Knock events.
- Delivers a JSON payload per event, signed with HMAC-SHA256.
- Logs every delivery attempt (status code, response body, latency) for 30 days.
- Lets admins create their own event types and send test events.
How it works
Auth model: customer-supplied URL + per-subscription signing secret. No OAuth — the customer registers an endpoint URL and Knock Knock generates a signing secret. Direction: outbound only.
Subscriptions (WebhookModel)
Each subscription row:
{
company_id, // owning tenant
url, // destination endpoint
description, // optional label
events: [...], // event names this endpoint receives
secret, // HMAC signing secret (32-byte hex; hidden by default)
status // "active" | "inactive"
}
Multiple subscriptions per company are allowed — different URLs subscribing to different events.
Event catalogue (app/configs/webhookEvents.js)
Event types are code, not admin records — WebhookEventModel, its admin CRUD, and the admin Webhook Events UI were deleted (cb83986ec, frontend e20cc659); GET /webhook-events is read-only. The same catalogue drives custom webhooks and Zapier.
| Event | When |
|---|---|
new_session | Session created |
new_user_identified | Anonymous session became identified |
new_lead | Lead captured |
lead_score_update | Score changed |
chat_transcript | Chat ended |
new_booking | Booking via any source |
call_completed | Phone or web AI call finished (channel: 'phone' | 'web') |
booking_rescheduled | Booking moved (Calendly: invitee.created carrying old_invitee) |
booking_cancelled | Booking cancelled |
WEBHOOK_EVENTS_PLAN.md (repo root) records all three phases live 2026-09-07; only the Zapier app promotion is blocked (a version-specific invite link is used instead), and free-plan Calendly accounts can't hold webhooks at all.
Delivery
Each event:
- Payload JSON-serialized (max body size 1 MB).
- HMAC-SHA256 signature computed over the string
"{timestamp}.{payload}"using the subscription'ssecret. - POST to the subscription's
urlwith headers:X-Webhook-Event: <event_name>X-Webhook-Signature: <hex hmac>X-Webhook-Timestamp: <unix seconds>Content-Type: application/json
- Request times out after 10 seconds.
- Logged in
WebhookLogModel(success/failure, response status, response body,duration_ms, error). Logs auto-expire after 30 days (TTL).
The delivered payload wraps the event data:
{
"event": "booking_created",
"data": { "...event-specific..." },
"webhook_id": "...",
"company_id": "...",
"timestamp": "1716200000"
}
Dispatch and retries. WebhookHelper.dispatch(company_id, event, data) is the single entry: it loads custom webhooks and company.zapier.webhooks filtered by event and enqueues one BullMQ job per target on queue tenant-webhooks (WebhookQueueService, job deliver, concurrency 5, worker in the API pods, needs Redis). 4 attempts, jittered exponential backoff from 60 s capped at 30 min; jobId ${event}|${sha1(payload).slice(0,12)}|${webhook_id}. A 4xx other than 408/429 is UnrecoverableError (no retry). After 5 consecutive failed jobs a custom webhook flips to status: 'inactive' with disabled_reason (new WebhookModel fields: consecutive_failures, last_delivered_at, disabled_reason); a Zapier hook answering 410 is $pulled. Every attempt logs to webhook_logs (Zapier under webhook_id = zapier:<hook _id>), bodies truncated. SSRF guard: net.BlockList + assertPublicUrl, maxRedirects: 0, 10 s timeout; Zapier URLs must be on hooks.zapier.com. Non-admins need canManageIntegrations; the log endpoint caps limit at LOG_PAGE_MAX. Payload shapes: custom = {event, data, webhook_id, company_id, timestamp} + HMAC; Zapier = bare data with a prepended id = the delivery id so Zapier dedups.
Call events are cron-driven (schedular): CallCompletedWebhookJob every 1 min (phone; 6 h lookback, batch 100, terminal + SETTLE_MS, stamps inbound_outbound_calls.tenant_delivery_processed_at — declared in backend and ms-comm models, lock-step — even with no subscriber) and WebCallCompletedWebhookJob every 2 min (service_rooms quiet 10 min, 24 h lookback, stamps webhook_delivery_processed_at, channel: 'web', recording URL through whiteLabelHost.forCompany). Shared builder app/lib/callCompletedPayload.js; the platform-API job reuses it.
The old note that delivery was a single attempt is obsolete since
cb83986ec(2026-09-07). The knowledge base describes exponential backoff (3 retries) and auto-deactivation after 3 consecutive failures — treat that as roadmap/aspirational until confirmed in code. For production reliability, recipients should be resilient and admins should monitor the delivery log.
Post-call payload & delivery (call_ended)
The post-call payload (PostCallWebhookService) now also carries:
| Field | Notes |
|---|---|
call_sentiment | ElevenLabs analysis sentiment, default neutral |
transcript_text | Formatted Speaker: text lines joined with newlines |
recording_url | Signed public link (see below), or the record's literal URL, '' when unavailable |
- Signed recording links —
RecordingLinkService.buildUrl(): HMAC-SHA256 over<conversationId>.<exp>withRECORDING_LINK_SECRET, TTL 365 days (links get stored on CRM contacts and clicked weeks later; revocable by rotating the secret). Verified constant-time by the public routeGET /public/recordings/:id?exp&token, which streams from ElevenLabs on demand — audio is never stored. Fail-closed: no secret / no base → no link, never an unsigned one. Links are served fromapi.nextlevelai.site(base rewritten fromapi.knock-knockapp.com). ms-communication has a sign-only copy (ms-communication/app/services/RecordingLinkService.js) — the signing input and secret must stay identical in both. - Delivery timing / exactly-once — ms-communication (
InboundOutboundCallController.saveAndEmail) deliberately holds the webhook until the ElevenLabs post-call analysis lands (it carries summary/recording/sentiment). If the analysis never arrives,PostCallWebhookFallbackJob(backend cron, every minute, 5-minute grace) fires with whatever exists. Both paths share an atomic claim oninbound_outbound_calls.post_call_webhook_sent_at, so a late analysis and the fallback can never both send. This is the one place backend writes toinbound_outbound_calls.
new_user_identified (revived)
Formerly fired only from the (since deleted) HappierLeads inbound handler; now emitted from session create (UserSessionController) — at most once per session, and only when the visitor arrived anonymous and got identified during that session (never on a pageview of an already-known person). Source precedence: KnockKnock's own resolved person (contact.source: "knockknock") outranks Apollo's guessed employee ("apollo"). Payload: user_session_id, contact {name, email, phone, source}, company, ip, initial_page_visit, identified_at.
Verification (recipient side)
The receiver recomputes the HMAC over "{timestamp}.{body}" with the shared secret and compares (constant-time):
import hmac, hashlib
ts = request.headers["X-Webhook-Timestamp"]
sig = request.headers["X-Webhook-Signature"]
mac = hmac.new(secret.encode(), f"{ts}.{request.body}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, mac):
return 403
Configuration & options
Routes (all under the authenticated admin app):
| Action | Route |
|---|---|
| List subscriptions | GET /webhooks |
| Create subscription | POST /webhooks |
| Get one + 20 recent logs | GET /webhooks/:id |
| Update (url/events/status) | PUT /webhooks/:id |
| Delete (and clean up logs) | DELETE /webhooks/:id |
| Rotate signing secret | POST /webhooks/:id/regenerate-secret |
| Send a test event | POST /webhooks/:id/test |
| Paginated delivery logs | GET /webhooks/:id/logs |
| Event catalogue (read-only) | GET /webhook-events, GET /webhook-events/:_id |
Behaviors & edge cases
- The tenant webhooks page shows an amber auto-disabled badge (reason on hover) and
attempt Non retried log rows; the admin Webhook Events screen is gone. - Timeout: the recipient must respond
2xxwithin 10 seconds, or the delivery is recorded as a failure. - Ordering isn't strict — high-frequency events may arrive out of order; use the
timestampin the payload. - No bulk historical replay — re-delivery is per-event (via the events screen) where supported; there's no backfill of past events.
Plan & limits
- Available on all plans (verify tier gating).
- 1 MB max payload; 10s delivery timeout; 30-day log retention.
Technical implementation
- Owning service:
backend. - Models:
app/models/WebhookModel.js(subscriptions),WebhookLogModel.js(delivery log, 30-day TTL); catalogue inapp/configs/webhookEvents.js; queue inapp/services/WebhookQueueService.js. - Controllers/routes:
app/controllers/WebhookController.js,app/routes/webhookRoutes.js,webhookEventRoutes.js(read-only). Signing inapp/helpers/WebhookHelper.js(crypto.createHmac('sha256', secret)over"{timestamp}.{payload}"). - Post-call path:
app/services/PostCallWebhookService.js,app/services/RecordingLinkService.js,app/jobs/PostCallWebhookFallbackJob.js(+ the sign-onlyRecordingLinkServicecopy in ms-communication). - See CRM & External Integration.
Note: the WhatsApp onboarding path (Dualhook) is a separate inbound webhook layer (
DualhookService.js/DualhookWebhookController.js) with its ownX-Dualhook-SignatureHMAC-SHA256 verification — not part of the customer-facing outbound webhooks described here.
What Nox can tell you
- Which webhooks are subscribed.
- Recent failures (count + URLs) and average latency per subscription.
- Whether a specific event got delivered (via
WebhookLogModel/WebhookEventModelqueries).