Skip to main content

Fathom

Fathom is the canonical meeting-truth source for SABLE. Its signed webhook is the primary ingest path; fathom-sync is the hourly reconciliation backstop, plus a set of maintenance operations for backfilling and repairing meetings.

How it works​

Webhook (fathom-webhook)​

Fathom calls this function directly. It verifies the signed webhook against any configured secret: the FATHOM_WEBHOOK_SECRET edge secret, the Vault row of that name, or the Vault row FATHOM_WEBHOOK_SECRET_NEXT written by registerWebhook (so an old and a new webhook both verify while one replaces the other). It then claims the delivery by inserting its audit row (unique on detail->>webhook_id, status processing) and answers 202 at once. Ingesting takes 15-30 s, longer than Fathom waits, so the rest runs in the background: it normalizes the payload, fetches the transcript by recording_id if the payload didn't include it, calls the shared ingestMeeting, settles the audit row (done with the source_id, or suppressed) and emits activity_events. The activity emission is soft-fail, so a problem writing the activity trail never blocks the ingest itself.

A redelivery of an id that is already claimed answers 200 with duplicate: true and does nothing. It never answers 409: Fathom retries any non-2xx answer, which is how a slow 200 used to turn into hours of refused redeliveries.

After the 202 Fathom does not redeliver, so a background run that fails is recovered by the hourly reconciliation run (sable.fathom.reconcile, at :15), not by Fathom. The failed run deletes its processing claim so a manual resend can still retry it. A run killed mid-ingest (a redeploy, the wall-clock limit) leaves its claim at processing; reconciliation still picks the meeting up, and such rows can be found with action = 'fathom_webhook' and detail->>'status' = 'processing' older than a few minutes.

The signature payload is <webhook-id>.<webhook-timestamp>.<raw-body>; the function rejects stale timestamps.

Poller (fathom-sync)​

fathom-sync covers two jobs: the scheduled reconciliation sweep, and a set of on-demand maintenance operations, selected by which fields are present in the request body.

Normal reconciliation run (no special flags): list meetings (limit 1–200, default 3; createdAfter or lookbackDays 1–30) → normalize → ingestMeeting per meeting inside a wall-clock time budget (10s–140s, default 110s) → emit activity_events (soft-fail) → run a bounded link-repair sweep (5 items) if budget remains.

