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 shape | What 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:
- Returns
duplicateif a meeting with that title within ±30 minutes, or a related recording, is already stored. - Returns
lostif another recording already holds a live claim on the call. - Otherwise records a claim in
fathom_meeting_claims(a hash of the title, the start time, the recording id) and returnswon.
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.
-
As an admin or lead, POST
{ registerWebhook: true }tofathom-sync. The response carries onlywebhook_id, which is also recorded inauditasfathom_webhook_registered. It refuses with409whileFATHOM_WEBHOOK_SECRET_NEXTexists, because a second registration would overwrite the secret of a webhook still in use (force: trueoverrides). -
Both webhooks now verify. Confirm a delivery from the new one (a
fathom_webhookaudit 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). -
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 nxtwhere cur.name = 'FATHOM_WEBHOOK_SECRET' and nxt.name = 'FATHOM_WEBHOOK_SECRET_NEXT';delete from vault.secrets where name = 'FATHOM_WEBHOOK_SECRET_NEXT'; -
If a
FATHOM_WEBHOOK_SECRETedge 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
| Secret | Used for |
|---|---|
FATHOM_API_KEY | REST polling and transcript fetch |
FATHOM_WEBHOOK_SECRET | Webhook signature verification (edge secret and/or Vault row) |
FATHOM_WEBHOOK_SECRET_NEXT | Vault row only, written by registerWebhook; also accepted for verification |
ANTHROPIC_API_KEY | Decision/promise/task extraction |
SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY | Writes |
Cron cadence
| Task | Schedule | Params |
|---|---|---|
sable.fathom.reconcile | Hourly, :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
| Table | What's written |
|---|---|
sources | type='meeting', external_id, full transcript text (body), Fathom metadata |
decisions, promises, tasks | Extracted per meeting, joined back to the source |
approvals | Pending Asana task-create approvals derived from extracted tasks |
knowledge_chunks | Transcript embeddings — including via the link-repair backfill |
activity_events | Ingest activity trail (soft-fail emission) |
connector_checkpoints | Health, high-water-mark, link_repair_attempts metadata |
audit | Webhook 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
backfillKnowledgefixes — either wait for the automatic 5-item sweep at the end of the next scheduled poll, or run it directly withsourceIds/externalIdstargeting that meeting. - A meeting stopped repairing after several relink attempts. Per-source link-repair retries cap
at 3, tracked in
metadata.link_repair_attemptson 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: truereturns thewhsec_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 untilpending_foundis 0), then{ backfillRecords: true, stale: true, limit: 4 }until no stale records remain. A meeting Fathom has no summary for stops after 3 tries; clearmetadata.fathom_summary_attemptsto re-arm it. - A recorded meeting appears twice because two Fathom bots joined the same call. Pass every
alternate recording id under
relatedByIdon 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 receiversupabase/functions/fathom-sync/index.ts— poller + backfill/maintenance operationssupabase/functions/fathom-sync/ingest.ts— sharedingestMeeting/backfillLinkedKnowledgelogicweb/api/meeting.js— Meeting Brief API (readssources/decisions/promises/tasks, generatesmeeting_record)trigger/connectors.ts—sable.fathom.reconcilescheduledocs/bgm-sow-meeting-truth.md,scripts/bgm-sow/— master-register backfill context