# Guide to Fathom Webhooks: Features and Best Practices

Fathom records, transcribes and summarises video meetings. Its webhook fires once a recording's content is ready and sends you the meeting: who attended, when, and optionally the transcript, the summary, the action items and matching CRM records. That lets you push meeting notes into a CRM, a ticketing system or your own data store without polling the API for new recordings.

This guide covers how Fathom webhooks are created and signed, what the payload contains, the gaps in Fathom's documentation, and the best practices for running a Fathom receiver in production.

## What are Fathom webhooks?

A Fathom webhook is a destination URL attached to your Fathom user, with two choices: which recordings trigger it, and which content to include. There is one kind of event, "New meeting content ready", and every delivery is a `POST` whose JSON body is the meeting itself.

Each request carries `webhook-id`, `webhook-timestamp` and `webhook-signature` headers. The signature is an HMAC-SHA256 over the ID, the timestamp and the raw body, keyed with the webhook's `whsec_` secret. That is the Standard Webhooks scheme, so any library that implements it can verify Fathom deliveries.

## Fathom webhook features

| Feature | Details |
| --- | --- |
| Configuration | **User Settings** > **API Access** > **Manage** > **Add Webhook**, or `POST /webhooks` |
| Events | One: "New meeting content ready" |
| Triggers | Your recordings, recordings shared with you, and (Team Plans) team recordings |
| Optional content | Transcript, summary, action items, CRM matches. At least one must be on |
| Signature headers | `webhook-id`, `webhook-timestamp`, `webhook-signature` |
| Scheme | Standard Webhooks: HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}`, sent as `v1,<base64>` |
| Secret | One per webhook, prefixed `whsec_`, returned when the webhook is created |
| Replay window | Fathom suggests "typically 5 minutes" in its steps; enforce one only if you choose |
| Acknowledgement | Any `2xx` |
| Retries | Not documented. `webhook-id` is reused when a webhook is resent |
| Source IPs | Not documented |

## Common events

Fathom has a single webhook, documented as `newMeeting` ("New meeting content ready"). It fires shortly after a recorded meeting ends and Fathom has processed it.

What triggers it depends on `triggered_for`:

| Value | Recordings included |
| --- | --- |
| `my_recordings` | Your private recordings and those you've shared with individuals |
| `shared_external_recordings` | Recordings other users shared with you (on Team Plans, excluding users on your plan) |
| `my_shared_with_team_recordings` | Recordings you've shared with teams. Team Plans only |
| `shared_team_recordings` | Recordings you can access from other users on your Team Plan. Team Plans only |

The body is Fathom's `Meeting` object. There's no event type field, because there's only one event. A trimmed example:

```json
{
  "title": "Quarterly Business Review",
  "meeting_title": "QBR 2025 Q1",
  "meeting_type": "Quarterly Business Review",
  "recording_id": 123456789,
  "url": "https://fathom.video/xyz123",
  "share_url": "https://fathom.video/share/xyz123",
  "meeting_url": "https://us02web.zoom.us/j/123456789",
  "created_at": "2025-03-01T17:01:30Z",
  "scheduled_start_time": "2025-03-01T16:00:00Z",
  "scheduled_end_time": "2025-03-01T17:00:00Z",
  "recording_start_time": "2025-03-01T16:01:12Z",
  "recording_end_time": "2025-03-01T17:00:55Z",
  "calendar_invitees_domains_type": "one_or_more_external",
  "shared_with": "single_team",
  "transcript_language": "en",
  "transcript": [
    {
      "speaker": { "display_name": "Jane Doe", "matched_calendar_invitee_email": "jane.doe@acme.com" },
      "text": "Let's revisit the budget allocations.",
      "timestamp": "00:05:32"
    }
  ],
  "default_summary": {
    "template_name": "general",
    "markdown_formatted": "## Summary\nWe reviewed Q1 OKRs, identified budget risks, and agreed to revisit projections next month.\n"
  },
  "action_items": [
    {
      "description": "Email revised proposal to client",
      "user_generated": false,
      "completed": false,
      "recording_timestamp": "00:10:45",
      "recording_playback_url": "https://fathom.video/calls/xyz123?timestamp=645",
      "assignee": { "name": "Jane Doe", "email": "jane.doe@acme.com", "team": "Marketing" }
    }
  ],
  "calendar_invitees": [
    {
      "name": "John Smith",
      "matched_speaker_display_name": "John Smith",
      "email": "john.smith@client.com",
      "is_external": true,
      "email_domain": "client.com"
    }
  ],
  "recorded_by": {
    "name": "Alice Johnson",
    "email": "alice.johnson@acme.com",
    "team": "Customer Success",
    "email_domain": "acme.com"
  },
  "crm_matches": null
}