The listing carries no meeting content (meeting deletion, #306). After the deletion-suppression preflight, ingestMeeting fetches the transcript and Fathom's own summary (GET /recordings/{id}/summary) by recording id, so the meeting record is written with Fathom's summary as its baseline and the meeting page can show it. Fathom's action items and highlights have no per-recording endpoint and arrive only with the webhook. A delivery for a meeting that already has a row (the webhook arriving after the poll created it) fills any Fathom summary, action items or highlights the row lacks instead of being dropped; a summary added under an existing record marks the record stale (metadata.record_stale_reason).

A call that ingested nothing also runs, in order and only with time left: the link repair, a summary repair (up to 5 meetings from the last 30 days with no Fathom summary, 3 tries each, counted in metadata.fathom_summary_attempts), one missing-record repair, and, when no record is missing, one stale-record refresh (metadata.record_refresh_attempts, 3 tries).

Backfill/maintenance operations:

Body shapeWhat it does
{ backfillStubs: true, limit }Backfills stub/record rows
{ backfillRecords: true, limit }Writes meeting records for meetings that have none
{ backfillRecords: true, stale: true, limit }Regenerates records marked stale because a Fathom summary arrived after them; keep limit at 4 or under
{ backfillFathomSummaries: true, limit?, sinceDays?, sourceIds?, dryRun? }Fetches Fathom's summary for meetings stored without one (max limit 50); marks their records stale
{ backfillKnowledge: true, limit, externalIds?, sourceIds?, dryRun? }The link-repair operation — see below
{ recordingIds: [...], relatedById?, titles? }Targeted backfill by specific Fathom recording ids
{ registerWebhook: true }Admin or lead only. Registers a webhook for own, shared and teammates' recordings (shared_team_recordings), writes the whsec_ secret straight to Vault as FATHOM_WEBHOOK_SECRET_NEXT, records the webhook id in audit (fathom_webhook_registered), and returns only the id

backfillKnowledge repairs meetings whose project_id was set after ingest (for example, an operator SQL relink). It indexes the already-stored transcript into knowledge_chunks, appends the living project-note entry from the decisions/promises/tasks already extracted, and inherits the project onto derived rows still missing one. It does not re-run Claude extraction — only the embed step. A small bounded sweep of the same repair (limit 5) also runs automatically at the end of every scheduled poll, so future post-ingest relinks heal without operator action; per-source retries cap at 3 (tracked in metadata.link_repair_attempts — clear it to re-arm a source that hit the cap).

recordingIds targets a specific set of Fathom recording ids — used for master-register backfills. When multiple Fathom bots recorded one call, pass every alternate id under relatedById so ingestion skips a recording if any family member already exists. The primary-id policy is lowest numeric recording id (preferPrimaryRecordingId).

Two bots on one call​

When two people's Fathom bots record the same meeting, both recordings usually arrive within seconds. Every path skips a recording whose related recording, or a meeting with the same title within ±30 minutes, is already stored. That check runs before the transcript fetch and the model call, and the row is written after them, so two recordings arriving together both used to pass it (8 duplicate pairs in the 90 days to 6 Oct 2026).

New Fathom meetings therefore claim the call first, through claim_fathom_meeting. In one transaction, under a lock per organisation and title, it:

  1. Returns duplicate if a meeting with that title within ±30 minutes, or a related recording, is already stored.
  2. Returns lost if another recording already holds a live claim on the call.
  3. Otherwise records a claim in fathom_meeting_claims (a hash of the title, the start time, the recording id) and returns won.

Only a winner fetches the transcript and calls the model; the others return skipped ("another Fathom recording of this call is being ingested"). Once the winner's row exists, the other recordings are added to its related_recording_ids and the claims are deleted. If the winner fails, it releases the claim it created, and the next reconcile run ingests one of the others. Claims older than two hours are removed. The title is stored only as a hash. The hash isn't anonymous (short titles can be guessed), but the table has no access policies, so only the ingest path reads it.

Replacing the webhook (secret rotation)​

The secret never passes through an operator: registerWebhook writes it to Vault itself.

  1. As an admin or lead, POST { registerWebhook: true } to fathom-sync. The response carries only webhook_id, which is also recorded in audit as fathom_webhook_registered. It refuses with 409 while FATHOM_WEBHOOK_SECRET_NEXT exists, because a second registration would overwrite the secret of a webhook still in use (force: true overrides).

  2. Both webhooks now verify. Confirm a delivery from the new one (a fathom_webhook audit row after a new recording), then delete the old webhook in Fathom by its id, or in Fathom's settings if the id was never recorded (true of the webhook registered before 30 Sept 2026).

  3. Promote the new secret without reading it back, then drop _NEXT:

    select vault.update_secret(cur.id, nxt.decrypted_secret)
    from vault.secrets cur, vault.decrypted_secrets nxt
    where cur.name = 'FATHOM_WEBHOOK_SECRET' and nxt.name = 'FATHOM_WEBHOOK_SECRET_NEXT';
    delete from vault.secrets where name = 'FATHOM_WEBHOOK_SECRET_NEXT';
  4. If a FATHOM_WEBHOOK_SECRET edge secret is set, unset it (supabase secrets unset FATHOM_WEBHOOK_SECRET). The receiver falls back to the Vault row, and a retired secret stays accepted for as long as either copy holds it.

If the secret cannot be stored, the response returns the new webhook_id with secret_stored: false. Delete that webhook in Fathom and register again.

Reported health honesty​

The checkpoint written after each run claims a high-water-mark/coverage window only over what was actually processed — never the full requested listing — specifically because an earlier version could report success while having only partially processed a batch. stopped_early is reported whenever the time budget cuts a run off before it finished its work.

Secrets​

SecretUsed for
FATHOM_API_KEYREST polling and transcript fetch
FATHOM_WEBHOOK_SECRETWebhook signature verification (edge secret and/or Vault row)
FATHOM_WEBHOOK_SECRET_NEXTVault row only, written by registerWebhook; also accepted for verification
ANTHROPIC_API_KEYDecision/promise/task extraction
SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEYWrites

Cron cadence​

TaskScheduleParams
sable.fathom.reconcileHourly, :15 (15 * * * *){limit:25, lookbackDays:3}, loops up to 8 passes while stopped_early

The webhook has no schedule — it fires on Fathom's own delivery timing and is the primary path; the poller is reconciliation, not the main ingest route.

Table mapping​

TableWhat's written
sourcestype='meeting', external_id, full transcript text (body), Fathom metadata
decisions, promises, tasksExtracted per meeting, joined back to the source
approvalsPending Asana task-create approvals derived from extracted tasks
knowledge_chunksTranscript embeddings — including via the link-repair backfill
activity_eventsIngest activity trail (soft-fail emission)
connector_checkpointsHealth, high-water-mark, link_repair_attempts metadata
auditWebhook replay-guard + one row per webhook delivery

Duplicate behavior​

Historical duplicate sources.external_id rows exist in the data, so ingestion uses a lookup index and skips when it finds an existing source, rather than enforcing a hard uniqueness constraint across all historical data.

What can go wrong​

  • A meeting's project link was fixed manually, but the derived decisions/tasks still show the old project (or none). This is exactly what backfillKnowledge fixes — either wait for the automatic 5-item sweep at the end of the next scheduled poll, or run it directly with sourceIds/externalIds targeting that meeting.
  • A meeting stopped repairing after several relink attempts. Per-source link-repair retries cap at 3, tracked in metadata.link_repair_attempts on the source. Clear that field to re-arm it.
  • The link-repair scan itself failed with a URL-too-large error. This was a real regression — the existence check was putting a whole page of UUIDs into a single URL. It was fixed (see #218, deployed 2026-09-09); if it recurs, check whether the batch size for the existence check grew again.
  • A late-registered webhook secret was lost. registerWebhook: true returns the whsec_ value exactly once. If it wasn't saved, the webhook must be re-registered to get a new one — Fathom does not resurface a previously issued secret.
  • A meeting has a SABLE record but no Fathom summary. From 12 Sept 2026 until this fix shipped the poll stored none (it fetched only the transcript back), and the webhook was the only path that carried one. The idle summary repair heals the last 30 days on its own; to do it at once, POST { backfillFathomSummaries: true, sinceDays: 30, limit: 50 } (repeat until pending_found is 0), then { backfillRecords: true, stale: true, limit: 4 } until no stale records remain. A meeting Fathom has no summary for stops after 3 tries; clear metadata.fathom_summary_attempts to re-arm it.
  • A recorded meeting appears twice because two Fathom bots joined the same call. Pass every alternate recording id under relatedById on the next targeted backfill so the ingestion can recognize the family and skip re-ingesting it under a second id.

Where the code lives​

  • supabase/functions/fathom-webhook/index.ts — signed webhook receiver
  • supabase/functions/fathom-sync/index.ts — poller + backfill/maintenance operations
  • supabase/functions/fathom-sync/ingest.ts — shared ingestMeeting / backfillLinkedKnowledge logic
  • web/api/meeting.js — Meeting Brief API (reads sources/decisions/promises/tasks, generates meeting_record)
  • trigger/connectors.ts — sable.fathom.reconcile schedule
  • docs/bgm-sow-meeting-truth.md, scripts/bgm-sow/ — master-register backfill context