Appearance
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:
getServerMessageAssistantId(message)— pulls the Vapi assistant idgetTeamIdFromVapiMessage(message)— resolves teamId from message if not passedprepareCallData(...)— inserts/updates thecallsrowbuildHCPConfig(teamId, assistantId)— buildsHouseCallProConfighcpWorkflow(...)— the monolith (next step)extractActionItems(transcript, teamId, ...)— extracts action items from transcriptpostCallProcess(...)— updates call record. No try/catch wrapping this call. If it throws, control exits handler without running notifier.notifier(...)— sends email. Only runs ifcallDetails && bookingsResultboth present.oncallV2AdapterForNonST(...)— on-call v2 dispatch. Only runs ifoncall_enabled && oncall_calendar_v2_enabled. Wrapped in try/catch.- 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 hcpWorkflow | Calls |
|---|---|
| Cancellation | hcpJobCanceller, createHCPCustomerFromCallDetails (fallback) |
| Rescheduling | hcpJobRescheduler, applyTechReassignmentOnReschedule, createHCPCustomerFromCallDetails (fallback) |
| Non-service | createHCPCustomerFromCallDetails (if create_customer_if_not_found) |
| Service-required | hcpCustomerResolver, 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:
| Aspect | Legacy webhook | Common webhook |
|---|---|---|
| Route | POST /api/responder/non-st-crm/hcp/webhook | POST /api/responder/common/workflow |
| HTTP behavior | Synchronous, blocks on all post-call work until response | Returns fast; post-call work happens in Inngest workers |
| Outcome classification | LLM 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 isolation | Sequential, no try/catch around postCallProcess | Per-stage try/catch: postCallProcess, notifier, oncallV2, applyTechReassignmentOnReschedule each independent |
| Retries | None. Vapi retries entire webhook on 5xx. | Inngest step retries per phase. Failed runOutcome leaves row pending for manual retrigger. |
| Idempotency | Each Vapi retry re-runs the full handler | prepare() returns existing outcome_results rows on retry |
| Cross-tenant safety | None. Trusts URL teamId. | verifyVapiTeam cross-checks against Vapi assistant owner |
assistant-request CRM resolution | Hardcoded literal 'hcp' in getVoiceAgent(team, 'hcp') | Resolved from typedConfig.crm via handleAssistantRequest |
status-update / hang handling | Not handled | Handled |
| Logging | console.log + ad-hoc JSON.stringify | Logger + formatLogData → structured Datadog |
| Leaf functions | lib/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):
| Capability | Legacy delivers? | Common delivers? |
|---|---|---|
| Outcome classification once, not per-leaf | ❌ Each CRM monolith re-classifies | ✅ determineWorkflowOutcomes → 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 URL | ✅ verifyVapiTeam |
| Structured observability | ❌ console.log | ✅ Logger + 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:
| CRM | Legacy webhook | Legacy run/handler.ts | Common-webhook package | Migration state |
|---|---|---|---|---|
acculynx | ✅ | ✅ | ❌ (but inline branches in booking/booking-triage.ts) | Half-migrated |
clypboard | ✅ | ✅ | ❌ | Legacy |
fieldRoutes | ✅ | ✅ | ❌ | Legacy (likely next target — "Field Service") |
hcp (HouseCall Pro) | ✅ | ✅ | 🚧 PR #10249 in flight | In progress |
iaa | ✅ | ✅ | ❌ | Legacy |
jobNimbus | ✅ | ❌ (no handler.ts) | ❌ | Legacy, possibly live-agent-only |
jobber | ✅ | ✅ | ❌ | Legacy |
oasis | ✅ | ❌ | ✅ booking-oasis/ | Dual-state |
pestPac | ✅ | ✅ | ❌ | Legacy |
servpro / servpro-playwright | ✅ | ✅ | ❌ | Legacy |
workiz | ✅ | ✅ | ❌ | Legacy |
albiware | (legacy lives in booking-triage.ts) | n/a | ✅ booking-albiware/ | Migrated |
autoops | n/a (always common) | n/a | ✅ booking-autoops/ | Greenfield |
dynamics365 | n/a | n/a | ✅ booking-dynamics365/ | Greenfield |
salesforce | n/a | n/a | ✅ booking-salesforce/ (Junk Removal) + booking-salesforce-omnia/ (Roofing) | Greenfield |
service_titan | implicit via pages/api/responder/<team>/... per-team routes | n/a | implicit via HomeServicesWorkflowRunTriager default → generic stages | Migrated (the implicit default) |
four_seasons | implicit | n/a | implicit via HomeServicesWorkflowRunTriager per-case branch | Migrated (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.tsexists - AND
booking/booking-triage.ts(the generic ServiceTitan-shaped booking pipeline) has multipletypedConfig.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 → runBookingWorkflow → booking-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 byFlooringWorkflowRunTriager
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 norun/handler.ts- No common-webhook package
Live-agent-only? Sunset? Worth a one-line check before touching it as a migration target.
Cross-references
- What the migration moves to:
architecture.md - How to actually execute a migration:
migration-playbook.md - PR #10249 (HCP) walked through:
crms/housecall-pro.md - The agent architecture (separate concern from this webhook architecture):
agent-architecture-legacy-vs-current.md - The L0-L7 post-call dispatch chain:
post-call-dispatch-chain.md