This is the complete, standalone configuration reference for
attioom. Every scope, endpoint, and setting listed here
was verified against live API responses and the official docs — this is
not a guess at what scopes "should" work, it's what we actually confirmed.
Do these in order. Steps 1–2 (Zoom) and step 3 (Attio) can happen in parallel; step 4 (Supabase) can happen any time before you deploy.
In the Zoom App Marketplace → Build App → Server-to-Server OAuth. This grants account-wide access via one admin action — no per-user consent flow, no refresh tokens to manage.
After creating the app, note down:
- Account ID
- Client ID
- Client Secret
These become ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET.
Zoom's scope picker groups scopes by API; add the granular, :admin-suffixed
variant of each (not the basic non-admin version — non-admin scopes only let
the app act on the app-owner's own meetings, not on any user's):
| Scope | Why the bridge needs it |
|---|---|
meeting:read:list_meetings:admin |
Scheduler: list each recorded user's upcoming meetings |
meeting:read:meeting:admin |
Fetch a single meeting's settings (auto_recording status, meeting_invitees) |
meeting:update:meeting:admin |
Enable auto_recording: cloud on a meeting |
meeting:read:list_past_participants:admin |
Fetch actual attendance after a call ends — this is how the bridge decides whether a call had an external participant |
cloud_recording:read:list_recording_files:admin |
List a completed recording's files (MP4, VTT transcript) and get a download token |
cloud_recording:delete:meeting_recording:admin |
Delete the Zoom-hosted copy once it's safely in Attio |
There is no scope for listing a regular meeting's invitees after it's created.
GET /meetings/{id}/inviteesdoes not exist — Zoom returns error code 2300 ("This API endpoint is not recognized") for it, confirmed live and by Zoom's own staff on their developer forum. Don't add ameeting:read:invitee:admin-shaped scope hoping it'll unlock this; it won't, because the endpoint isn't there. The bridge uses actual post-meeting attendance (list_past_participants) as its source of truth instead — that's the whole reason that scope is in this list.
In the same app, under Feature → Event Subscriptions, add:
recording.completed— required. This is the sole trigger that starts ingestion for a call.recording.transcript_completed— recommended but optional. Zoom's transcript can finish processing well after the recording itself; this event lets the bridge pick up a late transcript promptly instead of waiting for its next scheduled retry pass. If you skip this, transcripts still arrive eventually — just possibly a bit later.endpoint.url_validation— Zoom sends this automatically the first time you save a webhook URL, and re-validates it roughly every 72 hours. The bridge's webhook handler answers it automatically; you don't configure anything for it beyond pointing the URL correctly (below).
Set the Event notification endpoint URL to:
https://<your-deployment-host>/api/webhooks/zoom
Zoom requires this endpoint to respond within 3 seconds. The bridge's handler does the minimum work needed (verify signature, dedupe, enqueue a row) and responds in well under 1 second — the actual download/upload work happens later, out-of-band, in a cron job.
Zoom will show you a Secret Token for this app — that's
ZOOM_WEBHOOK_SECRET_TOKEN, used to verify webhook signatures (HMAC-SHA256)
and to answer the URL-validation challenge.
For each user you want recorded:
- Cloud recording must be enabled (Zoom Admin → User Management → the user → Meeting settings, or account-wide under Account Settings).
- Audio transcript must be enabled if you want VTT transcripts pushed to Attio. Without it, Zoom still records the call, but there's nothing for the bridge to fetch — the call just ends up with no transcript in Attio.
- The user needs a Pro or higher Zoom license. Basic (free) accounts can't use cloud recording at all.
If you're auditing this before deploying it against a production Zoom account, here's the exact endpoint list, each one verified live:
| Endpoint | Purpose |
|---|---|
POST zoom.us/oauth/token (form-encoded body, Basic auth) |
Mint a Server-to-Server access token |
GET /users/{email}/meetings?type=scheduled |
Scheduler: list a user's upcoming meetings. Note: this response never includes host_email or settings — the bridge fills in the host from the user you queried, and fetches the single-meeting endpoint separately for real settings. |
GET /meetings/{id} |
Real auto_recording status, plus settings.meeting_invitees when Zoom's own scheduling flow was used (empty for calendar-app invites) |
PATCH /meetings/{id} with {settings:{auto_recording:"cloud"}} |
Enable cloud recording. Zoom allows at most 100 such updates per meeting per 24h — not a practical limit for normal use. |
GET /past_meetings/{uuid}/participants |
Actual attendance after the call — the authoritative source for "did this call have an external participant." Zoom withholds the email address of anyone outside the host's own Zoom account — this is deliberate on Zoom's part, not a bug. The bridge uses the internal_user boolean flag Zoom does provide, instead of comparing emails, for exactly this reason. |
GET /meetings/{uuid}/recordings?include_fields=download_access_token&ttl=86400 |
List a completed recording's files and get a fresh, scoped download token |
DELETE /meetings/{uuid}/recordings |
Delete the Zoom-hosted copy (moves to trash by default) once the recording is safely ingested into Attio |
GET {download_url} with Authorization: Bearer {download_token} |
Download the actual MP4 / VTT bytes |
One correctness detail worth knowing: a numeric meeting ID always resolves to the latest instance of a recurring meeting. If you pass a numeric ID to the recordings/delete endpoints for a recurring meeting, you can act on the wrong instance. The bridge always uses the instance UUID instead, which targets exactly the occurrence you mean.
In Attio: Settings → Developers → Access Tokens (or reach out to your Attio account team if you don't see this — see the alpha note below).
| Scope | Why |
|---|---|
meeting:read-write |
Create/find meetings that recordings attach to |
call_recording:read-write |
Create call recordings and check their processing status |
record_permission:read |
Required alongside the meeting scopes for Attio's permission model |
user_management:read |
List workspace members, to figure out who should be recorded |
As of this writing, Attio's meeting and call-recording write APIs
(POST /v2/meetings, POST /v2/meetings/{id}/call_recordings, and the
transcript-push endpoint) require per-workspace enablement — having the
right scopes on your token is not sufficient on its own. You'll get 403 or
404 responses even with correct scopes until Attio turns this on for you.
Contact Attio support and ask them to enable Call Intelligence / call recording API access for your workspace. This is the single most common reason a fresh setup doesn't work — if you're getting unexpected 403/404s from the meeting or call-recording endpoints, this is almost certainly why.
| Endpoint | Purpose |
|---|---|
POST /v2/meetings |
Find-or-create. Attio treats two calls with the same external_ref as the same meeting — this is the only identity mechanism available, because external_ref is write-only everywhere else in the API (it's never returned by any GET/list response, confirmed against Attio's OpenAPI spec and live). All seven fields in the request body are required, including description and external_ref. |
GET /v2/meetings (filterable by participants, ends_from, starts_before, sortable) |
Used to find an existing calendar-synced meeting (by host + approximate start time) so the recording attaches to real CRM context instead of a bare placeholder meeting |
POST /v2/meetings/{id}/call_recordings with {data:{video_url}} |
Create a call recording from a URL. Attio fetches the URL itself — it must be a public HTTPS URL to an .mp4 file, ≤500MB, and Attio does a HEAD request first (needs a valid Content-Length). Rate-limited to roughly 1 request/second. |
GET /v2/meetings/{id}/call_recordings/{id} |
Poll processing status (processing → completed/failed) |
POST /v2/meetings/{id}/call_recordings/{id}/transcript |
Push transcript segments. This endpoint is not in Attio's public docs or OpenAPI spec — we learned about it directly from Attio support. If you're implementing something similar independently, ask Attio support about it; don't assume it'll always exist at this exact path. Payload shape: {data:{transcript:[{speech, start_time, end_time, speaker:{name, email_address}}]}}; end_time must be strictly greater than start_time. Re-pushing a transcript that already exists returns a specific "already has a transcript" response, which the bridge treats as success (not an error). |
GET /v2/workspace_members |
Resolve which workspace members should have their calls recorded. A deactivated member shows access_level: "suspended" — the bridge's default user-resolution filters these out. |
Attio does not auto-transcribe videos uploaded via the API (confirmed with Attio support — their public docs are silent on this either way). If you want transcripts in Attio, you have to fetch them from Zoom's VTT file yourself and push them via the transcript endpoint above — Attio isn't doing that transcription for you.
This bridge uses Postgres (via Supabase, accessed over PostgREST) as its job queue, and Supabase Storage as a temporary staging area for video files before they're handed to Attio.
- Run
sql/schema.sqlin the Supabase SQL editor. It's idempotent — safe to re-run. - Raise your project's storage file-size limit to at least 500MB (Supabase → Settings → Storage → "Upload file size limit"). Supabase's default is much lower than a typical hour-long call recording; this is the single most common "why is every recording failing to stage" issue in practice. 500MB matches Attio's own cap on call-recording uploads, so there's no benefit to setting it higher for this use case.
- Note your Project URL and service role key — these become
SUPABASE_URLandSUPABASE_SERVICE_ROLE_KEY. The service role key is required because the bridge's tables have row-level security enabled with no policies for authenticated/anon roles — by design, only server-side code with the service role key can touch this data.
| Variable | Purpose |
|---|---|
ZOOM_ACCOUNT_ID |
From your Server-to-Server OAuth app |
ZOOM_CLIENT_ID |
From your Server-to-Server OAuth app |
ZOOM_CLIENT_SECRET |
From your Server-to-Server OAuth app |
ZOOM_WEBHOOK_SECRET_TOKEN |
From the same app's webhook/event-subscription config |
ATTIO_API_KEY |
Workspace access token — see §3 for scopes |
SUPABASE_URL |
Your Supabase project URL |
SUPABASE_SERVICE_ROLE_KEY |
Your Supabase service role key |
CRON_SECRET |
A secret you generate yourself; guards both cron endpoints against unauthenticated calls. Whatever scheduler you use must send Authorization: Bearer $CRON_SECRET. |
RECORDED_USER_EMAILS |
Comma/semicolon/newline-separated list of the email addresses whose meetings should be recorded. There is no built-in default roster — you must supply this list (directly via this var, or by wiring your own "who's an internal user" lookup in src/zoom/recording-users.ts). |
INTERNAL_EMAIL_DOMAINS |
Comma-separated list of your own company's email domains (e.g. yourcompany.com), leading @ optional. Required if ZOOM_RECORDING_REQUIRE_EXTERNAL is on (the default) — this is how the bridge tells "one of our people" apart from "an external participant" when Zoom's own internal_user flag isn't present for a given participant. There is deliberately no default value here; a generic package should never guess at your company's domain. |
| Variable | Default | Purpose |
|---|---|---|
ZOOM_RECORDING_REQUIRE_EXTERNAL |
true |
Skip recording calls with no external (non-internal-domain) participant. Set to false to record all internal meetings too. |
ZOOM_RECORDING_MIN_DURATION_SECONDS |
60 |
Skip recordings shorter than this — filters out accidental/empty room joins. A recording with a known duration below this threshold (including exactly 0) is skipped; a recording with an unknown duration is allowed through. |
ZOOM_RECORDING_SKIP_TOPIC_PATTERNS |
(none) | Pipe-separated, case-insensitive regular expressions. Any meeting whose topic matches one is never recorded. Example: standup|internal sync to skip your team's recurring internal meetings. There is no built-in pattern — you opt into whatever your team wants excluded. |
ZOOM_RECORDING_RECORD_ONE_ON_ONE |
true |
Set to false to skip 1:1 calls (2 participants, at most 1 of them external) |
ZOOM_RECORDING_USER_EMAILS_ONLY |
false |
If you wire in an external roster source (e.g. your own CRM's member list) in addition to RECORDED_USER_EMAILS, this forces the env-var list to be used exclusively |
ZOOM_ATTIO_STAGING_BUCKET |
zoom-recording-staging |
Supabase Storage bucket name used for temporary MP4 staging |
ZOOM_ATTIO_DELETE_ZOOM_AFTER_INGEST |
true |
Delete the Zoom-hosted recording once ingestion is complete. Deletion always waits until the transcript has reached a terminal state (successfully pushed, confirmed absent, or retries exhausted) — never deletes a source recording while a transcript might still be recoverable from it. |
ZOOM_MOCK_MODE |
false |
When true, every Zoom call returns in-memory fixture data instead of hitting the real API — useful for local development and testing without live credentials. This is explicit-only: if credentials are simply missing and mock mode isn't turned on, the bridge fails loudly rather than silently faking success. |
ZOOM_DEBUG |
false |
Logs every outbound Zoom API request (path + status) to the console |
- Zoom S2S OAuth app created, all six scopes from §1a added
- Webhook subscribed to
recording.completed(+ optionallyrecording.transcript_completed), pointed at your deployed/api/webhooks/zoom - Cloud recording + audio transcript enabled for each user you want recorded; each has a Pro+ license
- Attio workspace token created with all four scopes from §3a
- Confirmed with Attio support that call-recording APIs are enabled for your workspace (§3b) — do this early, it's not instant
-
sql/schema.sqlapplied to your Supabase project - Supabase storage file-size limit raised to ≥500MB
- All required env vars set (§5), including
RECORDED_USER_EMAILSandINTERNAL_EMAIL_DOMAINS— there are no defaults for either - Cron scheduler configured to call both cron endpoints with the
CRON_SECRETbearer token (seeexample/nextjsfor the Vercel reference; any scheduler works as long as it sends the header)