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 |
|---|---|---|
|
|
Campaign broadcast; |
|
|
The |
|
|
The Nooks |
|
|
Email campaign broadcast |
|
|
|
|
|
|
|
|
|
|
|
Sequence enrollment / start |
|
— (cancels |
Sequence stop |
|
|
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 |
|---|---|---|
|
|
Per campaign |
|
|
|
Inngest cron |
|
Daily at 00:00 UTC; zeroes |
Inngest cron |
|
Daily at 00:00 UTC; zeroes |
Inngest cron |
|
Weekly, Monday 11:15 UTC; releases unused phone numbers |
Inngest cron |
|
06:00 and 18:00 America/New_York |
Inngest cron |
|
Daily at 03:00 America/New_York; deletes |
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; ifcurrentRunsis already at or above it, no events are sent for that campaign this tick and the result reportsthrottled. Otherwise onlymaxInflight - currentRunsevents are enqueued, even if more targets were leased. -
Every successfully enqueued
call-target/call.requestedincrements the counter. -
callTargetdecrements it on every exit path through afinish()helper, so a skipped or failed dial does not leak capacity. -
callEndedAnalysisincrements 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 |
|---|---|---|
|
|
Call-ended handler, Nooks handler, Resend handlers, HubSpot webhook, sequence |
|
|
The Resend |
|
Email variation generation requests |
|
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 |
|