Practical gotchas for Zoom and Attio, derived from this repo's code and docs/setup.md. Not a substitute for vendor docs.
Zoom places download_token at the top level of the webhook JSON body for recording.completed, not only inside payload. The parser in src/zoom/webhooks.ts checks both locations. The queue processor can also fetch a fresh token via GET /meetings/{uuid}/recordings?include_fields=download_access_token.
The reference webhook handler (example/nextjs/app/api/webhooks/zoom/route.ts) only verifies, dedup-claims, and enqueues. Heavy work runs in processZoomAttioBridgeQueue.
A numeric meeting ID resolves to the latest instance of a recurring meeting. The bridge always uses the instance UUID for recordings, delete, and download (encodeZoomMeetingUuid in src/zoom/client.ts).
GET /meetings/{id}/invitees does not exist (Zoom error 2300). Pre-meeting filtering uses settings.meeting_invitees from the single-meeting GET when present; ingest-time policy uses GET /past_meetings/{uuid}/participants.
Zoom withholds user_email for participants outside the host's Zoom account. The bridge uses Zoom's internal_user boolean as the primary externality signal, with INTERNAL_EMAIL_DOMAINS as fallback when an email is present.
recording.completed can fire before the VTT is ready. Optional recording.transcript_completed webhook speeds pickup; otherwise the transcript retry queue polls Zoom for up to 24h (TRANSCRIPT_MAX_WAIT_MS).
Server-to-Server OAuth uses a form-encoded body (grant_type=account_credentials&account_id=...) with Basic auth — see getZoomAccessToken in src/zoom/client.ts.
Meeting and call-recording write APIs require per-workspace enablement from Attio support — correct scopes alone are not sufficient. Expect 403/404 until enabled.
POST /v2/meetings is find-or-create keyed on external_ref, but list/GET responses never return it. The bridge uses host + start-time matching (findAttioMeetingByHostAndStart) to attach to calendar-synced meetings when possible.
POST /v2/meetings/{id}/call_recordings with { data: { video_url } }:
- URL must be public HTTPS to an
.mp4 - Max 500MB (
ATTIO_MAX_VIDEO_BYTES) - Attio does a
HEADfirst — needs validContent-Length - Roughly 1 request/second rate limit
POST /v2/meetings/{id}/call_recordings/{id}/transcript is used by this bridge but is not in Attio's public OpenAPI spec. Payload: { data: { transcript: [{ speech, start_time, end_time, speaker }] } }. end_time must be strictly greater than start_time.
Attio does not auto-transcribe API-uploaded videos — you must push Zoom's VTT yourself.
Confirmed behavior: videos uploaded via API are not transcribed by Attio. Transcript content comes from Zoom VTT via createAttioCallRecordingTranscript.
PostgREST applies or= logic trees on UPDATE to the RETURNING representation, not the WHERE clause. A single .or('processing_started_at.is.null,...') UPDATE can set a lease while returning zero rows.
The bridge uses two sequential single-column UPDATEs (.is then .lt) in claimBridgeRowLease (src/bridge/bridge.ts). Do not "simplify" this back to one .or() call — see also the comment in sql/schema.sql.
All bridge tables have RLS enabled with no anon/authenticated policies. Only the service role key can access queue, scheduler, and dedup tables.
Raise the project storage upload limit to ≥500MB in the Supabase dashboard. The SQL schema cannot set this. Default Supabase limits are below typical hour-long recordings.
zoom-recording-staging is created as a private bucket. Attio receives time-limited signed URLs, not public routes.
Two-phase claim → process → commit (src/webhook-dedup.ts). Transient failures call releaseWebhookClaim so Zoom's retry re-enters the handler. sweepStaleWebhookClaims deletes rows stuck in claimed past 5 minutes.
Queue idempotency for recordings is additionally enforced by the unique index on zoom_meeting_uuid — duplicate webhook deliveries must not clobber in-flight rows (ignoreDuplicates: true on enqueue).