Adding a handler
Each dispatcher uses the same registry pattern, so adding an event type is a three-line change plus a handler class.
Retell
-
Create
lib/events/handlers/<event-name>.tsextendingRetellCallEventHandler:
import { RetellCallEventHandler } from "."
export class MyEventHandler extends RetellCallEventHandler {
async handle() {
const call = (this.context.payload?.call ?? {}) as Record<string, any>
const campaignId = call?.metadata?.campaignId
const targetId = call?.metadata?.campaignTargetId
if (!campaignId || !targetId) return // no campaign context — nothing to do
// …
}
}
-
Export it from
lib/events/handlers/index.ts. -
Register it in the
WebhookEventDispatcherconstructor:
this.register("my_event", MyEventHandler)
-
Add the event type in the Retell dashboard’s webhook configuration.
Resend
-
Create
lib/events/handlers/resend/email-<type>.tsextendingResendEventHandler, implementinghandle(repo: EmailCampaignRepository). -
Export it from
lib/events/handlers/resend/index.ts. -
Register it in the
ResendWebhookDispatcherconstructor:this.register("email.<type>", EmailTypeHandler). -
Subscribe to the event in the Resend dashboard.
Use this.bumpSendingDomainStat(repo, sendingDomainId, field) for counters rather than writing them
directly, so every handler increments the same way.
Nooks
-
Create
lib/events/handlers/nooks/<event-name>.tsextendingNooksEventHandler. -
Export it from
lib/events/handlers/nooks/index.ts. -
Register it in the
NooksWebhookDispatcherconstructor.
Telnyx
TelnyxWebhookDispatcher has no handlers yet. POST /api/webhooks/telnyx only stores the raw event.
When a stored payload shows an event type worth handling:
-
Create
lib/events/handlers/telnyx/<event-name>.tsextendingTelnyxEventHandler. -
Register it in the
TelnyxWebhookDispatcherconstructor:this.register("<event_type>", HandlerClass).
Rules for handler code
- Be idempotent
-
A provider may deliver the same event twice, and an operator may replay a stored
WebhookEvent. Guard with a unique constraint (providerCallId,(subscriptionId, externalId)) or a status check, not with a hope that it will not happen. - Validate before acting
-
Missing correlation metadata means the event is not yours — log and return rather than guessing.
- Do not throw for expected conditions
-
An unmatched target or an absent HubSpot id is a normal outcome. Log it and return; reserve throwing for genuine bugs.
- Delegate through a repository
-
Handlers may read a lookup row directly, but persistence belongs in
lib/repositories. The one sanctioned exception is theWebhookEventupsert in the route, which is part of accepting the request. - Emit, don’t block
-
Anything slow — AI analysis, retries, CRM sync — becomes an Inngest event. Keep
handle()to the state change that must be visible immediately. - Use conditional spreads on updates
-
…(call.recording_url ? { recordingUrl: call.recording_url } : {}). A webhook that omits a field should not overwrite a good value withnull.
Testing a handler
There is no test runner configured. In practice:
-
Find a representative payload in the
WebhookEventtable. -
curlit at the endpoint locally. -
Verify the resulting
Call,CampaignTargetorEmailCampaignTargetrows. -
Check the Inngest dashboard for the emitted event and its function run.
The decomposed call-ended/ handler shows the shape to aim for if you want a handler that can be
unit-tested: dependencies injected through dependencies.ts, database access behind an adapter
interface, and the state machine isolated in a pure StateDecisionEngine.