Appearance
Common Webhook — Migration playbook
Step-by-step recipe for migrating a CRM from the legacy per-CRM webhook onto the common webhook, matching the shape of PR AvocaAI/avoca-next#10249 (HouseCall Pro). Read architecture.md and legacy-comparison.md first if you haven't — this page assumes both.
How to read this
This page documents the existing brittle shape, not a desired-state shape. The four-file copy-paste adapter pattern is repetitive, has known smells, and is the path the team agreed on. Follow it. The improvement arguments live in future-brief.md. Ship the migration first, advocate for changes second.
Unsolicited Opinion
The migration is 80% mechanical, 20% judgment. The mechanical part: rename <crm>Workflow's sections into three new files, add a dispatch block to the triager, flip two config columns. The judgment part: pick the right pattern (single-outcome vs multi-outcome vs per-case-inline), decide which outcome types the CRM supports, decide what happens on failure paths (fallback to contact creation or hard fail). The judgment lives in step 2.
When to use this playbook
Use this playbook when all of the following hold:
- The CRM has an existing legacy webhook at
pages/api/responder/non-st-crm/{crm}/webhook.ts - The CRM has legacy leaves at
lib/non-st-workflow/{crm}/utils/* - The CRM has a config builder (e.g.,
buildHCPConfig) and a call-details extractor (e.g.,getHCPCallDetails) - The CRM's per-team config table is populated for the teams you intend to migrate (job types, technician mappings, service hours, etc., captured at customer onboarding — see
crms/housecall-pro.md § HCP-specific API constraintsfor why this matters) - The CRM's action vocabulary fits HOME_SERVICES (booking / message / cancellation / rescheduling; possibly eta / job-notes / etc.)
- Avoca product team has signed off on migrating this CRM
If any of these are false, this playbook doesn't apply directly — see § When the four-file pattern doesn't fit at the bottom.
Config-table population is a separate workstream from the migration
For CRMs without runtime APIs to fetch job types or technicians (HCP, likely Field Routes, likely Job Nimbus), the migration doesn't populate the per-team config — that happens via customer onboarding (manual ops, admin portal, or one-off scripts). If the config is incomplete for a team, migrating that team will fail in ways that look like agent bugs but are actually config gaps. Validate config completeness before flipping a team's responder_webhook_configs.crm value.
The five steps
Step 1: Decide the vertical (5 min)
Step 2: Write the four-file booking-<crm>/ package (2-3 hours)
Step 3: Add the dispatch block to the vertical triager (15 min)
Step 4: Roll out per team (Vapi server URL + config) (per-team)
Step 5: Validate (per-team)
Off-ramp: How to back out (always available)Total: roughly half a day per CRM for the code, plus per-team rollout time.
Step 1: Decide the vertical
For 95% of cases, the answer is HOME_SERVICES (the default). Use the table below to confirm:
| Vertical | Action vocabulary | Existing CRMs |
|---|---|---|
HOME_SERVICES | booking / message / rescheduling / cancellation / eta / job-notes / confirmed-appointment / update-customer-info | ServiceTitan, ALBIWARE, FOUR_SEASONS, HOUSECALL_PRO |
AUTO_SERVICE | single workflow handling all booking-shaped intents (booking + reschedule + cancel inside one classifier) | AUTO_OPS |
WINDOW | single workflow | DYNAMICS_365 |
FLOORING | book / cancel / reschedule (oasis-flavored) | OASIS |
JUNK_REMOVAL | single workflow | SALESFORCE |
ROOFING | single workflow | SALESFORCE_OMNIA |
If your CRM's action vocabulary doesn't fit HOME_SERVICES, you're probably looking at a new vertical, which is out of scope for this playbook. Raise the question with the team.
Step 2: Write the four-file booking-<crm>/ package
Create apps/web/lib/workflow/stages/booking-<crm>/ with four files. Each file's structure is templated; what varies is called out per-file.
File 1: <crm>-context.ts
Owns: shared types, prepare{Crm}Context, run{Crm}PostCall, small helpers.
Templated structure (look at booking-hcp/hcp-context.ts for the canonical implementation):
typescript
// 1. Exported types
export interface <Crm>CallContext {
teamId: number;
callId: string;
callConversationId: string;
transcript: string;
report: PostCallReport;
assistantName?: string;
callType?: string;
isOutbound?: boolean;
voiceAssistantId?: string | null;
guidance?: string;
}
export interface <Crm>WorkflowResult {
callDetails: <Crm>BookingProcessPromptResultType | null;
bookingResult: <Crm>BookingResult;
notificationSent: boolean;
}
export interface <Crm>PreparedContext {
config: <Crm>Config;
callDetails: <Crm>BookingProcessPromptResultType;
<crm>: <Crm>Client;
phoneNumber: string;
}
// 2. prepare<Crm>Context — extract call details, build client
export async function prepare<Crm>Context(ctx: <Crm>CallContext, context: LogContext): Promise<<Crm>PreparedContext | null> {
const config = await build<Crm>Config(ctx.teamId, ctx.report.vapi?.assistantId);
const callDetails = await get<Crm>CallDetails(ctx.teamId, ctx.transcript, context, ctx.callConversationId);
if (!callDetails) return null;
// ... optional: deterministic slot resolution from fetched_availabilities ...
const client = new <Crm>Client(config.apiKey);
return { config, callDetails, <crm>: client, phoneNumber: ctx.report.caller?.number ?? '' };
}
// 3. run<Crm>PostCall — orchestrates postCallProcess + notifier + oncallV2 with per-stage try/catch
export async function run<Crm>PostCall(...): Promise<boolean> {
const actionItems = await extractActionItems(ctx.transcript, ctx.teamId, ...);
try { await postCallProcess({...}, context); } catch (e) { log.error(...); }
try { await notifier({...}, context); notificationSent = true; } catch (e) { log.error(...); }
if (config.typedConfig?.oncall_enabled && config.typedConfig?.oncall_calendar_v2_enabled) {
try { await oncallV2AdapterForNonST({...}); } catch (e) { log.error(...); }
}
return notificationSent;
}
// 4. helpers
export function failurePreparedContextResult(error: string): <Crm>WorkflowResult { ... }
export function buildFormattedCallFrom(phoneNumber: string): string { ... }What varies per CRM:
- CRM client class (
HouseCallPro,OasisClient, etc.) — instantiated fromconfig.apiKeyor whatever auth method the CRM uses - Config builder (
buildHCPConfig,buildOasisConfig, etc.) - Call-details extractor (
getHCPCallDetails,getOasisCallDetails, etc.) - Whether slot-id resolution from
fetched_availabilitiesis needed (HCP does this; check if your CRM uses booking windows) - Which post-call adapters fire — every CRM has
postCallProcess+notifier; only some haveoncallV2AdapterForNonST(currently HCP); some have CRM-specific post-call hooks (oasis has a Hubspot fallback in its prepare path)
File 2: run-booking-<crm>.ts
Owns: the booking workflow function, handles both service-required and non-service (info-only) paths.
Templated structure:
typescript
export async function run<Crm>BookingWorkflow(ctx: <Crm>CallContext, context: LogContext): Promise<<Crm>WorkflowResult> {
const log = new Logger({ name: 'run<Crm>BookingWorkflow', ...context, teamId, callId });
log.info(`<Crm> booking workflow started | DATA: ...`);
const prepared = await prepare<Crm>Context(ctx, context);
if (!prepared) return failurePreparedContextResult('No call details extracted');
const { config, callDetails, <crm>, phoneNumber } = prepared;
const callFrom = buildFormattedCallFrom(phoneNumber);
// ── Non-service / info-only path ─────────────────────────────────────
if (callDetails.service_yes_or_no !== 'Yes') {
log.info('Service not required, skipping booking', { ... });
if (config.typedConfig?.create_customer_if_not_found) {
try { await create<Crm>CustomerFromCallDetails({...}, context); }
catch (e) { log.error(e, { detail: '...' }); }
}
const bookingResult: <Crm>BookingResult = { success: true, bookingInformation: { bookingConfirmed: false, serviceRequired: false }, ... };
const notificationSent = await run<Crm>PostCall(ctx, config, callDetails, bookingResult, context);
return { callDetails, bookingResult, notificationSent };
}
// ── Service-required path ────────────────────────────────────────────
let bookingResult: <Crm>BookingResult;
try {
if (!phoneNumber) throw new Error('No phone number found');
const customerResolverResult = await <crm>CustomerResolver({...}, context);
callDetails.is_new_customer = customerResolverResult.isNewCustomer; // system override
// job-vs-estimate decision (if applicable)
const shouldBookEstimate = config.alwaysBookJob ? false : (config.alwaysBookEstimate || callDetails.is_estimate);
bookingResult = shouldBookEstimate
? await <crm>EstimateBooker({...}, context)
: await <crm>JobBooker({...}, context);
} catch (error) {
log.error(error, { detail: '...' });
bookingResult = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
const notificationSent = await run<Crm>PostCall(ctx, config, callDetails, bookingResult, context);
return { callDetails, bookingResult, notificationSent };
}What varies per CRM:
- Whether there's a job/estimate split. HCP has it; oasis doesn't.
- The
alwaysBookJob > alwaysBookEstimate > LLM is_estimatepriority chain — replicate this exactly if the CRM has it. - Customer-not-found fallback (some CRMs create contacts, some Hubspot leads, some hard fail).
- Leaf-function argument shapes (each
<crm>JobBookertakes a slightly different param object).
File 3: run-cancellation-<crm>.ts
Owns: the cancellation workflow function.
Templated structure:
typescript
export async function run<Crm>CancellationWorkflow(ctx: <Crm>CallContext, context: LogContext): Promise<<Crm>WorkflowResult> {
const prepared = await prepare<Crm>Context(ctx, context);
if (!prepared) return failurePreparedContextResult('No call details extracted');
const { config, callDetails, <crm>, phoneNumber } = prepared;
const callFrom = buildFormattedCallFrom(phoneNumber);
// ── Missing job_id path: fallback to contact creation ────────────────
if (!callDetails.cancel_job_id) {
log.warn('Cancellation outcome but no cancel_job_id extracted', { ... });
if (config.typedConfig?.create_customer_if_not_found) {
try { await create<Crm>CustomerFromCallDetails({...}, context); }
catch (e) { log.error(e, { detail: '...' }); }
}
const bookingResult: <Crm>BookingResult = { success: false, error: 'Cancellation detected but no job ID could be extracted' };
const notificationSent = await run<Crm>PostCall(ctx, config, callDetails, bookingResult, context);
return { callDetails, bookingResult, notificationSent };
}
// ── Cancel ───────────────────────────────────────────────────────────
let bookingResult: <Crm>BookingResult;
try {
const cancellationResult = await <crm>JobCanceller({...}, context);
const success = /* CRM-specific success criteria */;
bookingResult = {
success,
bookingInformation: { bookingConfirmed: false, serviceRequired: false, jobId: cancellationResult.jobId },
cancellationResult,
...(success ? {} : { error: 'Cancellation partially failed' }),
};
} catch (error) {
log.error(error, { detail: '...' });
bookingResult = { success: false, error: error instanceof Error ? error.message : '<crm>JobCanceller threw' };
}
const notificationSent = await run<Crm>PostCall(ctx, config, callDetails, bookingResult, context);
return { callDetails, bookingResult, notificationSent };
}What varies per CRM:
- Success criteria for the cancel. HCP:
alreadyTerminal || (unscheduled && unassigned). Oasis: lead-canceller'ssuccessflag directly. Look at the legacy<crm>Workflow's cancel branch for the canonical condition.
File 4: run-rescheduling-<crm>.ts
Owns: the rescheduling workflow function.
Templated structure:
typescript
export async function run<Crm>ReschedulingWorkflow(ctx: <Crm>CallContext, context: LogContext): Promise<<Crm>WorkflowResult> {
const prepared = await prepare<Crm>Context(ctx, context);
if (!prepared) return failurePreparedContextResult('No call details extracted');
const { config, callDetails, <crm>, phoneNumber } = prepared;
// ── Missing required fields path: fallback to contact ────────────────
if (!callDetails.reschedule_job_id || !callDetails.reschedule_new_time) { /* fallback */ }
// ── Parse time ───────────────────────────────────────────────────────
const timezone = config.timezone || 'America/New_York';
const jobDuration = config.jobDuration || 2;
const newStart = DateTime.fromFormat(callDetails.reschedule_new_time, 'MM/dd/yyyy hh:mm a', { zone: timezone });
if (!newStart.isValid) { /* return parse-failure */ }
const newEnd = newStart.plus({ hours: jobDuration });
// ── Reschedule + tech-reassignment ────────────────────────────────────
let bookingResult: <Crm>BookingResult;
try {
const rescheduleResult = await <crm>JobRescheduler({...newStart.toISO(), newEnd.toISO()...}, context);
if (rescheduleResult.success) {
try { await applyTechReassignmentOnReschedule({...}, context); }
catch (techError) { log.error(techError, { detail: 'tech-reassignment failed; reschedule remains committed' }); }
}
bookingResult = { success: rescheduleResult.success, ... };
} catch (error) {
bookingResult = { success: false, error: ... };
}
const notificationSent = await run<Crm>PostCall(ctx, config, callDetails, bookingResult, context);
return { callDetails, bookingResult, notificationSent };
}What varies per CRM:
- Time format and timezone source.
- Default duration (HCP: 2 hours; check your CRM's convention).
- Whether tech-reassignment exists (HCP has
applyTechReassignmentOnReschedule; others may not). - Whether reschedule success is binary or partial.
Step 3: Add the dispatch block to the vertical triager
Edit lib/workflow/stages/run/run-triage-home-service.ts (or the matching vertical file).
Placement: after any existing vertical-specific early-returns (e.g., after the ALBIWARE block), before the main outcome-type switch.
Imports (add to top of file):
typescript
import { run<Crm>BookingWorkflow } from '@/lib/workflow/stages/booking-<crm>/run-booking-<crm>';
import { run<Crm>CancellationWorkflow } from '@/lib/workflow/stages/booking-<crm>/run-cancellation-<crm>';
import { run<Crm>ReschedulingWorkflow } from '@/lib/workflow/stages/booking-<crm>/run-rescheduling-<crm>';
import type { <Crm>CallContext } from '@/lib/workflow/stages/booking-<crm>/<crm>-context';Dispatch block:
typescript
if (typedConfig.crm === '<CRM_KEY>') {
const errors: { error: unknown }[] = [];
const ctx: <Crm>CallContext = {
teamId,
callId: callId ?? '',
callConversationId: conversationId ?? '',
transcript: this.transcript,
report,
assistantName: report.assistantName ?? '',
callType,
isOutbound: report.isOutbound,
guidance: row.guidance,
};
try {
let result;
switch (row.type) {
case 'rescheduling':
result = await run<Crm>ReschedulingWorkflow(ctx, this.context);
break;
case 'cancellation':
result = await run<Crm>CancellationWorkflow(ctx, this.context);
break;
case 'eta':
case 'job-notes':
case 'confirmed-appointment':
case 'update-customer-info':
this.log.info(`<CRM> does not support outcome '${row.type}', skipping`, {
outcomeId: row.id,
outcomeType: row.type,
});
result = { skipped: true, reason: `<CRM> does not support outcome '${row.type}'` };
break;
default:
// 'booking' and 'message' both route through the booking workflow,
// matching the Flooring/Oasis precedent.
result = await run<Crm>BookingWorkflow(ctx, this.context);
}
return { results: [{ ...result, outcome: row }], errors };
} catch (error) {
this.log.error(
error instanceof Error ? error : new Error(String(error)),
{
detail: `Error running <CRM> ${row.type} workflow`,
outcomeId: row.id,
outcomeType: row.type,
errorMessage: error instanceof Error ? error.message : String(error),
errorStack: error instanceof Error ? error.stack : undefined,
}
);
errors.push({ error });
return { results: [], errors };
}
}Key contract to preserve: the return shape {results: [...], errors} matches the retry contract enforced by WorkflowRunTriager.runOutcome. On exception, return {results: [], errors} so the row stays pending for retry. See architecture.md for the full contract.
Unsupported outcome enumeration: list every outcome your CRM doesn't support in the no-op case branches. Anything you don't list falls through to the booking workflow default, which silently books.
Step 4: Rollout
Two flips, per team:
Flip 1: Vapi server URL
In Vapi's dashboard (or via API), change the team's voice assistant's server.url from:
https://app.avoca.ai/api/responder/non-st-crm/<crm>/webhook?teamId=<N>to:
https://app.avoca.ai/api/responder/common/workflow?teamId=<N>Vapi will start POSTing all events for that team's calls to the common webhook.
Flip 2: responder_webhook_configs.crm
Update the team's responder_webhook_configs row:
sql
UPDATE responder_webhook_configs
SET crm = '<CRM_KEY>',
vertical = 'HOME_SERVICES' -- or whichever vertical applies
WHERE team_id = <N>;Confirm vertical is set correctly. The factory in run-triage.ts defaults to HomeServicesWorkflowRunTriager if vertical is unset, but explicit is better.
Do not roll out before merging the code
The dispatch block (step 3) must be on main and deployed before you flip a team's server URL. Otherwise the common webhook receives events for a CRM it doesn't recognize and falls through to the generic stage, which will try to call ServiceTitan APIs.
Step 5: Validate
Per-team validation after the rollout flip. Hamming is the primary testing surface for voice flows (per project memory feedback_hamming_for_all_voice_testing).
Hamming scenarios
For HOME_SERVICES CRMs with the four-outcome set (booking, cancellation, rescheduling, message), run at minimum:
- Booking happy path — caller wants service, agent books, expect a successful job/estimate in the CRM
- Booking non-service path — caller calls for info, no service, expect contact created (if
create_customer_if_not_found) and no booking - Cancellation happy path — caller wants to cancel an existing job, agent has the job_id, expect cancellation in the CRM
- Cancellation missing job_id — caller says "cancel my appointment" but no job_id extractable, expect contact-fallback
- Rescheduling happy path — caller has job_id + new time, agent reschedules, expect job moved in the CRM + optional tech reassignment
- Rescheduling missing fields — same as cancellation missing-job-id
Manual smoke + Datadog
For each scenario:
- Confirm a
responder_workflow_runsrow landed and reachedCOMPLETEDstatus - Confirm
outcome_resultsrows have the expectedtypeandstatus: completed - Confirm
calls.is_bookablematches the expected outcome class - Confirm the CRM API state (job created / cancelled / rescheduled — query the CRM directly)
- Confirm Datadog logs show the expected stage transitions with no errors in the migrated path
Datadog query template
service:avoca-next-prod @teamId:<N> @callId:<vapi-call-id>For the same call's full chain (Vapi → common webhook → Inngest → triager → leaf):
service:avoca-next-prod @callId:<vapi-call-id>
| stats count by operationWhat "passing" looks like
The migrated team's calls should look identical or better to its pre-migration baseline:
- Same booking/cancel/reschedule success rates
- Same email notifications going out (notifier behavior unchanged)
- No new errors in Datadog (occasional log.error from post-call stages is expected; new patterns of failure are not)
- No HCP API state divergences (jobs created/cancelled/rescheduled correctly)
Off-ramp
Flip both back. The legacy path continues serving un-migrated teams because the route still exists.
sql
UPDATE responder_webhook_configs
SET crm = '<CRM_KEY>' -- already set, but ensure
WHERE team_id = <N>;In Vapi, flip server.url back to the legacy route.
Document the off-ramp reason in the team's notes. The legacy code stays in the repo as long as any team is on it — only delete the legacy webhook + handler when all teams for that CRM are migrated.
Failure modes / smells to watch for
Catalog from the PR #10249 review + research findings. Each is a thing the existing brittle shape allows but that should be caught in code review:
| Smell | What it looks like | Why it matters |
|---|---|---|
| Empty-string fallbacks | callId: callId ?? '' | Downstream falsey checks survive but it's not fail-fast |
| Default-case fall-through silently books | default: result = await run<Crm>BookingWorkflow(...) | Typo or new outcome type silently books — list every supported AND unsupported outcome explicitly |
| Silent error swallowing in post-call | try { await postCallProcess(...) } catch (e) { log.error(...) } with no surfaced signal | If notifier breaks for all migrated teams, only Datadog noise tells you |
| Slot-id resolution duplicated | prepare<Crm>Context re-implements logic from legacy <crm>Workflow verbatim | Drift risk when one path changes and the other doesn't — flag for reconciliation when legacy sunsets |
| Per-workflow client allocation | new <Crm>Client(config.apiKey) inside each workflow function | Minor cost, illustrative of copy-paste tax — see future-brief.md |
Hardcoded CRM key in legacy getVoiceAgent | getVoiceAgent(team, 'hcp') literal string | When sunsetting legacy webhook, confirm nothing else reads the literal |
Missing __tests__ | No unit/contract tests for the new package | PR #10249 added none. Hamming + manual smoke are the safety net. Worth proposing in code review. |
Defensible-PR checklist
The migration PR should pass these checks before opening:
- [ ] Existing CRM code paths (ServiceTitan, ALBIWARE, FOUR_SEASONS, etc.) are byte-identical (
git diff main...HEAD --statshows only adds/inserts) - [ ] Zero deletions in the diff (only additions)
- [ ] The dispatch block in the vertical triager has explicit cases for every unsupported outcome (no silent fall-through except for
booking/message) - [ ] All error paths in the new
booking-<crm>/files are logged withlog.error+ a descriptivedetail - [ ] The four-file package only imports legacy leaves from
lib/non-st-workflow/<crm>/and shared utilities — no new business logic - [ ] PR description explicitly notes: byte-identical existing paths, per-team rollout via config flip, off-ramp by flipping back
- [ ] PR description lists which outcomes the CRM supports and which it doesn't (the no-op set)
When the four-file pattern doesn't fit
Single-outcome CRM (no cancel, no reschedule)
Use the ALBIWARE shape instead: one file + outer early-return + override determineWorkflowOutcomes. See booking-albiware/.
CRM whose action vocabulary doesn't fit HOME_SERVICES
Likely needs its own vertical triager + own determineWorkflowOutcomes (like the AUTO_OPS / AUTO_SERVICE pairing). Discuss with the team — out of scope for this playbook.
CRM with no LLM extractor yet
Build the get<Crm>CallDetails-equivalent first as a separate PR. Then run this playbook.
Greenfield CRM (no legacy leaves)
Use the oasis shape instead: own the leaves under lib/{crm}/, write a full booking-{crm}/ package that includes its own post-call-process.ts and notifier.ts. See booking-oasis/.
CRM that's half-migrated already (like ACCULYNX)
Inline branches already exist in booking/booking-triage.ts. A "full migration" is less about adding a new package and more about either (a) removing the inline branches and writing a proper booking-acculynx/ package, or (b) deciding the inline-branches shape is acceptable and sunsetting only the legacy webhook. Discuss with the team.
Cross-references
- The system this migrates to:
architecture.md - The system this migrates from:
legacy-comparison.md - PR #10249 walked through end-to-end:
crms/housecall-pro.md - Improvement arguments to accumulate for "running up the flagpole":
future-brief.md