Skip to main content

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.

EventWhen
new_sessionSession created
new_user_identifiedAnonymous session became identified
new_leadLead captured
lead_score_updateScore changed
chat_transcriptChat ended
new_bookingBooking via any source
call_completedPhone or web AI call finished (channel: 'phone' | 'web')
booking_rescheduledBooking moved (Calendly: invitee.created carrying old_invitee)
booking_cancelledBooking 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:

  1. Payload JSON-serialized (max body size 1 MB).
  2. HMAC-SHA256 signature computed over the string "{timestamp}.{payload}" using the subscription's secret.
  3. POST to the subscription's url with headers:
    • X-Webhook-Event: <event_name>
    • X-Webhook-Signature: <hex hmac>
    • X-Webhook-Timestamp: <unix seconds>
    • Content-Type: application/json
  4. Request times out after 10 seconds.
  5. 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:

FieldNotes
call_sentimentElevenLabs analysis sentiment, default neutral
transcript_textFormatted Speaker: text lines joined with newlines
recording_urlSigned public link (see below), or the record's literal URL, '' when unavailable
  • Signed recording linksRecordingLinkService.buildUrl(): HMAC-SHA256 over <conversationId>.<exp> with RECORDING_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 route GET /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 from api.nextlevelai.site (base rewritten from api.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 on inbound_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 to inbound_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):

ActionRoute
List subscriptionsGET /webhooks
Create subscriptionPOST /webhooks
Get one + 20 recent logsGET /webhooks/:id
Update (url/events/status)PUT /webhooks/:id
Delete (and clean up logs)DELETE /webhooks/:id
Rotate signing secretPOST /webhooks/:id/regenerate-secret
Send a test eventPOST /webhooks/:id/test
Paginated delivery logsGET /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 N on retried log rows; the admin Webhook Events screen is gone.
  • Timeout: the recipient must respond 2xx within 10 seconds, or the delivery is recorded as a failure.
  • Ordering isn't strict — high-frequency events may arrive out of order; use the timestamp in 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 in app/configs/webhookEvents.js; queue in app/services/WebhookQueueService.js.
  • Controllers/routes: app/controllers/WebhookController.js, app/routes/webhookRoutes.js, webhookEventRoutes.js (read-only). Signing in app/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-only RecordingLinkService copy 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 own X-Dualhook-Signature HMAC-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 / WebhookEventModel queries).