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_ | Recordings other users shared with you (on Team Plans, excluding users on your plan) |
my_ | 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:
{
"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:
- Open User Settings and go to API Access.
- Generate an API key, then go to Manage > Add Webhook.
- Enter a destination URL.
- Select which new recordings should trigger the webhook.
- Select what data to include in the payload.
Or create one with the API, authenticating with your key in X-Api-Key:
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.
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:
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_ (TypeScript) and Fathom.verify_ (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_ (the example secret from Fathom's documentation), webhook-id msg_, 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 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
2xxas soon as the meeting is stored. - Reconcile with the API's
GET /meetingslist 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_forvalue you treat differently. - Filter on
recorded_by.emailorcalendar_in your handler.invitees_ domains_ type
How Hookdeck Can Help: Hookdeck filters can route on body fields such as calendar_, 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.
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.
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 verifies each Fathom delivery, stores it, and retries into your service on your schedule.
Get started with Hookdeck for free and handle Fathom webhooks reliably in minutes.