Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
505 changes: 505 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/v1-ready/fathom/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Fathom (fathom.video) API key.
# Generate under Fathom > User Settings > API Access.
# Sent on every request as the `X-Api-Key` header.
FATHOM_API_KEY=your_fathom_api_key_here
21 changes: 21 additions & 0 deletions packages/v1-ready/fathom/LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Left Hook

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
163 changes: 163 additions & 0 deletions packages/v1-ready/fathom/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Fathom API Module (`@friggframework/api-module-fathom`)

A [Frigg](https://friggframework.org) API module for [Fathom](https://fathom.video)
— the AI meeting recorder. It wraps Fathom's public REST API so a Frigg
integration can list meetings/recordings, pull transcripts and summaries, read
team members, and register webhooks.

Integrations consume it the standard Frigg way:

```javascript
const meetings = await this.fathom.api.listMeetings({ include_summary: true });
const { transcript } = await this.fathom.api.getTranscript(recordingId);
```

## Authentication

Fathom uses **API-key** authentication. Generate a key in Fathom under
**User Settings → API Access**, and the module sends it on every request as the
`X-Api-Key` header:

```bash
curl https://api.fathom.ai/external/v1/meetings -H "X-Api-Key: YOUR_API_KEY"
```

- **Base URL:** `https://api.fathom.ai/external/v1`
- **Rate limit:** 60 requests/minute across all of an account's API keys.

Set `FATHOM_API_KEY` in your environment (see `.env.example`). The module also
exposes a JSON-Schema authorization form (`getAuthorizationRequirements`) so the
key can be collected through the Frigg auth UI / `frigg auth` CLI.

## API Methods

| Method | HTTP | Purpose |
|---|---|---|
| `listMeetings(params)` | `GET /meetings` | List meetings/recordings (paginated via `next_cursor`). |
| `listAllMeetings(params, opts)` | — | Convenience: follows `next_cursor` and returns a flat array. |
| `getTranscript(recordingId, params)` | `GET /recordings/{id}/transcript` | Transcript segments (or async POST to `destination_url`). |
| `getSummary(recordingId, params)` | `GET /recordings/{id}/summary` | Markdown-formatted call summary. |
| `listTeamMembers(params)` | `GET /team_members` | People on the Fathom account (optional `cursor`, `team`). |
| `createWebhook(data)` | `POST /webhooks` | Register a webhook for new meeting content. |

### `listMeetings` parameters (all optional)

`cursor`, `created_after`, `created_before`, `meeting_type`,
`include_transcript`, `include_summary`, `include_action_items`,
`include_highlights`, `include_crm_matches`, `calendar_invitees_domains_type`,
and the array filters `recorded_by[]` (emails), `teams[]`,
`calendar_invitees_domains[]`. Array values are serialized with the `key[]`
repeated-key convention.

### Meeting shape (fields used by consumers)

```jsonc
{
"recording_id": 12345,
"title": "Acme <> Left Hook",
"meeting_title": "Discovery call",
"meeting_type": "external",
"url": "https://fathom.video/calls/12345",
"share_url": "https://fathom.video/share/...",
"created_at": "2026-08-11T18:00:00Z",
"scheduled_start_time": "2026-08-11T18:00:00Z",
"scheduled_end_time": "2026-08-11T18:30:00Z",
"recording_start_time": "2026-08-11T18:01:00Z",
"recording_end_time": "2026-08-11T18:29:00Z",
"calendar_invitees": [
{ "name": "Jane Buyer", "email": "jane@acme.com",
"email_domain": "acme.com", "is_external": true,
"matched_speaker_display_name": "Jane" }
],
"recorded_by": { "name": "Sean", "email": "sean@lefthook.co",
"email_domain": "lefthook.co", "team": "Left Hook" }
}
```

Attendee emails live on `calendar_invitees[].email` (with `is_external` and
`email_domain`), which is what you match against a CRM/Reevo contact. The
recorder is `recorded_by.email`.

### Transcript shape

```jsonc
{
"transcript": [
{ "speaker": { "display_name": "Jane",
"matched_calendar_invitee_email": "jane@acme.com" },
"text": "...", "timestamp": "00:01:12" }
]
}
```

### Summary shape

```jsonc
{ "summary": { "template_name": "General", "markdown_formatted": "## ..." } }
```

## Webhooks (this integration is webhook-driven)

Create a webhook with `createWebhook`:

```javascript
await this.fathom.api.createWebhook({
destination_url: 'https://your-frigg-app/webhooks/fathom',
triggered_for: ['my_recordings', 'shared_external_recordings'],
include_summary: true,
include_transcript: true,
include_action_items: true,
});
```

- `triggered_for` (required, ≥1): `my_recordings`,
`shared_external_recordings`, `my_shared_with_team_recordings`,
`shared_team_recordings`.
- At least one of `include_transcript`, `include_summary`,
`include_action_items`, `include_crm_matches` must be `true`.
- The response includes a `secret` (`whsec_...`) used to verify delivery
signatures.

**Event:** `new-meeting-content-ready` — delivered after a meeting is processed.
The payload carries the same meeting fields listed above (recording id, titles,
timestamps, `share_url`, `calendar_invitees[]`, `recorded_by`) plus the opted-in
`summary` / `transcript` / `action_items`.

**Signature verification:** each delivery carries `webhook-id`,
`webhook-timestamp`, and `webhook-signature` headers. Verify by HMAC-SHA256 over
`{id}.{timestamp}.{rawBody}` using the base64-decoded portion of the webhook
secret after the `whsec_` prefix, comparing in constant time within a 5-minute
timestamp tolerance (Svix-style signing).

## Documented-endpoint notes

The public REST API documents **list** meetings only (no single-meeting `GET`
by id) — fetch a specific recording's content via the transcript/summary
endpoints keyed on `recording_id`. There is **no `/me` identity endpoint**, so
the module derives account identity from the first meeting's `recorded_by`,
falling back to a stable label. `list-webhooks` / `delete-webhook` are not
documented at the time of writing, so only `createWebhook` is modeled.

## Testing

```bash
npm install
npm test
```

Tests are fully offline — HTTP methods are stubbed and assertions are made on the
request options the module builds. No API key or network access is required.

## Sources

- Fathom Developer Hub — https://developers.fathom.ai/
- List meetings — https://developers.fathom.ai/api-reference/meetings/list-meetings
- Get transcript — https://developers.fathom.ai/api-reference/recordings/get-transcript
- Get summary — https://developers.fathom.ai/api-reference/recordings/get-summary
- List team members — https://developers.fathom.ai/api-reference/team-members/list-team-members
- Create a webhook — https://developers.fathom.ai/api-reference/webhooks/create-a-webhook
- Webhooks overview — https://developers.fathom.ai/webhooks

## License

MIT — see [LICENSE.md](./LICENSE.md).
184 changes: 184 additions & 0 deletions packages/v1-ready/fathom/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
const { ApiKeyRequester, get } = require('@friggframework/core');

/**
* Fathom (fathom.video) API client.
*
* Auth: API key sent in the `X-Api-Key` request header.
* curl https://api.fathom.ai/external/v1/meetings -H "X-Api-Key: YOUR_API_KEY"
*
* Base URL: https://api.fathom.ai/external/v1
* Rate limit: 60 requests/minute across all of an account's API keys.
*
* Docs: https://developers.fathom.ai/
*/
class Api extends ApiKeyRequester {
constructor(params = {}) {
super(params);

// ApiKeyRequester puts `headers[this.api_key_name] = this.api_key`
this.api_key_name = 'X-Api-Key';
this.api_key =
get(params, 'api_key', null) ||
get(params, 'access_token', null) ||
get(params, 'apiKey', null);

this.baseUrl = 'https://api.fathom.ai/external/v1';

this.URLs = {
meetings: '/meetings',
teamMembers: '/team_members',
transcript: (recordingId) =>
`/recordings/${recordingId}/transcript`,
summary: (recordingId) => `/recordings/${recordingId}/summary`,
webhooks: '/webhooks',
};
}

// ---- Query helpers -----------------------------------------------------

/**
* Build a query string that supports both scalar params and array params
* (Fathom array filters use the `key[]` repeated-key convention). The core
* Requester's built-in query builder can't emit repeated keys, so we build
* the string here and append it to the URL directly.
*/
_buildQuery(params = {}) {
const usp = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
for (const v of value) {
if (v === undefined || v === null) continue;
usp.append(`${key}[]`, String(v));
}
} else {
usp.append(key, String(value));
}
}
const qs = usp.toString();
return qs ? `?${qs}` : '';
}

