Skip to content

Common Webhook — Legacy comparison

The system that CRM migrations are migrating from. Read architecture.md first if you haven't — this page assumes the four-layer mental model from there and contrasts it with the legacy shape.

How to read this

HCP is used as the canonical legacy example throughout — because we're mid-migration, both legacy and new paths exist concurrently for HCP, making the side-by-side cleanest. The same shape applies to all 9 un-migrated CRMs (slightly different leaves, identical structure). Migration order: HCP → likely fieldRoutes → backlog. See the inventory table at the bottom.

Unsolicited Opinion

The migration delivers two things that are easy to confuse. (1) Same leaves, new orchestration: hcpJobBooker, hcpCustomerResolver, hcpJobCanceller, etc., are unchanged — both webhooks call them. (2) New observability + safety properties: per-stage error isolation, durable retries, idempotent prepare, vertical-aware routing, structured logs. The first is "we moved the code"; the second is "we got actual capabilities." A defensible migration argues from (2) — the leaves are stable, the orchestration upgrade is real.


Why "non-st-crm"? The historical framing

The URL prefix /api/responder/non-st-crm/<crm>/webhook and the directory name lib/non-st-workflow/<crm>/ aren't accidental. They encode a historical framing that still drives the codebase:

ServiceTitan is bread-and-butter. Everything else is "non-ST-CRM."

ServiceTitan was the first CRM Avoca integrated against, and the product line was built assuming ST shapes by default. When other CRMs (HouseCall Pro, Field Routes, IAA, Job Nimbus, etc.) got added, they were each treated as a deviation from ST — given their own webhook route, their own per-team config table, their own monolithic workflow. The "non-ST-CRM" label is the literal directory layout.

The common webhook migration is, structurally, the first attempt to treat all CRMs as peers. ServiceTitan still has the privilege of being the implicit-default in HomeServicesWorkflowRunTriager (no explicit branch needed — it falls through to the generic stages), but the migration shape is "ST is one CRM among many" rather than "ST is the system and others are accommodated."

Unsolicited Opinion

The framing matters for migration arguments. A reviewer reading a CRM migration PR may instinctively defend ST-shaped assumptions (calendar model, customer resolution, job-type vocabulary) as "the right way." The migration's success criterion isn't "make the new CRM behave like ST" but rather "make the new CRM and ST both produce the same canonical post-call state." The bread-and-butter framing is what they're moving away from, not toward.


The legacy shape, end to end

Every un-migrated CRM has the same overall layout, mirrored under three directories:

pages/api/responder/non-st-crm/<crm>/webhook.ts    ← HTTP entry, per-CRM route
lib/non-st-workflow/<crm>/
  ├── run/handler.ts                                ← post-call entry function
  ├── run/workflow.ts                               ← monolithic workflow function
  ├── utils/*.ts                                    ← leaf functions (jobBooker, customerResolver, etc.)
  ├── types/*.ts                                    ← per-CRM types
  └── tools/*.ts                                    ← live-agent tool handlers (in some CRMs)

The HTTP route dispatches by message.type and hands end-of-call-report to handler.ts::{crm}BookingHandler. That function calls workflow.ts::{crm}Workflow (the monolith), then runs postCallProcess + notifier + oncallV2 sequentially. The whole chain is synchronous from Vapi's POST until the HTTP response.


Walking the legacy HCP path

Step 1: The route handler

pages/api/responder/non-st-crm/hcp/webhook.ts is shallow:

typescript
if (message.type === 'assistant-request') {
  return getVoiceAgent(team, 'hcp');           // CRM key hardcoded
}
if (message.type === 'end-of-call-report') {
  result = await hcpBookingHandler(message, teamId, logContext);
} else if (message.type === 'transfer-destination-request') {
  result = await handleTransferCall(message, teamId, logContext);
}

Three message types handled. status-update and hang are not handled — the common webhook adds both.

The getVoiceAgent(team, 'hcp') call wires the CRM key 'hcp' as a literal string. The common webhook resolves the CRM from typedConfig.crm instead — so the migration has to confirm nothing else reads the hardcoded literal.

Step 2: The handler

lib/non-st-workflow/hcp/run/handler.ts::hcpBookingHandler is ~175 lines:

  1. getServerMessageAssistantId(message) — pulls the Vapi assistant id
  2. getTeamIdFromVapiMessage(message) — resolves teamId from message if not passed
  3. prepareCallData(...) — inserts/updates the calls row
  4. buildHCPConfig(teamId, assistantId) — builds HouseCallProConfig
  5. hcpWorkflow(...) — the monolith (next step)
  6. extractActionItems(transcript, teamId, ...) — extracts action items from transcript
  7. postCallProcess(...) — updates call record. No try/catch wrapping this call. If it throws, control exits handler without running notifier.
  8. notifier(...) — sends email. Only runs if callDetails && bookingsResult both present.
  9. oncallV2AdapterForNonST(...) — on-call v2 dispatch. Only runs if oncall_enabled && oncall_calendar_v2_enabled. Wrapped in try/catch.
  10. Returns {callId, conversationId, workflowResult} directly to the HTTP response

The whole chain is in one async function. No queue. No retry. A timeout means Vapi sees the failure and retries the entire webhook.

Step 3: The workflow monolith

lib/non-st-workflow/hcp/run/workflow.ts::hcpWorkflow is ~440 lines. Inside that one function, it triages based on flags extracted from the LLM:

typescript
const callDetails = await getHCPCallDetails(teamId, transcript, context, conversationId);
if (!callDetails) return { success: false, ... };

// Deterministic slot resolution (mirrors what booking-info.ts does for ST)
// ... slot resolution block ...

// Triage by extracted flags
if (callDetails.is_cancellation) {
  // ... ~75 lines of cancellation handling ...
}
if (callDetails.is_rescheduling) {
  // ... ~115 lines of rescheduling handling ...
}
if (callDetails.service_yes_or_no !== 'Yes') {
  // ... ~35 lines of non-service path ...
}
// Service-required + booking path (~125 lines)

The triage flags come from the LLM during getHCPCallDetails. So outcome classification happens inside the leaf, not upstream. Each CRM's monolith makes its own LLM call and reads the resulting flags.

Step 4: The leaf functions

lib/non-st-workflow/hcp/utils/ has 16 files. The monolith calls these by hand based on which triage branch it's in:

Branch in hcpWorkflowCalls
CancellationhcpJobCanceller, createHCPCustomerFromCallDetails (fallback)
ReschedulinghcpJobRescheduler, applyTechReassignmentOnReschedule, createHCPCustomerFromCallDetails (fallback)
Non-servicecreateHCPCustomerFromCallDetails (if create_customer_if_not_found)
Service-requiredhcpCustomerResolver, hcpJobBooker or hcpEstimateBooker

These leaf functions are the only code the migration preserves byte-for-byte. The new booking-hcp/ package imports and calls these same functions. The migration moves orchestration, not action logic.


Side-by-side: same call, new path

For HCP specifically (because both paths exist), the difference for a real call:

AspectLegacy webhookCommon webhook
RoutePOST /api/responder/non-st-crm/hcp/webhookPOST /api/responder/common/workflow
HTTP behaviorSynchronous, blocks on all post-call work until responseReturns fast; post-call work happens in Inngest workers
Outcome classificationLLM extraction inside hcpWorkflow, read via callDetails.is_cancellation etc.LLM classification once via determineWorkflowOutcomes, persisted as outcome_results rows; triager dispatches by row type
Post-call error isolationSequential, no try/catch around postCallProcessPer-stage try/catch: postCallProcess, notifier, oncallV2, applyTechReassignmentOnReschedule each independent
RetriesNone. Vapi retries entire webhook on 5xx.Inngest step retries per phase. Failed runOutcome leaves row pending for manual retrigger.
IdempotencyEach Vapi retry re-runs the full handlerprepare() returns existing outcome_results rows on retry
Cross-tenant safetyNone. Trusts URL teamId.verifyVapiTeam cross-checks against Vapi assistant owner
assistant-request CRM resolutionHardcoded literal 'hcp' in getVoiceAgent(team, 'hcp')Resolved from typedConfig.crm via handleAssistantRequest
status-update / hang handlingNot handledHandled
Loggingconsole.log + ad-hoc JSON.stringifyLogger + formatLogData → structured Datadog
Leaf functionslib/non-st-workflow/hcp/utils/* (unchanged)Same functions, called from booking-hcp/run-*.ts adapters

Most of these are invisible to operators if the migration is done right. The differences manifest under failure: a notifier outage doesn't block on-call dispatch; a slow CRM API doesn't time out the Vapi webhook; a duplicate dispatch doesn't double-book.


Why the common webhook exists

Synthesized capability deltas (no formal ADR found in the production repo, so inferred from observable structure):

CapabilityLegacy delivers?Common delivers?
Outcome classification once, not per-leaf❌ Each CRM monolith re-classifiesdetermineWorkflowOutcomes → rows → leaves
Durable retry per phase❌ Vapi retries the whole webhook✅ Inngest step.run per phase
Per-stage error isolation❌ Sequential, throw past✅ Per-stage try/catch
Idempotent prepare❌ Every retry re-classifies✅ Existing outcome_results returned
Vertical-aware action stages❌ Per-CRM duplication of booking/reschedule/cancel✅ Generic stages shared across CRMs in HOME_SERVICES
Cross-tenant teamId verification❌ Trusts URLverifyVapiTeam
Structured observabilityconsole.logLogger + Datadog query primitives
status-update / hang handling
AutomatedTaskFactory triggers✅ Fires on RESPONDER_POST_CALL after triager

Unsolicited Opinion

The "outcome classification once" win is the deepest. Legacy: every CRM's monolith makes its own LLM call to figure out if a call was a cancel/reschedule/booking. New: the LLM classifies once at the vertical level, the row's type drives leaf selection. This is what makes per-outcome retry sane — you can't retry "the cancel branch" of a monolith function, but you can retry "the row whose type is cancellation."


Migration inventory

State of every CRM in the production repo as of 2026-05-12:

CRMLegacy webhookLegacy run/handler.tsCommon-webhook packageMigration state
acculynx❌ (but inline branches in booking/booking-triage.ts)Half-migrated
clypboardLegacy
fieldRoutesLegacy (likely next target — "Field Service")
hcp (HouseCall Pro)🚧 PR #10249 in flightIn progress
iaaLegacy
jobNimbus❌ (no handler.ts)Legacy, possibly live-agent-only
jobberLegacy
oasisbooking-oasis/Dual-state
pestPacLegacy
servpro / servpro-playwrightLegacy
workizLegacy
albiware(legacy lives in booking-triage.ts)n/abooking-albiware/Migrated
autoopsn/a (always common)n/abooking-autoops/Greenfield
dynamics365n/an/abooking-dynamics365/Greenfield
salesforcen/an/abooking-salesforce/ (Junk Removal) + booking-salesforce-omnia/ (Roofing)Greenfield
service_titanimplicit via pages/api/responder/<team>/... per-team routesn/aimplicit via HomeServicesWorkflowRunTriager default → generic stagesMigrated (the implicit default)
four_seasonsimplicitn/aimplicit via HomeServicesWorkflowRunTriager per-case branchMigrated (special case)

Migration backlog (legacy → common): 9 CRMs (acculynx half-counts, hcp in flight, jobNimbus needs investigation).


Special states explained

ACCULYNX — half-migrated

  • Legacy webhook still exists at pages/api/responder/non-st-crm/acculynx/webhook.ts
  • Legacy lib/non-st-workflow/acculynx/run/handler.ts exists
  • AND booking/booking-triage.ts (the generic ServiceTitan-shaped booking pipeline) has multiple typedConfig.crm === 'ACCULYNX' branches at lines 213, 225, 246
  • No booking-acculynx/ package

So ACCULYNX is dispatched through the generic pipeline (via HomeServicesWorkflowRunTriager's implicit-default → runBookingWorkflowbooking-triage.ts) WITH inline branches — pattern 4 from architecture.md. This is a third state beyond fully-legacy and fully-migrated, and an obstacle for a "full" ACCULYNX migration — the existing branches probably already cover the booking path, so the migration would be more about removing the legacy webhook than adding a new package.

OASIS — dual-state

  • Legacy webhook exists at pages/api/responder/non-st-crm/oasis/webhook.ts
  • But no lib/non-st-workflow/oasis/run/handler.ts
  • And booking-oasis/ package exists, used by FlooringWorkflowRunTriager

Likely: assistant-request (live agent) still routes via the legacy webhook for some teams, while end-of-call-report goes through the common webhook. Worth confirming with the Avoca team.

jobNimbus — possibly live-agent-only

  • Legacy webhook exists at pages/api/responder/non-st-crm/jobNimbus/webhook.ts
  • lib/non-st-workflow/jobNimbus/ exists but has no run/handler.ts
  • No common-webhook package

Live-agent-only? Sunset? Worth a one-line check before touching it as a migration target.


Cross-references