```

`recording_id` identifies the recording. `transcript`, `default_summary`, `action_items`, `highlights` and `crm_matches` are nullable, and are only filled in when you asked for them. `meeting_title`, `meeting_type` and `meeting_url` can also be `null`. When no CRM is connected, `crm_matches` has an `error` field instead of matches.

## Setting up Fathom webhooks

In the Fathom app:

1. Open **User Settings** and go to **API Access**.
2. Generate an API key, then go to **Manage** > **Add Webhook**.
3. Enter a destination URL.
4. Select which new recordings should trigger the webhook.
5. Select what data to include in the payload.

Or create one with the API, authenticating with your key in `X-Api-Key`:

```bash
curl -X POST https://api.fathom.ai/external/v1/webhooks \
  -H "X-Api-Key: $FATHOM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "destination_url": "https://example.com/webhooks/fathom",
    "triggered_for": ["my_recordings"],
    "include_summary": true,
    "include_action_items": true
  }'

```

The response includes the webhook's `secret`. Store it. Fathom says to check the response body to confirm the webhook was created as expected, and API-created webhooks also appear in Settings. To test, record a short meeting of about two minutes; the webhook should arrive shortly after it ends.

## Securing Fathom webhooks

Fathom's steps are the Standard Webhooks steps. Read the three headers, base64-decode the part of your secret after `whsec_`, compute an HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}`, base64-encode it, and compare it in constant time with each signature in `webhook-signature`.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyFathomSignature(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatureHeader = headers['webhook-signature'];
  if (!id || !timestamp || !signatureHeader || !secret) return false;

  // Optional: Fathom's steps suggest a tolerance of about 5 minutes, e.g.
  // if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const signedContent = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const expected = crypto.createHmac('sha256', key).update(signedContent).digest();

  // webhook-signature is a space-delimited list of "v1,<base64>" entries.
  return signatureHeader.split(' ').some((entry) => {
    const [version, value] = entry.split(',');
    if (version !== 'v1' || !value) return false;
    const received = Buffer.from(value, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/fathom', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.FATHOM_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyFathomSignature(req.body, req.headers, secret)) {
    return res.sendStatus(401);
  }

  const meeting = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on req.headers['webhook-id'] or meeting.recording_id, then process asynchronously.
  res.sendStatus(204);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_fathom_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signature_header = headers.get("webhook-signature")
    if not (msg_id and timestamp and signature_header and secret):
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, value = entry.partition(",")
        if version == "v1" and hmac.compare_digest(value.encode(), expected):
            return True
    return False

```

If you use Fathom's SDK, `Fathom.verifyWebhook(webhook_secret, request.headers, request.body)` (TypeScript) and `Fathom.verify_webhook(...)` (Python) do this for you, given the raw body.

One thing to watch if you copy Fathom's own Node example: it calls `crypto.timingSafeEqual` without checking lengths first, and that function throws when the two buffers differ in length. A malformed signature then becomes a 500 rather than a 401. Check the lengths before comparing, as above.

To check your verifier, use the secret `whsec_5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH` (the example secret from Fathom's documentation), `webhook-id` `msg_2x2zwLOcWaRTzsLK8KtbQt1FTk9`, `webhook-timestamp` `1761039000` and this exact body:

```
{"title":"Quarterly Business Review","recording_id":123456789,"url":"https://fathom.video/xyz123"}

```

The `webhook-signature` should be:

```
v1,/Ci3gofxn3TvFIR7iNPqugoX3ZOYCHce62ZakAwMkww=

```

> **Make Fathom webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the Standard Webhooks signature on every Fathom delivery, durably queues each meeting, and lets you replay it into your CRM sync or notes pipeline.

## Fathom webhook limitations and pain points

### Retry behaviour isn't documented

**The Problem:** Your endpoint is down when a meeting finishes, and you don't know whether Fathom will send that meeting again.

**Why It Happens:** Fathom says any `2xx` acknowledges a webhook, and that `webhook-id` "will be the same when the same webhook is being resent (e.g. due to a previous failure)". That implies resends, but Fathom publishes no schedule, timeout or limit.

**Workarounds:**

* Return a `2xx` as soon as the meeting is stored.
* Reconcile with the API's `GET /meetings` list on a schedule, so a missed webhook is caught.

**How Hookdeck Can Help:** Hookdeck accepts each Fathom delivery as soon as it arrives and stores it, so an outage on your side doesn't depend on Fathom's undocumented retries. You can retry or replay stored meetings into your service once it recovers.

### Payloads can be large

**The Problem:** A long meeting with the transcript included produces a large body that times out or hits a size limit in your stack.

**Why It Happens:** With `include_transcript` on, the body carries every transcript line for the whole meeting, plus the summary and action items if you asked for them.

**Workarounds:**

* Include only what you use. If you need the transcript occasionally, leave it off and fetch it with `GET /recordings/{recording_id}/transcript`.
* Raise body size limits on the webhook route only.

**How Hookdeck Can Help:** Hookdeck accepts the full delivery at once and queues it, so your service can take its time processing a long transcript without holding Fathom's request open.

### One webhook, several audiences

**The Problem:** A single webhook receives your own meetings and meetings shared with you, and your CRM sync processes recordings it shouldn't.

**Why It Happens:** `triggered_for` is a list, and every selected category arrives at the same URL with the same body shape. The body tells you `shared_with` and `recorded_by`, but there's no field naming which trigger matched.

**Workarounds:**

* Create separate webhooks with separate URLs for each `triggered_for` value you treat differently.
* Filter on `recorded_by.email` or `calendar_invitees_domains_type` in your handler.

**How Hookdeck Can Help:** Hookdeck filters can route on body fields such as `calendar_invitees_domains_type`, so only meetings with external attendees reach your CRM sync.

## Best practices

### Verify against the raw body

The signature covers the exact bytes Fathom sent, and Fathom stresses using "the raw body, before any JSON parsing". Use `express.raw()`, `await request.text()` in a Next.js route handler, or `await request.body()` in FastAPI.

### Deduplicate on `webhook-id`

A resent webhook keeps its `webhook-id`, so store it and skip ones you've already handled. `recording_id` identifies the meeting itself and is the key for your own records. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Treat optional content as optional

Check `transcript`, `default_summary`, `action_items` and `crm_matches` for `null` before reading them, even when you asked for them. `meeting_url` is `null` when there's no associated calendar meeting, and `meeting_title` and `meeting_type` can be `null` too.

### Acknowledge first, process after

Store the meeting, return `2xx`, then write to your CRM or call a model on the transcript. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Keep verification on outside development

Fathom says you can skip verification while testing locally, "but don't forget to add it back in before going live". Fail closed when the secret isn't configured.

## Conclusion

Fathom sends one webhook per processed meeting, signed with Standard Webhooks: an HMAC-SHA256 of `webhook-id`, `webhook-timestamp` and the raw body, keyed with your base64-decoded `whsec_` secret. Include only the content you need, verify against the raw bytes with a length check before the constant-time compare, deduplicate on `webhook-id`, and reconcile with the API because Fathom doesn't document its retries.

[Hookdeck Event Gateway](https://hookdeck.com) verifies each Fathom delivery, stores it, and retries into your service on your schedule.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Fathom webhooks reliably in minutes.