// ---- Meetings ----------------------------------------------------------

/**
* List meetings/recordings.
* GET /meetings
*
* Supported params (all optional):
* cursor, created_after, created_before, meeting_type,
* include_transcript, include_summary, include_action_items,
* include_highlights, include_crm_matches,
* calendar_invitees_domains_type,
* recorded_by[] (array of emails), teams[] (array),
* calendar_invitees_domains[] (array of domains)
*
* Response: { limit, next_cursor, items: [ Meeting ] }
* Each Meeting includes recording_id, title, meeting_title, share_url,
* url, scheduled_start_time, recording_start_time/end_time,
* calendar_invitees[{ name, email, email_domain, is_external }],
* recorded_by{ name, email, email_domain, team }.
*/
async listMeetings(params = {}) {
return this._get({
url: this.baseUrl + this.URLs.meetings + this._buildQuery(params),
});
}

/**
* Convenience: page through every meeting, following `next_cursor`.
* Returns a flat array of meeting items.
*/
async listAllMeetings(params = {}, { maxPages = 50 } = {}) {
const all = [];
let cursor = params.cursor;
let pages = 0;
do {
const page = await this.listMeetings({ ...params, cursor });
if (Array.isArray(page.items)) all.push(...page.items);
cursor = page.next_cursor;
pages += 1;
} while (cursor && pages < maxPages);
return all;
}

