Workflows

Durable work lives in lib/workflows/functions/ and is served together from app/api/inngest/route.ts. Each side effect is its own step.run, so it retries independently and is skipped on replay; step.sleep provides real delays without holding concurrency.

Registered functions

Function Trigger event Retries Concurrency Purpose

callTarget

call-target/call.requested

0

5

Window check → pre-validate → dial

callEndedAnalysis

call-target/call.ended

0

20

Double tap, contact update, AI analysis

callCycleBudgetCron

cron 0 8,20 * * * ET

2

1

Sum phone-call callCost for the Eastern billing cycle, the calendar month (1st 00:00 through the last day 23:59 ET). Pause that workspace’s running call campaigns when the total exceeds Subscription.callCycleBudgetUsd.

nooksCallAnalysis

nooks/call.logged

0

10

AI analysis of a human rep’s call

emailSend

email-campaign/send.requested

2

20

Send one campaign email

emailCampaignDuplicate

email-campaign/duplicate.requested

2

5

Chunked recipient copy

emailCampaignImportSegment

email-campaign/import-segment.requested

2

5

Chunked segment member copy

segmentRefresh

segment/refresh.requested

2

1 per strategyId

Fill staging from Outreach filters or BigQuery SQL, insert newcomers, soft-close this strategy’s dropouts when the scan is complete

segmentRefreshCron

cron 0 6,18 * * * ET

2

—

List active re-engagement segments and enqueue one refresh event per Outreach or BigQuery strategy

reengagementDailyList

segment/daily-list.requested

2

1 per strategyId

Pick today’s list from open members (score desc, volume cap, stale skip), persist HubSpot list ids, add assigned contacts to those lists

reengagementDailyListCron

cron 0 7 * * * ET; segment/daily-list-cron.requested

2

1 per subscriptionId or all

Find-or-create today’s HubSpot daily segments for active sales reps, attach tenant-scoped list ids to pick events, then enqueue one daily-list event per active Outreach or BigQuery re-engagement strategy

reengagementNeverCalledDailyList

cron 30 7 * * * ET; segment/never-called-daily-list.requested

2

1

One shared HubSpot list, Outreach-Never-Called-{YYYY-MM-DD}, of up to 1,000 people already on a re-engagement list who have never had a human-rep call. Longer waits are more likely to be picked.

sequenceExecute

sequence/execute

1

50

Run a contact’s whole sequence

hubspotContactImport

cron 0 6,10,14,18,22 * * * ET; contacts/hubspot-import.requested

2

1

Pull up to 1000 HubSpot contacts missing from the Outreach BQ mirror and upsert them into HUBSPOT_CONTACT_SYNC_SUBSCRIPTION_ID

hubspotCallImport

cron 0 8,20 * * * ET; calls/hubspot-import.requested

2

1

Pull up to 1000 HubSpot CALL engagements from v_calls_latest after Configs hubspot_call_sync_cursor and upsert them into the same workspace

payabilitySupplierImport

cron 0 6,10,14,18,22 * * * ET; suppliers/payability-import.requested

2

1

Incremental upsert from v_supplier_summary_extended after supplier_sync_cursor, then re-pull the oldest lastSyncedAt locals by key so status changes that do not bump BQ updated still write

webhookEventPurgeCron

cron 0 3 * * * ET

2

1

Delete WebhookEvent rows with receivedAt strictly older than seven days, in batches of 1,000, up to 50,000 rows per run. A remaining backlog continues the next day.

phoneNumberDailyResetCron

cron 0 0 * * * UTC

2

1

Zero PhoneNumbers.dailyCallCount on every live number

sendingDomainDailyResetCron

cron 0 0 * * * UTC

2

1

Zero EmailSendingDomain.dailySentCount and clear dailySentDate. A backstop: the daily cap already ignores counters from an earlier UTC day

phoneNumberOptimizerCron

cron 15 11 * * 1 UTC

2

1

Release numbers unused for 3+ days with fewer than 10 lifetime calls. find-unused lists candidates, then each release is its own release-<id> step, so a retry never repeats a finished release. A failed release is logged and counted, and does not stop the rest

purchasePhoneNumber

phone-number/purchase.requested

0

1

Buy and register a number (body currently disabled)

processRequest

—

—

—

Scaffolding / test function

testContextCollection

—

—

—

Scaffolding / test function

A function that is not in the functions array of app/api/inngest/route.ts will never run.

batchCallAnalysisWorkflow (lib/workflows/batch-call-analysis-workflow.ts) is not an Inngest function — it uses the 'use workflow' / 'use step' directive runtime. See Batch call analysis.

Client

lib/plugins/inngest-client.ts creates the client with id dialer-v2-app and installs @inngest/middleware-sentry, so a failing step is reported with its workflow context. Sentry is initialised in the same module.

Guard mechanisms

