Events

Every provider webhook follows the same shape: persist the raw payload, do the minimum synchronous work, emit an Inngest event for anything expensive, and acknowledge.

sequenceDiagram
  participant P as Provider
  participant R as Route handler
  participant DB as WebhookEvent
  participant D as Dispatcher registry
  participant H as Handler
  participant I as Inngest

  P->>R: POST /api/webhooks/…
  R->>R: read raw body, verify signature
  R->>DB: upsert on (source, eventId)
  R->>D: dispatch(eventType, context)
  D->>H: new Handler(context).handle()
  H->>DB: update domain state
  H->>I: emit follow-up event
  R-->>P: 200 { received: true }

Why this shape

Persist before dispatch

The raw payload is stored first, so a handler bug never loses an event. WebhookEvent is unique on (source, eventId), making duplicate deliveries a no-op.

Always acknowledge

The route returns 200 even when the handler throws. A 500 would make the provider retry work that may have partially succeeded; the stored payload can be replayed deliberately instead.

Emit, don’t block

The heavy work — AI analysis, retries, CRM sync — happens in Inngest functions triggered by events the handler emits. The webhook response stays fast, and the expensive part becomes replayable and independently retryable.

Dispatchers

Independent registries, one per provider, all following the same pattern: a Map from event type to handler constructor, populated in the dispatcher’s constructor.

Provider Dispatcher Endpoint

Retell AI

WebhookEventDispatcher (lib/events/event-handler.ts)

POST /api/webhooks/event-logger

Resend

ResendWebhookDispatcher (lib/events/resend-event-handler.ts)

POST /api/webhooks/resend

Nooks

NooksWebhookDispatcher (lib/events/nooks-event-handler.ts)

POST /api/webhooks/nooks

Telnyx

TelnyxWebhookDispatcher (lib/events/telnyx-event-handler.ts)

POST /api/webhooks/telnyx

An unregistered event type is silently ignored — the raw row is still stored.

HubSpot webhooks (POST /api/webhooks/hubspot) do not use a dispatcher; they are verified, normalised and republished to the CRM queue. Five9 payloads (POST /api/webhooks/five9) are stored only.

Event context

Each dispatcher passes its handlers a context object.

Retell (lib/events/types.ts):

type WebhookEventContext = {
  source: string          // "Retell"
  eventType: string       // "call_started" | "call_analyzed"
  eventId: string
  providerCallId: string
  payload: any            // raw webhook payload
}

Resend (lib/events/handlers/resend/base-handler.ts):

type ResendWebhookContext = {
  eventType: string        // e.g. "email.clicked"
  eventId: string          // svix-id — the idempotency key
  resendEmailId?: string   // data.email_id
  toEmail?: string         // data.to[0]
  payload: Record<string, unknown>
}

Telnyx (lib/events/handlers/telnyx/base-handler.ts):

type TelnyxWebhookContext = {
  eventType: string        // data.event_type
  eventId: string          // data.id — the idempotency key
  providerCallId: string | null  // conversation_id, else call_control_id
  payload: Record<string, unknown>
}

No Telnyx handlers are registered yet. See Telnyx AI assistant events.

Signature verification

Provider Mechanism Behaviour when unset

Resend

Svix, RESEND_WEBHOOK_SECRET

Verification skipped (development mode)

HubSpot

HMAC / legacy signature, HUBSPOT_WEBHOOK_CLIENT_SECRET

Request rejected

Nooks

Authorization: Bearer or X-Nooks-Signature, NOOKS_WEBHOOK_SECRET

Verification skipped

Telnyx

Ed25519, TELNYX_PUBLIC_KEY, headers telnyx-timestamp and telnyx-signature-ed25519

Request rejected (503)

ElevenLabs

HMAC-SHA256, ELEVENLABS_WEBHOOK_SECRET, header ElevenLabs-Signature

Request rejected (503)

Retell

none

—

Five9

none

—

All verifying routes read the body as raw text first, because signatures are computed over the exact bytes. See Secrets and webhook verification.

Emitted Inngest events

Emitted by Event

Retell call_analyzed handler

call-target/call.ended

Nooks call.logged handler

nooks/call.logged

Campaign broadcast, callEndedAnalysis double tap

call-target/call.requested

Email campaign broadcast

email-campaign/send.requested

See Workflows.

Realtime broadcast

RetellCallEventHandler.broadcastEvent(event, campaignId, payload) posts to Supabase Realtime’s broadcast API on the topic campaign-<campaignId>, letting the dashboard react live. It is skipped with a warning when the Supabase URL or key is missing, so a misconfigured environment degrades rather than failing the webhook.