// ---- Recording content -------------------------------------------------

/**
* Get a recording's transcript.
* GET /recordings/{recording_id}/transcript
*
* With no destination_url the transcript is returned directly:
* { transcript: [ { speaker: { display_name,
* matched_calendar_invitee_email }, text, timestamp } ] }
* With destination_url it is POSTed there asynchronously and the
* endpoint returns { destination_url }.
*/
async getTranscript(recordingId, params = {}) {
return this._get({
url:
this.baseUrl +
this.URLs.transcript(recordingId) +
this._buildQuery(params),
});
}

/**
* Get a recording's summary.
* GET /recordings/{recording_id}/summary
*
* Direct response: { summary: { template_name, markdown_formatted } }
* Async (destination_url): { destination_url }
*/
async getSummary(recordingId, params = {}) {
return this._get({
url:
this.baseUrl +
this.URLs.summary(recordingId) +
this._buildQuery(params),
});
}

// ---- Team members ------------------------------------------------------

/**
* List team members.
* GET /team_members
* Optional params: cursor, team (filter by team name).
* Response: { limit, next_cursor, items: [ { name, email, created_at } ] }.
*/
async listTeamMembers(params = {}) {
return this._get({
url: this.baseUrl + this.URLs.teamMembers + this._buildQuery(params),
});
}

// ---- Webhooks ----------------------------------------------------------

/**
* Create a webhook.
* POST /webhooks
*
* body: {
* destination_url, // required
* triggered_for: [ 'my_recordings' | 'shared_external_recordings'
* | 'my_shared_with_team_recordings'
* | 'shared_team_recordings' ], // required
* include_transcript?, include_summary?,
* include_action_items?, include_crm_matches? // >=1 must be true
* }
*
* Response: { id, url, secret, created_at, triggered_for, include_* }
* The returned `secret` (whsec_...) verifies delivery signatures
* (webhook-id / webhook-timestamp / webhook-signature headers, HMAC-SHA256).
*/
async createWebhook(data) {
return this._post({
url: this.baseUrl + this.URLs.webhooks,
headers: { 'Content-Type': 'application/json' },
body: data,
});
}
}

module.exports = { Api };
10 changes: 10 additions & 0 deletions packages/v1-ready/fathom/defaultConfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "fathom",
"config": {
"apiKey": true,
"batch": {
"concurrency": 3,
"delay": 1000
}
}
}
Loading
Loading