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.
WebhookEventis unique on(source, eventId), making duplicate deliveries a no-op. - Always acknowledge
-
The route returns
200even when the handler throws. A500would 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 |
|
|
Resend |
|
|
Nooks |
|
|
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, |
Verification skipped (development mode) |
HubSpot |
HMAC / legacy signature, |
Request rejected |
Nooks |
|
Verification skipped |
Telnyx |
Ed25519, |
Request rejected ( |
ElevenLabs |
HMAC-SHA256, |
Request rejected ( |
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 |
|
Nooks |
|
Campaign broadcast, |
|
Email campaign broadcast |
|
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.