Skip to content

CRM Credentials

How Avoca wires per-team CRM credentials and how runtime call code resolves them. Same conceptual pattern across every CRM vertical; one notable inconsistency (AutoOps has no admin UI).

The pattern

Each CRM has its own credentials table (or pair of tables), all keyed on team_id. When a call comes in, the team_id flows from the phone number through the voice assistant to the runtime code; the runtime code reads from that CRM's credentials table to make API calls.

Resolution chain at call time:

Twilio dials phone number
  ↓ phone_numbers.team_id
team
  ↓ <crm>_credentials lookup (or <crm>_config / <crm>_api_keys, varies per CRM)
credential row
  ↓ api_key / tenant_id / client_id / client_secret (per-CRM shape)
CRM API call

The voice_assistant sits in the chain (phone → voice_assistant → team) but is NOT itself referenced in any credentials table. Multiple voice_assistants on the same team share the team's CRM credentials. One team cannot have multiple CRM client mappings on the same vertical.

Per-CRM table reference

CRMTable(s)Per-team or per-enterprise?Admin UI route
ServiceTitanservice_titan (per-team: tenant_id, client_id, client_secret, app_key)per-team/team/[teamId]/integrations/crm/service-titan/credentials
HouseCall Prohouse_call_pro_api_keysper-team/team/[teamId]/integrations/crm/hcp
Jobberjobber_api_keysper-team/team/[teamId]/integrations/crm/jobber/credentials
Acculynxacculynx_configper-team/team/[teamId]/integrations/crm/acculynx/credentials
AutoOpsautoops_credentials (per-enterprise: api_key) + autoops_team_configs (per-team: client_id, client_name, client_key, time_zone)per-enterprise for the credential, per-team for the mappingnone (see Tech Debt below)

AutoOps is structurally different in one way

AutoOps splits credentials and per-team mapping into TWO tables. Other CRMs collapse this into one table per team. The reason: AutoOps's API key is enterprise-scoped (one key serves all of EAS's 5+ shops), while the client_id differs per shop. Other CRMs typically issue one credential per shop.

Schema diff at a glance:

ServiceTitan / HCP / Jobber / Acculynx pattern (one table per team):
  <crm>_credentials (team_id UNIQUE, api_key + per-CRM fields)

AutoOps pattern (two tables, enterprise + team):
  autoops_credentials (enterprise_id UNIQUE, api_key)
  autoops_team_configs (team_id UNIQUE, credential_id, client_id, ...)

This shape is intentional. EAS's 5 shops all run under one cl_<...> enterprise in AutoOps's data model; the AutoOps API key authenticates calls to the enterprise tenant; the client_id parameter on each API call scopes the operation to a specific shop. Mirroring that structure means the Avoca-side data model has the same shape.

Encryption

All API keys are stored encrypted in the DB. The runtime helpers encryptApiKey() / decryptApiKey() from lib/api-keys/encryption.ts handle the round-trip via the WEBHOOK_API_KEY_ENCRYPTION_KEY env var. The admin UI for each CRM (where one exists) does the encryption transparently on save and decryption on display.

Tech debt: no admin UI for AutoOps

Every other CRM has a /team/[teamId]/integrations/crm/<vertical>/ settings page. AutoOps doesn't. Configuring AutoOps for a new team currently requires direct DB inserts (Kareem-driven for real EAS shops).

Filling this gap would be a /team/[teamId]/integrations/crm/autoops/ page mirroring the other CRM pages: a credentials page at the enterprise level for the api_key, plus a per-team page for the client_id / client_name / client_key / time_zone mapping.

Tracked in unknowns.md.

At call time

In-call tool handlers (e.g., autoOpsLookupCustomer, stCheckCustomer, etc.) receive the team_id via the Vapi tool-dispatch context. They call the CRM-specific lookup function (e.g., getAutoOpsConfig(teamId)) to get the credentials, then make the API call.

Post-call workflow stages (booking-autoops, cancellations-autoops, rescheduling-autoops, and the equivalent for other CRMs) receive the team_id from the end-of-call-report payload. Same lookup, same API call path.