Asynchronous runtime

Anything slow, retryable or scheduled leaves the HTTP request. Four mechanisms do this work.

1. Inngest durable functions

Functions live in lib/workflows/functions/ and are served together from app/api/inngest/route.ts:

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [
    testContextCollection,
    processRequest,
    purchasePhoneNumber,
    callTarget,
    callEndedAnalysis,
    nooksCallAnalysis,
    emailSend,
    emailCampaignDuplicate,
    emailCampaignImportSegment,
    segmentRefresh,
    sequenceExecute,
  ],
})

The client (lib/plugins/inngest-client.ts) has id dialer-v2-app and installs @inngest/middleware-sentry, so every step failure is reported with workflow context.

Each side effect is its own step.run, which means it retries independently and is skipped on replay. step.sleep is used for real delays (the double-tap minute, sequence waits, gate retry windows) and does not consume concurrency while sleeping.

Event names in use:

Event Function Emitted by

call-target/call.requested

callTarget

Campaign broadcast; callEndedAnalysis double tap

call-target/call.ended

callEndedAnalysis

The call-ended webhook handler

nooks/call.logged

nooksCallAnalysis

The Nooks call.logged handler

email-campaign/send.requested

emailSend

Email campaign broadcast

email-campaign/duplicate.requested

emailCampaignDuplicate

POST /api/email-campaigns/{id}/duplicate

email-campaign/import-segment.requested

emailCampaignImportSegment

POST /api/email-campaigns/{id}/targets/from-segment; optional segment_id on create

segment/refresh.requested

segmentRefresh

POST /api/segments/{id}/refresh or segmentRefreshCron (06:00 and 18:00 ET) — one event per active Outreach or BigQuery strategy. Steps: verify, create run, fill staging, insert (cap), reconcile removals when not truncated, finalize.

sequence/execute

sequenceExecute

Sequence enrollment / start

sequence/cancel

— (cancels sequenceExecute)

Sequence stop

phone-number/purchase.requested

purchasePhoneNumber

Phone-number selector, when no number is eligible

See Workflows for each function’s steps and guards.

2. Cron broadcasters

Campaign loops are pull-based. A scheduler calls a broadcast endpoint, which leases a batch of due work and enqueues one Inngest event per item.

Scheduler Target Cadence

pg_cron (Supabase)

POST /api/campaigns/{id}/broadcast

Per campaign

pg_cron (Supabase)

POST /api/email-campaigns/{id}/broadcast

Campaign.schedulerIntervalSeconds (default 30 s)

Inngest cron

phoneNumberDailyResetCron

Daily at 00:00 UTC; zeroes PhoneNumbers.dailyCallCount

Inngest cron

sendingDomainDailyResetCron

Daily at 00:00 UTC; zeroes EmailSendingDomain.dailySentCount

Inngest cron

phoneNumberOptimizerCron

Weekly, Monday 11:15 UTC; releases unused phone numbers

Inngest cron

segmentRefreshCron → segment/refresh.requested

06:00 and 18:00 America/New_York

Inngest cron

webhookEventPurgeCron

Daily at 03:00 America/New_York; deletes WebhookEvent rows older than seven days

Both broadcast endpoints are gated by verifyInternalSecret(). Inngest cron schedules are declared on each function in lib/workflows/functions/; the app has no Vercel crons. See Deployment and scheduled jobs.

3. The Redis in-flight counter

A campaign’s concurrency cannot be derived from the database cheaply, because a call is in-flight from the moment an Inngest event is sent until the provider webhook lands. A Redis counter bridges that window.

lib/redis/call-target-counter.ts exposes incrementCallTargetRuns(campaignId) and decrementCallTargetRuns(campaignId).

  • The broadcaster reads the counter first. The ceiling is maxConcurrentCalls × 2; if currentRuns is already at or above it, no events are sent for that campaign this tick and the result reports throttled. Otherwise only maxInflight - currentRuns events are enqueued, even if more targets were leased.

  • Every successfully enqueued call-target/call.requested increments the counter.

  • callTarget decrements it on every exit path through a finish() helper, so a skipped or failed dial does not leak capacity.

  • callEndedAnalysis increments it again before re-firing a double tap, because that re-fire creates a new in-flight call.

The × 2 headroom exists because one target can legitimately have more than one event in flight around a double tap, and because a leased target may still be waiting for its first Inngest step to start.

4. AWS SQS

Fire-and-forget messages to downstream Payability services. Publishers live in lib/plugins/sqs-client.ts.

Queue event_type values Producer

CRM_QUEUE_URL

Crm.DialerCallEnded, Crm.DealUpdate, Crm.CustomEvent, Crm.NewActivity, Crm.HubSpotWebhook, Crm.TaskCreate, Contact.Update, Contact.Unsubscribe

Call-ended handler, Nooks handler, Resend handlers, HubSpot webhook, sequence crm_task and crm_event steps, unsubscribe endpoint

INCOMING_EMAIL_SQS_QUEUE_URL

Email.IncomingCategorise

The Resend email.received handler

EMAIL_VARIATION_QUEUE_URL

Email variation generation requests

lib/services/email-variation-sqs.ts

CRM publishes are deliberately conditional: handlers only enqueue when a HubSpot contact id is available, so downstream consumers never receive an unresolvable payload. See CRM queue and HubSpot events.

Choosing a mechanism

Need Use

Must survive a crash and retry a specific step

Inngest function

Must run on a wall-clock schedule

Cron endpoint that enqueues Inngest events

Must respect a real-time concurrency ceiling

Redis counter checked by the broadcaster

Hand off to another service, no result needed

SQS publish

Cheap, best-effort side effect inside a request

await it in a try/catch that logs and continues