Documentation source
Acuity Scheduling
How DOC'S integrates with Acuity Scheduling — inbound webhooks, the visit linker, nightly backfill, and the source-of-truth split between commerce and clinical.
## Overview
DOC'S uses [Acuity Scheduling](https://acuityscheduling.com/) for all commerce: appointment booking, customer profiles, packages, memberships, and payment. Acuity is already trusted by the operator and patients — amble does not rebuild any of it.
The integration adds the **clinical layer** that Acuity does not have: structured visit records, per-patient protocols, modality dosing, feeling-trend tracking, and protocol-optimization analytics. The contract is:
- **Acuity is the source of truth** for: appointment, payment, pack balance, membership status, customer contact info.
- **amble (DOC360) is the source of truth** for: protocol, modality dose/duration, visit-level clinical capture, feeling trend, flags, protocol-optimization analytics.
The inbound channel is two components:
1. **Inbound webhook** — Acuity POSTs a skinny payload on booking/cancellation/change. The webhook handler creates a stub `visit` record immediately.
2. **Linker action** — fires on `entity_created` for visits; expands the stub via the Acuity REST API and resolves which patient the visit belongs to.
A nightly cron backfill handles any delivery gaps.
---
## Webhook Flow
```mermaid
sequenceDiagram
participant A as Acuity Scheduling
participant W as /api/webhooks/inbound/[token]
participant DB as Supabase (entities)
participant INN as Inngest
participant LINK as docs-acuity-link-visit-to-patient
participant ACUITY_API as Acuity REST API
A->>W: POST application/x-www-form-urlencoded<br>{action, id, calendarID, appointmentTypeID}<br>x-acuity-signature: base64-HMAC-SHA256
W->>W: Verify HMAC (base64 encoding)
W->>DB: INSERT visit entity (stub)<br>acuity_id, acuity_calendar,<br>acuity_appointment_type<br>tags: ["needs-expansion"]
W->>INN: send entity/created
W-->>A: 200 {success: true, entityId}
INN->>LINK: dispatch (trigger: entity_created, entity_type: visit)
LINK->>ACUITY_API: GET /appointments/{acuity_id}?pastFormAnswers=true
LINK->>DB: UPDATE visit entity<br>scheduled_for, duration, client fields,<br>intake_form_responses, sync_status="webhook"
LINK->>DB: LINK or CREATE patient record
LINK->>DB: REMOVE "needs-expansion" tag
```
The stub-then-expand pattern keeps webhook response times under 100ms (no external API call in the hot path) while the linker runs asynchronously with retries.
Source files:
- Webhook route: `app/api/webhooks/inbound/[token]/route.ts`
- Route handler and entity creation: `features/webhooks/server/inbound.ts`
- Endpoint configuration: `scripts/seed-docs-acuity-webhook-endpoint.ts`
- Linker action: `scripts/seed-docs-acuity-actions.ts` (`ACUITY_LINKER_ACTION`)
---
## Payload Mapping
Acuity sends `application/x-www-form-urlencoded` with four fields. The route handler parses them into a flat object and applies the `field_mapping` from the `inbound_webhook_endpoints` row.
| Acuity webhook field | visit entity field | Notes |
| -------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | `acuity_id` | Upsert key. Used for deduplication. |
| `calendarID` | `acuity_calendar` | Which DOC'S modality calendar in Acuity owns the booking. |
| `appointmentTypeID` | `acuity_appointment_type` | Appointment type ID from Acuity. |
| `action` | _(not mapped)_ | `scheduled`, `rescheduled`, `canceled`, `changed`. Not stored on the stub — the linker expands the current state from the REST API regardless of action. |
The entity `title` is not in the Acuity payload. The stub receives the fallback title `"Webhook Item"`. The linker rewrites it to `"Acuity {acuity_id} — {scheduled_for_date} — {appointment_type}"` after expansion.
Source: `ACUITY_FIELD_MAPPING` in `scripts/seed-docs-acuity-webhook-endpoint.ts:54`.
### Fields added by the linker (after expansion)
After the linker calls `GET /appointments/{acuity_id}?pastFormAnswers=true`:
| visit field | Source |
| ----------------------- | ------------------------------------------------------- |
| `scheduled_for` | Acuity `datetime` (ISO conversion) |
| `duration_minutes` | Acuity `duration` |
| `client_email` | Acuity `email` — lower-cased before storing |
| `client_first_name` | Acuity `firstName` |
| `client_last_name` | Acuity `lastName` |
| `client_phone` | Acuity `phone` |
| `intake_form_responses` | Acuity `forms[]` array (verbatim, full answers) |
| `sync_status` | Set to `"webhook"` |
| `patient_id` | Resolved by linker patient-resolution logic (see below) |
Schema source: `VISIT_ACUITY_FIELDS` in `scripts/seed-docs-acuity-entity-types.ts:106`.
Provider payload provenance is not stored on the visit entity. The deterministic
sync stores Acuity source objects, hashes, source URLs, run ids, mapper versions,
and entity-field provenance in `integration_objects` and
`integration_entity_links`.
---
## Patient Resolution
The linker resolves `patient_id` using a deterministic-first, agent-fallback chain:
1. **Exact email match** — search patients where `LOWER(acuity_client_email) = LOWER(visit.client_email)`.
2. **Household-payer match** — search patients where `LOWER(household_payer_email) = LOWER(visit.client_email)`. If exactly one match, link.
3. **Phone + name match** — if `visit.client_phone` is set, match patients by `contact_phone` with a last-name fuzzy check (Levenshtein ≤ 2).
4. **Agent fallback — intake form answers** — only if all three deterministic paths failed. The agent examines `intake_form_responses` for "Who is this appointment for?" or "Patient name" answers to handle household-payer bookings.
5. **Auto-create patient (low confidence)** — if still unmatched, creates a new patient with `content.needs_review = true`. The "Patients needing review" view surfaces these for operator verification. Tag is cleared once the operator confirms the identity from the patient detail page.
Race conditions (two concurrent linker invocations) are handled by catching `23505` unique-constraint errors on `acuity_client_email` and re-fetching the conflicting row rather than aborting.
Source: linker `instructions` in `scripts/seed-docs-acuity-actions.ts:64–144`.
---
## Source-of-Truth Conflict Resolution
When an operator edits a visit field in amble after an Acuity sync, the field is owned by amble from that point forward:
| Field category | Owner | Behavior on next Acuity sync |
| ------------------------------------------------------------------------------------------------------------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `scheduled_for`, `duration_minutes`, `client_email`, `client_first_name`, `client_last_name`, `client_phone` | Acuity | Linker overwrites from the REST API on expansion. |
| `patient_id`, `protocol_lane`, `modality_id`, `response_score`, `adverse_events` | amble | Never touched by the linker or backfill. |
| `sync_status` | Integration infrastructure | Set by webhook/linker/backfill paths; never user-editable. |
| Provider payloads, hashes, source URLs, run ids, mapper versions | Integration substrate | Stored in `integration_objects` and `integration_entity_links`, not in entity content. |
| `intake_form_responses` | Acuity (at booking time) | Overwritten on first expansion. Operator edits after that point are not protected — **do not use this field for operator-authored notes**. |
> **TBD**: Whether a re-expansion event (e.g., Acuity sends a `rescheduled` webhook for an already-expanded visit) should overwrite Acuity-owned fields or skip expansion if the visit is already in `status: in_progress` or `completed`. This policy is not yet coded. See `scripts/seed-docs-acuity-actions.ts` linker instructions step 0–0a for the pre-filter that skips already-expanded visits entirely — this means re-expansion currently does NOT happen. Contact Tyler or Nolan to confirm the desired behavior for rescheduled appointments.
---
## Nightly Backfill
The `docs-acuity-nightly-backfill` cron action runs at `03:00 America/New_York`
and dispatches the deterministic `acuity.nightlyBackfill` action definition. The
action reads `last_acuity_sync_at`, runs the registered Acuity integration
definition through `runRawIntegrationSync()`, and updates the checkpoint only
after the raw-object sync succeeds.
- **Appointments** → upsert `visit` records by `acuity_id`. New visits fire `entity_created` → linker runs automatically.
- **Orders** → upsert `payment` records by `acuity_order_id`. Resolves `patient_id` by email match only (no auto-create for payments).
The checkpoint timestamp is written as the **run start time**, not the end time. This ensures records modified during a multi-page sync are picked up on the next run. See the `CHECKPOINT` section in `scripts/seed-docs-acuity-actions.ts:162`.
Source: `ACUITY_BACKFILL_ACTION` in `scripts/seed-docs-acuity-actions.ts:155`.
---
## Integration layer (MCP bridge + deterministic sync)
Acuity is the first concrete integration on the generic `features/integrations/` seam. The seam is built on the platform's external-identity substrate — the `entities.external_id` / `external_source` columns and `upsertEntityKeyed()` — so adding the next system of record (JaneApp, Mindbody, an EHR) is "add an `EntityTranslation[]` + a client," not a rewrite.
### Agent reads — MCP bridge
The `acuity-scheduling-mcp` connection preset (`connection_type: "mcp"`, `config.bridge: "acuity"`) exposes Acuity to agents through the standard `listMcpTools` / `callMcpTool` gateway. Exactly two read-only tools:
- **`acuity_api`** — one tool, an `operation` enum (`me`, `list_appointments`, `get_appointment`, `list_clients`, `list_orders`, `list_calendars`, `list_appointment_types`, `list_forms`, `raw_get`, …). Defaults to `operation: "me"`, which is also the connection's safe-probe. `raw_get` is restricted to a read-only path allowlist.
- **`acuity_sync_snapshot`** — a bounded, read-only window (`startDate`/`endDate`) returning calendars, appointment types, appointments (optionally with intake answers), clients, and orders. Proves read access without mutating anything.
The existing `acuity-scheduling` REST/API preset is unchanged; both carry the same Basic-auth credential.
### Deterministic sync — `POST /api/admin/integrations/acuity/sync`
Replaces the prompt-only backfill with a deterministic executor
(`syncAcuity` / registered `acuity` definition → `runRawIntegrationSync`).
`requireAdmin()`-gated; `tenantId` from context, never request JSON.
- `mode: "dry_run"` (default) — records an integration run and returns proposed
inserts/updates/conflicts without writing raw objects, entity links, or
entities.
- `mode: "apply"` — stores redacted Acuity objects in `integration_objects`,
then idempotently upserts entities via `upsertEntityKeyed`, keyed on
`(external_source: "acuity:<resource>", external_id)`, and writes
`integration_entity_links`. The source is namespaced per resource
(`acuity:appointment` / `acuity:client` / `acuity:order`) because the identity
index `idx_entities_external_identity` is `(tenant_id, external_source,
external_id)` with **no** entity-type component — without the namespace, an
appointment and a client sharing a numeric id would collide and silently
overwrite each other across record types.
```bash
curl -X POST "$APP/api/admin/integrations/acuity/sync" \
-H "Content-Type: application/json" \
-d '{"mode":"dry_run","startDate":"2026-06-03","endDate":"2026-06-10"}'
```
### Webhook ↔ sync convergence
The webhook stub path and the deterministic-sync path share one idempotency key so they can never create duplicate records. The inbound webhook endpoint declares two reserved keys in its `field_mapping` — `__external_source` and `__external_id_field` (Acuity: `"acuity:appointment"` / `"acuity_id"` — namespaced per resource, since the webhook only creates `visit` entities from appointments). `processInboundWebhook` stamps `external_id` / `external_source` on the stub from those keys, and on a duplicate (`23505`) delivery it refetches the existing entity (by external identity, then by the declared content-path key) and returns its id — so Acuity stops retrying instead of seeing a 500. The legacy content-path unique indexes remain as defense-in-depth.
---
## Schema Extensions
The integration adds or extends three entity types:
| Entity type | Change | Key fields added |
| ----------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `visit` | Extended with Acuity link fields | `acuity_id`, `acuity_calendar`, `acuity_appointment_type`, `client_email`, `client_first_name`, `client_last_name`, `client_phone`, `intake_form_responses`, `sync_status` |
| `patient` | Extended with Acuity link fields | `acuity_client_email`, `household_payer_email`, `needs_review` |
| `payment` | New entity type | `acuity_order_id`, `amount_cents`, `currency`, `paid_at`, `payment_method`, `payer_email`, `payer_name`, `patient_id`, `covers_visit_ids`, `sync_status` |
Source: `scripts/seed-docs-acuity-entity-types.ts`.
---
## Failure Modes
### Webhook delivery failure
The route at `app/api/webhooks/inbound/[token]/route.ts` returns an HTTP error code when:
| Condition | HTTP status | `code` |
| ------------------------------------- | ----------- | ------------------- |
| Token not found or endpoint disabled | 404 | `not_found` |
| HMAC signature mismatch | 401 | `signature_invalid` |
| No entity type configured on endpoint | 422 | `misconfigured` |
| Entity insert failed | 500 | `internal` |
Acuity retries failed webhooks on its own schedule. The stub entity is only created after HMAC verification passes, so failed-signature deliveries do not create orphaned records.
### Linker / backfill failure
Both actions are managed by Inngest. The `action-dispatch` function (`features/inngest/functions/action-dispatch.ts`) has `retries: 2`. If all retries exhaust, Inngest moves the event to its dead-letter queue.
To find failed events: **Inngest dashboard** → Functions → `action-dispatch` or `docs-acuity-nightly-backfill` → Failed runs. The session record in amble is also updated with the error via `session_events` (append-only log).
The backfill guards its checkpoint: if the sync aborts (HTTP 5xx, rate-limit exhaustion), `last_acuity_sync_at` is NOT updated. The next nightly run replays from the same timestamp.
### Missing Acuity connection
If the `docs-acuity-scheduling` `agent_connection` row is absent or returns 401, the linker logs the error with the message `"DOC'S Acuity connection not configured"` (operator-actionable) and leaves the visit as a stub for the next backfill attempt. The nightly backfill similarly errors clearly and does not update the checkpoint.
---
## Operator Views
The Acuity wave seeds one dedicated view:
- **Patients needing review** (slug: `docs-acuity-patients-needs-review`) — filters patients where `content.needs_review = true`. Surfaces in the Operations workspace. Operator clears the flag after verifying identity from the patient detail page.
Source: `scripts/seed-docs-acuity-views.ts`.
---
## Local Testing
Simulate an Acuity webhook delivery using `curl`. First ensure the webhook endpoint is seeded and the dev server is running.
### 1. Seed the webhook endpoint (one-time)
```bash
env $(grep -E '^(NEXT_PUBLIC_SUPABASE_URL|SUPABASE_SECRET_KEY|SUPABASE_SERVICE_ROLE_KEY)=' .env.local | xargs) \
npx tsx scripts/seed-docs-acuity-webhook-endpoint.ts
```
The script prints the token (first 8 chars). Find the full token via Admin > Integrations > Webhooks in the running app, or query:
```sql
SELECT token, signature_secret
FROM inbound_webhook_endpoints
WHERE name = 'DOC''S Acuity Scheduling';
```
### 2. Send a test webhook
Acuity signs payloads as `base64(HMAC-SHA256(body, api_key))`. In local dev the `signature_secret` is a placeholder — bypass signature validation by setting `signature_secret = NULL` on the row, or use the placeholder value to compute a valid HMAC:
```bash
TOKEN="<your-token>"
SECRET="<signature_secret from DB>"
BODY="action=scheduled&id=99999&calendarID=12345&appointmentTypeID=678"
# Compute base64-HMAC-SHA256
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64)
curl -X POST "http://localhost:3000/api/webhooks/inbound/$TOKEN" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "x-acuity-signature: $SIG" \
-d "$BODY"
```
Expected response: `{"success":true,"entityId":"<uuid>"}`.
The stub visit entity appears immediately. The linker action fires asynchronously via Inngest — in local dev, Inngest must be running (`npx inngest-cli@latest dev` in a separate terminal) for the linker to execute.
### 3. Verify the stub
```sql
SELECT id, title, content->'acuity_id', tags, metadata
FROM entities
WHERE entity_type_slug = 'visit'
AND 'needs-expansion' = ANY(tags)
ORDER BY created_at DESC
LIMIT 5;
```
---
## Configuration
The endpoint row is seeded by `scripts/seed-docs-acuity-webhook-endpoint.ts`. After seeding, an operator must paste the real Acuity API key into Admin > Integrations > Webhooks > DOC'S Acuity Scheduling > Signature Secret.
**In Acuity Scheduling:** Integrations → Developer API → Notifications → Add subscription → URL: `https://app.sprinter.ai/api/webhooks/inbound/<token>`. Subscribe to: `appointment.scheduled`, `appointment.rescheduled`, `appointment.canceled`, `appointment.changed`.
The placeholder secret (`REPLACE-ME__...`) is recognizable in the Admin UI so the operator knows it needs replacing before the endpoint is live.
---
## Related Docs
- [DOC'S Workspace Catalog](/docs/features/docs-workspaces) — which workspace surfaces Acuity-synced visits and the "Patients needing review" view
- [DOC360](/docs/features/doc360) — clinical entity graph, modality structure, agent roster
- [Webhooks](/docs/features/webhooks) — inbound webhook platform mechanics
- [Nolan persona](/docs/personas/docs-operator-nolan) — operator JTBD and the commerce/clinical boundary contract