The functions use four distinct Inngest guards. Knowing which one applies is often the answer to "why did this not run twice".

Mechanism Where Effect

retries: 0

callTarget, callEndedAnalysis, nooksCallAnalysis, purchasePhoneNumber

A discrete business action must not be repeated automatically. Retrying a dial would call the prospect twice

rateLimit

callTarget (2 per targetPhoneNumber per 24 h), callEndedAnalysis (1 per callId per 24 h), nooksCallAnalysis (1 per callId per 24 h)

Excess events are dropped, not queued. This is the hard ceiling behind all the softer campaign limits

idempotency

purchasePhoneNumber — subscriptionId + state + areaCode

Only one purchase in flight per state and area code per workspace

cancelOn

sequenceExecute — sequence/cancel matched on data.contactSequenceId

Tears the run down immediately, including an in-progress step.sleep

Retry semantics

retries Meaning here

0

The action has an external side effect that must not repeat. Failures are returned as data (skipped, skipReason) rather than thrown, so an ineligible target does not look like an error

1

sequenceExecute — one retry is worth it because a whole journey is at stake, and each step is individually replay-safe

2

emailSend, emailCampaignDuplicate, segmentRefresh, segmentRefreshCron, hubspotContactImport, hubspotCallImport, payabilitySupplierImport, webhookEventPurgeCron, phoneNumberDailyResetCron, sendingDomainDailyResetCron, phoneNumberOptimizerCron — protected by their own idempotency guards, so a retry is safe

Diagram

flowchart TD
  Cron1["pg_cron"] --> B1["POST /api/campaigns/{id}/broadcast"]
  B1 -->|"call-target/call.requested"| CT["callTarget"]
  CT -->|Retell API| Retell["Retell AI"]
  Retell -->|"webhook call_analyzed"| WH["/api/webhooks/event-logger"]
  WH -->|"call-target/call.ended"| CEA["callEndedAnalysis"]
  CEA -->|"double tap: sleep 1m"| CT
  CEA --> Analysis["CallAnalysisService.runAndSave"]

  Cron2["pg_cron"] --> B2["POST /api/email-campaigns/{id}/broadcast"]
  B2 -->|"email-campaign/send.requested"| ES["emailSend"]
  ES -->|Resend API| Resend["Resend"]
  Resend -->|webhooks| WH2["/api/webhooks/resend"]

  NooksWH["/api/webhooks/nooks"] -->|"nooks/call.logged"| NCA["nooksCallAnalysis"]
  Enroll["POST /api/contact-sequences/{id}/start"] -->|"sequence/execute"| SE["sequenceExecute"]
  Stop["POST /api/contact-sequences/{id}/stop"] -->|"sequence/cancel"| SE
  Dup["POST /api/email-campaigns/{id}/duplicate"] -->|"email-campaign/duplicate.requested"| ECD["emailCampaignDuplicate"]
  Seg["POST .../targets/from-segment"] -->|"email-campaign/import-segment.requested"| ECS["emailCampaignImportSegment"]
  Refresh["POST /api/segments/{id}/refresh"] -->|"segment/refresh.requested"| SR["segmentRefresh"]
  CronSeg["segmentRefreshCron 06:00/18:00 ET"] -->|"segment/refresh.requested"| SR
  Daily["POST /api/segments/{id}/daily-list"] -->|"segment/daily-list.requested"| DL["reengagementDailyList"]
  DailyWs["POST /api/reengagement/daily-list"] -->|"segment/daily-list-cron.requested"| CronDaily
  CronDaily["reengagementDailyListCron"] -->|"segment/daily-list.requested"| DL
  CronNever["reengagementNeverCalledDailyList 07:30 ET"] --> HsNever["Outreach-Never-Called list"]
  CronHs["hubspotContactImport 06:00/10:00/14:00/18:00/22:00 ET"] --> BQ["hubspot4.v_contacts_latest"]
  CronHs --> Contacts["Contact + TargetPhoneNumbers + TargetEmails"]
  CronHsCall["hubspotCallImport 08:00/20:00 ET"] --> BQCalls["hubspot4.v_calls_latest"]
  CronHsCall --> Calls["Call"]
  CronSup["payabilitySupplierImport 06:00/10:00/14:00/18:00/22:00 ET"] --> BQSup["PayabilitySheets.v_supplier_summary_extended"]
  CronSup --> Suppliers["Supplier"]

Conventions

  • Name steps descriptively and stably — step ids are the replay key. sequenceExecute embeds the step index, retry count, channel and a slugified name so the dashboard is readable and ids survive replays.

  • Keep each step.run to one side effect. A step that does two things cannot retry half of them.

  • Import heavy dependencies inside the step (await import(…​)) where the function is otherwise cheap to load.

  • Return structured data ({ skipped, reason, skipReason }) rather than throwing for expected outcomes.

  • Decrement the Redis in-flight counter on every exit path, not just the happy one.