# Fathom

> Send Fathom meeting transcripts to Dimension Labs with the universal API.

This brings in each Fathom meeting transcript as one conversation, turn by turn: what your prospects and customers said, and what your own team said. You run it on your side: a small script pulls from the Fathom API and posts each turn to the [universal API](/api/tracker).

## What you need

- A Fathom user whose API key can see the meetings you want. API access is included on all Fathom plans. Keys are per user, and there are no org-level keys: for org-wide access, give an Admin view access to all shared calls and use that Admin's key. Private meetings stay visible only to their host's own key.
- Optionally, the email domains your company uses, for example `acme.com`, as a fallback for telling your team apart.
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

## 1. Get API access in Fathom

1. In Fathom, open **User Settings** and go to **API Access** ([quickstart](https://developers.fathom.ai/quickstart)).
2. Generate an API key and copy it.
3. For org-wide access, have an account admin give that user view access to all shared calls, as described in Fathom's [FAQ](https://developers.fathom.ai/faq).

Send the key on every request in the `X-Api-Key` header.

## 2. Pull new meetings

[List meetings](https://developers.fathom.ai/api-overview) created in a time window, with their transcripts:

```bash
curl -G -H "X-Api-Key: $FATHOM_API_KEY" "https://api.fathom.ai/external/v1/meetings" \
  -d include_transcript=true \
  -d created_after=2026-10-25T00:00:00Z \
  -d created_before=2026-10-26T00:00:00Z
```

- `created_after` and `created_before` filter on the meeting's `created_at`.
- Each page returns a `next_cursor`. Send it back as `cursor` until it is `null`.
- Each meeting has a `recording_id`, a `recording_start_time` and, with `include_transcript=true`, a `transcript`: a list of items, each with a `speaker` (`display_name`, and `matched_calendar_invitee_email` when Fathom matched the speaker to a calendar invitee), the `text`, and a `timestamp` (`HH:MM:SS` from the start of the meeting).
- `calendar_invitees` lists each invitee's `email`, `email_domain` and `is_external`.
- Without `recorded_by[]` or `teams[]`, meetings recorded by users outside your Fathom org aren't returned.
- The code waits `FATHOM_DELAY_HOURS` (1 by default) before it reads a meeting, so its transcript is ready. Fathom can also notify you by webhook when new meeting content is ready.

Rate limits, per Fathom: 60 calls in a 60 second window. Requests with `include_transcript=true` are heavy requests: 30 in 60 seconds, which Fathom may lower to 5 during busy periods. Going over returns `429`; wait at least `Retry-After` seconds ([API overview](https://developers.fathom.ai/api-overview)).

## 3. Map the fields

| Dimension Labs | Fathom | Notes |
|---|---|---|
| `text` | `transcript[].text` | One message per transcript item. |
| `userId` | a hash of `speaker.matched_calendar_invitee_email`, else of `display_name` | Never the email or name itself. A speaker with no matched email gets a hash of the recording id and display name. |
| `incoming` | the matched invitee's `is_external` | `true` for external invitees (prospects, customers), `false` for your team. With no matched invitee, the code treats the person who recorded the meeting, and emails on your `COMPANY_DOMAINS`, as your team; anyone else is incoming. |
| `sessionId` | `recording_id` | Prefixed, for example `fathom-123456789`. |
| `conversationId` | `recording_id` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | `recording_start_time` plus `timestamp` | `timestamp` is to the second, so items in the same second are spaced to 1,000 ms. |
| `platformJson` | `meeting_type`, `transcript_language`, `calendar_invitees_domains_type`; `speakerMatched`, `internal` | `calendar_invitees_domains_type` says whether the invitee list included external domains. |

Fathom tells your team apart itself: each calendar invitee has `is_external`, and each transcript speaker carries the email of the invitee Fathom matched them to.

**Personal data.** Speaker names and invitee emails are used only to match and hash, and are never sent. Leave out `title`, `meeting_title` and `crm_matches`, which name the customer. Transcripts hold names, companies and numbers: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// fathom.mjs. Environment: FATHOM_API_KEY, COMPANY_DOMAINS (optional, for example "acme.com,acme.co.uk"), FATHOM_DELAY_HOURS (default 1), DL_API_KEY.



const HOUR = 60 * 60 * 1000;
const API = 'https://api.fathom.ai/external/v1';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const DELAY = Number(process.env.FATHOM_DELAY_HOURS ?? 1) * HOUR;
const DOMAINS = (process.env.COMPANY_DOMAINS ?? '').toLowerCase().split(',').map((d) => d.trim()).filter(Boolean);
const hash = (s) => createHash('sha256').update(s.trim().toLowerCase()).digest('hex').slice(0, 32);
const seconds = (hms) => hms.split(':').reduce((a, p) => a * 60 + Number(p), 0);

// Retries Fathom's rate limits (429) and server errors (5xx), honoring Retry-After.
async function get(url) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { headers: { 'X-Api-Key': process.env.FATHOM_API_KEY } });
    if (res.ok) return res.json();
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Fathom returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

// Sends every meeting created at or after `since` (Unix ms) and at least FATHOM_DELAY_HOURS ago. Returns the new `since`.
export async function syncFathom({ since, historical = false }) {
  const until = Date.now() - DELAY;
  if (until <= since) return since;
  const params = { include_transcript: 'true', created_after: new Date(since).toISOString(), created_before: new Date(until).toISOString() };
  for (let cursor = null; ; ) {
    const page = await get(`${API}/meetings?${new URLSearchParams({ ...params, ...(cursor ? { cursor } : {}) })}`);
    for (const m of page.items ?? []) {
      const invitees = new Map((m.calendar_invitees ?? []).filter((i) => i.email).map((i) => [i.email.toLowerCase(), i]));
      const recorder = m.recorded_by?.name?.trim().toLowerCase();
      const session = `fathom-${m.recording_id}`;
      const start = toMillis(m.recording_start_time);
      let last = 0;
      for (const item of m.transcript ?? []) {
        if (!item.text?.trim()) continue;
        const email = item.speaker?.matched_calendar_invitee_email?.toLowerCase();
        const name = item.speaker?.display_name?.trim();
        const invitee = email && invitees.get(email);
        const internal = invitee ? !invitee.is_external
          : Boolean((name && name.toLowerCase() === recorder) || (email && DOMAINS.includes(email.split('@')[1])));
        const ts = Math.max(start + seconds(item.timestamp) * 1000, last + 1000); // keep items at least a second apart
        last = ts;
        await sendToDimensionLabs({
          text: item.text,
          userId: email ? `fathom-${hash(email)}` : name ? `fathom-${hash(`${m.recording_id}-${name}`)}` : session,
          incoming: !internal,
          sessionId: session,
          conversationId: session,
          dimensionlabs_timestamp: ts,
          platformJson: {
            meetingType: m.meeting_type, language: m.transcript_language, inviteeDomains: m.calendar_invitees_domains_type,
            speakerMatched: Boolean(email), internal,
          },
        }, { historical: historical || ts < Date.now() - HOUR });
      }
    }
    cursor = page.next_cursor;
    if (!cursor) return until;
  }
}
```

## 5. Run it

Save the code as `fathom.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/scheduled-sync) runner: `import { syncFathom as sync } from './fathom.mjs';`.

- **History first:** `node run.mjs 2025-01-01` sends every meeting since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends meetings once they are `FATHOM_DELAY_HOURS` old. By then the turns are more than an hour old, so they go to `/trackhistorical`: start a backfill whenever you want the latest meetings enriched.
- **Re-runs are safe:** duplicates are removed automatically. If a transcript was late, run `node run.mjs` with a date before the meeting to pick it up.

Every page with transcripts is a heavy request, so a history load is held to Fathom's heavy-request limit; the code waits whenever Fathom asks it to. You can also [upload a file](/data/upload-a-file) of exported transcripts (see [Call transcripts](/transform/recipe-call-transcripts), and check it with the [file checker](/data/check-a-file)), then run a backfill.

## Fathom reference

- [API overview (authentication, rate limits)](https://developers.fathom.ai/api-overview)
- [Quickstart](https://developers.fathom.ai/quickstart)
- [FAQ (API keys and org-wide access)](https://developers.fathom.ai/faq)
- [Webhooks](https://developers.fathom.ai/webhooks)
- [OpenAPI specification](https://developers.fathom.ai/api-reference/openapi.yaml)

Checked against Fathom's documentation on 2026-10-26.
