Skip to content

Latest commit

 

History

History
232 lines (181 loc) · 15.8 KB

File metadata and controls

232 lines (181 loc) · 15.8 KB

Setup guide: Zoom + Attio configuration

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.


1. Create a Zoom Server-to-Server OAuth app

In the Zoom App MarketplaceBuild AppServer-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.

1a. Add these exact scopes

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}/invitees does 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 a meeting: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.

1b. Subscribe to webhooks

In the same app, under FeatureEvent 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.

1c. Account-level settings

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.

2. What the bridge actually does with Zoom's API (so you know it isn't magic)

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.


3. Create an Attio workspace access token

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).

3a. Required scopes

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

3b. ⚠️ You need Attio to enable call-recording APIs for your workspace

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.

3c. What the bridge actually calls (verified)

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 (processingcompleted/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.


4. Supabase setup

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.

  1. Run sql/schema.sql in the Supabase SQL editor. It's idempotent — safe to re-run.
  2. 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.
  3. Note your Project URL and service role key — these become SUPABASE_URL and SUPABASE_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.

5. Full environment variable reference

Required

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.

Optional (sensible defaults shown)

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

6. Post-setup checklist

  • Zoom S2S OAuth app created, all six scopes from §1a added
  • Webhook subscribed to recording.completed (+ optionally recording.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.sql applied to your Supabase project
  • Supabase storage file-size limit raised to ≥500MB
  • All required env vars set (§5), including RECORDED_USER_EMAILS and INTERNAL_EMAIL_DOMAINS — there are no defaults for either
  • Cron scheduler configured to call both cron endpoints with the CRON_SECRET bearer token (see example/nextjs for the Vercel reference; any scheduler works as long as it sends the header)