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

  1. Create lib/events/handlers/<event-name>.ts extending RetellCallEventHandler:

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
    // …
  }
}
  1. Export it from lib/events/handlers/index.ts.

  2. Register it in the WebhookEventDispatcher constructor:

this.register("my_event", MyEventHandler)
  1. Add the event type in the Retell dashboard’s webhook configuration.

Resend

  1. Create lib/events/handlers/resend/email-<type>.ts extending ResendEventHandler, implementing handle(repo: EmailCampaignRepository).

  2. Export it from lib/events/handlers/resend/index.ts.

  3. Register it in the ResendWebhookDispatcher constructor: this.register("email.<type>", EmailTypeHandler).

  4. 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

  1. Create lib/events/handlers/nooks/<event-name>.ts extending NooksEventHandler.

  2. Export it from lib/events/handlers/nooks/index.ts.

  3. Register it in the NooksWebhookDispatcher constructor.

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:

  1. Create lib/events/handlers/telnyx/<event-name>.ts extending TelnyxEventHandler.

  2. Register it in the TelnyxWebhookDispatcher constructor: 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 the WebhookEvent upsert 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 with null.

Testing a handler

There is no test runner configured. In practice:

  1. Find a representative payload in the WebhookEvent table.

  2. curl it at the endpoint locally.

  3. Verify the resulting Call, CampaignTarget or EmailCampaignTarget rows.

  4. 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.