Skip to content

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 constraints for 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:

VerticalAction vocabularyExisting CRMs
HOME_SERVICESbooking / message / rescheduling / cancellation / eta / job-notes / confirmed-appointment / update-customer-infoServiceTitan, ALBIWARE, FOUR_SEASONS, HOUSECALL_PRO
AUTO_SERVICEsingle workflow handling all booking-shaped intents (booking + reschedule + cancel inside one classifier)AUTO_OPS
WINDOWsingle workflowDYNAMICS_365
FLOORINGbook / cancel / reschedule (oasis-flavored)OASIS
JUNK_REMOVALsingle workflowSALESFORCE
ROOFINGsingle workflowSALESFORCE_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 from config.apiKey or whatever auth method the CRM uses
  • Config builder (buildHCPConfig, buildOasisConfig, etc.)
  • Call-details extractor (getHCPCallDetails, getOasisCallDetails, etc.)
  • Whether slot-id resolution from fetched_availabilities is needed (HCP does this; check if your CRM uses booking windows)
  • Which post-call adapters fire — every CRM has postCallProcess + notifier; only some have oncallV2AdapterForNonST (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_estimate priority 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>JobBooker takes 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's success flag 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_runs row landed and reached COMPLETED status
  • Confirm outcome_results rows have the expected type and status: completed
  • Confirm calls.is_bookable matches 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 operation

What "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:

SmellWhat it looks likeWhy it matters
Empty-string fallbackscallId: callId ?? ''Downstream falsey checks survive but it's not fail-fast
Default-case fall-through silently booksdefault: result = await run<Crm>BookingWorkflow(...)Typo or new outcome type silently books — list every supported AND unsupported outcome explicitly
Silent error swallowing in post-calltry { await postCallProcess(...) } catch (e) { log.error(...) } with no surfaced signalIf notifier breaks for all migrated teams, only Datadog noise tells you
Slot-id resolution duplicatedprepare<Crm>Context re-implements logic from legacy <crm>Workflow verbatimDrift risk when one path changes and the other doesn't — flag for reconciliation when legacy sunsets
Per-workflow client allocationnew <Crm>Client(config.apiKey) inside each workflow functionMinor cost, illustrative of copy-paste tax — see future-brief.md
Hardcoded CRM key in legacy getVoiceAgentgetVoiceAgent(team, 'hcp') literal stringWhen sunsetting legacy webhook, confirm nothing else reads the literal
Missing __tests__No unit/contract tests for the new packagePR #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 --stat shows 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 with log.error + a descriptive detail
  • [ ] 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