Skip to content

Commit c165ade

Browse files
committed
Sync Fathom recordings from webhooks
1 parent 4349a89 commit c165ade

40 files changed

Lines changed: 1604 additions & 8 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Fathom live sync
2+
3+
Each Twenty user connects Fathom through OAuth. Fathom remains the recording
4+
permission boundary, so the two login email addresses do not need to match.
5+
6+
The connection hook registers a signed webhook at `TWENTY_FUNCTIONS_URL` and
7+
stores its identifier and signing secret in workspace-scoped application KV.
8+
The webhook subscribes to recordings owned by the connected user and recordings
9+
available through their Fathom team.
10+
11+
Deliveries are verified before parsing. A deterministic Call Recording ID makes
12+
replayed deliveries idempotent. Calendar events are linked only when normalized
13+
meeting URL and scheduled time produce one unambiguous match.
14+
15+
Twenty currently invokes the disconnect hook after deleting the connection
16+
token. The hook therefore marks the registration inactive so signed deliveries
17+
in flight are acknowledged, but cannot delete the remote Fathom webhook. Fathom
18+
publishes no token revocation endpoint either, so the webhook keeps delivering
19+
until the user removes the app from their Fathom settings; every such delivery
20+
is verified, then skipped. The uninstall hook still holds every connection
21+
token, so it deletes the Fathom webhook of each connected account before the
22+
app goes away.

packages/twenty-apps/public/fathom/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,6 @@ All notable changes to this application are documented in this file.
44

55
## 0.1.0
66

7-
- Initial application scaffolded with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app)
7+
- Add per-user Fathom OAuth and signed webhook registration.
8+
- Sync new Fathom recordings into idempotent Twenty Call Recordings.
9+
- Delete registered Fathom webhooks when the app is uninstalled.
Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
11
# Fathom
22

3-
Connect a Fathom account to Twenty through OAuth. Twenty stores and refreshes
4-
the connection token for the person who connected it.
3+
**Bring new Fathom meeting recordings, transcripts, and insights into Twenty.**
4+
5+
## What you get
6+
7+
- Fathom recordings saved as Call Recordings
8+
- Transcripts, summaries, and action items
9+
- Links to matching calendar events when the match is unambiguous
10+
- Automatic sync for new recordings
511

612
## Requirements
713

8-
The centrally managed Twenty OAuth app needs a Fathom client ID and client
9-
secret. Development setup lives in [SETUP.md](SETUP.md).
14+
Each person connects their Fathom account to Twenty. The connected Fathom
15+
account decides which recordings Twenty can import; the Fathom and Twenty login
16+
emails do not need to match.
17+
18+
## Heads up
19+
20+
- Twenty only links a recording to a calendar event when its meeting URL and
21+
scheduled time identify one clear match.
22+
- Disconnect and reconnect Fathom after changing the webhook URL or webhook
23+
scopes so Fathom receives the new registration.
24+
25+
Development setup lives in [SETUP.md](SETUP.md).

packages/twenty-apps/public/fathom/SETUP.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ Follow these steps to get your app running locally.
88
- Yarn 4
99
- Docker (to run the local Twenty server)
1010
- A Fathom OAuth client
11+
- A public HTTPS URL forwarding to the local Twenty server
12+
13+
Register this OAuth callback in Fathom:
14+
15+
```text
16+
https://<development-host>/auth/apps/callback
17+
```
18+
19+
Configure Twenty's `TWENTY_FUNCTIONS_URL` with the public functions base URL,
20+
including its path when present, for example `https://<development-host>/s`.
21+
The connection hook registers this destination automatically:
22+
23+
```text
24+
https://<development-host>/s/webhook/fathom?connectionId=<connected-account-id>
25+
```
1126

1227
## Steps
1328

@@ -34,7 +49,7 @@ Follow these steps to get your app running locally.
3449
yarn twenty dev
3550
```
3651

37-
5. Open [http://localhost:2020](http://localhost:2020), log in with the default development credentials: `tim@apple.dev` / `tim@apple.dev`, and connect Fathom from Settings.
52+
5. Open [http://localhost:2020](http://localhost:2020), log in with the default development credentials: `tim@apple.dev` / `tim@apple.dev`, and connect Fathom from Settings. The connection hook registers the signed webhook.
3853

3954
## Verifying your setup
4055

packages/twenty-apps/public/fathom/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
"test:watch": "vitest",
1919
"test:unit": "vitest run --config vitest.unit.config.ts"
2020
},
21+
"dependencies": {
22+
"@sniptt/guards": "^0.2.0",
23+
"fathom-typescript": "0.0.43"
24+
},
2125
"devDependencies": {
2226
"@types/node": "^24.7.2",
2327
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { FathomError } from 'fathom-typescript/sdk/models/errors';
2+
3+
export const buildFathomNotFoundError = (): FathomError =>
4+
new FathomError('Not found', {
5+
response: new Response(null, { status: 404 }),
6+
request: new Request('https://api.fathom.ai/external/v1/webhooks/1'),
7+
body: '',
8+
});

packages/twenty-apps/public/fathom/src/connection-providers/fathom-connection.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
import { defineConnectionProvider } from 'twenty-sdk/define';
22

3-
import { FATHOM_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
3+
import { FATHOM_PROVIDER_NAME } from 'src/constants/fathom.constant';
4+
import {
5+
FATHOM_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER,
6+
FATHOM_DISCONNECT_UNIVERSAL_IDENTIFIER,
7+
FATHOM_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER,
8+
} from 'src/constants/universal-identifiers';
49

510
export default defineConnectionProvider({
611
universalIdentifier: FATHOM_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER,
7-
name: 'fathom',
12+
name: FATHOM_PROVIDER_NAME,
813
displayName: 'Fathom',
914
type: 'oauth',
15+
onConnectLogicFunction: {
16+
universalIdentifier: FATHOM_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER,
17+
},
18+
onDisconnectLogicFunction: {
19+
universalIdentifier: FATHOM_DISCONNECT_UNIVERSAL_IDENTIFIER,
20+
},
1021
oauth: {
1122
authorizationEndpoint: 'https://fathom.video/external/v1/oauth2/authorize',
1223
tokenEndpoint: 'https://api.fathom.ai/external/v1/oauth2/token',
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const FATHOM_PROVIDER_NAME = 'fathom';
2+
export const FATHOM_WEBHOOK_ROUTE_PATH = '/webhook/fathom';
3+
export const FATHOM_WEBHOOK_CONNECTION_QUERY_PARAMETER = 'connectionId';

packages/twenty-apps/public/fathom/src/constants/universal-identifiers.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,11 @@ export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
77
'66c9ec61-dd73-4b1c-b3c5-ee54dffd8f05';
88
export const FATHOM_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER =
99
'eec1a27d-7678-43df-81e9-9f245ada8fe4';
10+
export const FATHOM_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER =
11+
'd6cb329f-8d0d-48f0-af30-e3ce2e11ddc6';
12+
export const FATHOM_DISCONNECT_UNIVERSAL_IDENTIFIER =
13+
'51edabd6-6698-4317-b134-f1d41633d76b';
14+
export const FATHOM_WEBHOOK_UNIVERSAL_IDENTIFIER =
15+
'4047fcd1-8581-4188-bf35-e3e49a6235ca';
16+
export const FATHOM_UNINSTALL_UNIVERSAL_IDENTIFIER =
17+
'2f0b2b98-6ec1-4a3f-95bb-b2adf4bd4e33';
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { buildFathomNotFoundError } from 'src/__tests__/utils/build-fathom-not-found-error.util';
4+
5+
const sdkMocks = vi.hoisted(() => ({
6+
createWebhook: vi.fn(),
7+
deleteWebhook: vi.fn(),
8+
getConnection: vi.fn(),
9+
kvGet: vi.fn(),
10+
kvSet: vi.fn(),
11+
}));
12+
13+
vi.mock('twenty-sdk/define', () => ({
14+
defineLogicFunction: (config: unknown) => config,
15+
}));
16+
17+
vi.mock('twenty-sdk/logic-function', () => ({
18+
kv: { get: sdkMocks.kvGet, set: sdkMocks.kvSet },
19+
getConnection: sdkMocks.getConnection,
20+
}));
21+
22+
vi.mock('fathom-typescript', () => ({
23+
Fathom: class Fathom {
24+
createWebhook = sdkMocks.createWebhook;
25+
deleteWebhook = sdkMocks.deleteWebhook;
26+
},
27+
}));
28+
29+
const { fathomRegisterConnectionHandler } =
30+
await import('src/logic-functions/fathom-register-connection');
31+
32+
const HOOK_PAYLOAD = {
33+
connectionProviderId: 'provider-1',
34+
connectionProviderName: 'fathom',
35+
connectedAccountId: 'connection-1',
36+
};
37+
38+
const INACTIVE_REGISTRATION = {
39+
webhookId: 'stale-webhook',
40+
secret: 'secret',
41+
isActive: false,
42+
};
43+
44+
describe('fathomRegisterConnectionHandler', () => {
45+
beforeEach(() => {
46+
vi.resetAllMocks();
47+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
48+
process.env.TWENTY_FUNCTIONS_URL = 'https://example.dev/s';
49+
sdkMocks.getConnection.mockResolvedValue({ accessToken: 'token' });
50+
sdkMocks.kvGet.mockResolvedValue(null);
51+
sdkMocks.createWebhook.mockResolvedValue({
52+
id: 'webhook-1',
53+
secret: 'secret-1',
54+
});
55+
});
56+
57+
it('registers a webhook pointing at the connection-scoped route', async () => {
58+
expect(await fathomRegisterConnectionHandler(HOOK_PAYLOAD)).toEqual({
59+
success: true,
60+
webhookId: 'webhook-1',
61+
});
62+
expect(sdkMocks.createWebhook).toHaveBeenCalledWith(
63+
expect.objectContaining({
64+
destinationUrl:
65+
'https://example.dev/s/webhook/fathom?connectionId=connection-1',
66+
}),
67+
);
68+
expect(sdkMocks.kvSet).toHaveBeenCalledWith('fathom-webhook:connection-1', {
69+
webhookId: 'webhook-1',
70+
secret: 'secret-1',
71+
isActive: true,
72+
});
73+
});
74+
75+
it('deletes the webhook it just created when the registration cannot be stored', async () => {
76+
sdkMocks.kvSet.mockRejectedValue(new Error('Key value store unavailable'));
77+
78+
await expect(fathomRegisterConnectionHandler(HOOK_PAYLOAD)).rejects.toThrow(
79+
'Key value store unavailable',
80+
);
81+
expect(sdkMocks.deleteWebhook).toHaveBeenCalledWith({ id: 'webhook-1' });
82+
});
83+
84+
it('keeps the webhook when the failed write turns out to have committed', async () => {
85+
sdkMocks.kvSet.mockRejectedValue(new Error('Response lost'));
86+
sdkMocks.kvGet.mockResolvedValueOnce(null).mockResolvedValueOnce({
87+
webhookId: 'webhook-1',
88+
secret: 'secret-1',
89+
isActive: true,
90+
});
91+
92+
expect(await fathomRegisterConnectionHandler(HOOK_PAYLOAD)).toEqual({
93+
success: true,
94+
webhookId: 'webhook-1',
95+
});
96+
expect(sdkMocks.deleteWebhook).not.toHaveBeenCalled();
97+
});
98+
99+
it('reports the storage failure even when the created webhook cannot be deleted', async () => {
100+
sdkMocks.kvSet.mockRejectedValue(new Error('Key value store unavailable'));
101+
sdkMocks.deleteWebhook.mockRejectedValue(new Error('Fathom unavailable'));
102+
103+
await expect(fathomRegisterConnectionHandler(HOOK_PAYLOAD)).rejects.toThrow(
104+
'Key value store unavailable',
105+
);
106+
});
107+
108+
it('reuses the webhook of an already active registration', async () => {
109+
sdkMocks.kvGet.mockResolvedValue({
110+
...INACTIVE_REGISTRATION,
111+
webhookId: 'existing-webhook',
112+
isActive: true,
113+
});
114+
115+
expect(await fathomRegisterConnectionHandler(HOOK_PAYLOAD)).toEqual({
116+
success: true,
117+
webhookId: 'existing-webhook',
118+
});
119+
expect(sdkMocks.createWebhook).not.toHaveBeenCalled();
120+
});
121+
122+
it('replaces an inactive registration whose webhook Fathom already dropped', async () => {
123+
sdkMocks.kvGet.mockResolvedValue(INACTIVE_REGISTRATION);
124+
sdkMocks.deleteWebhook.mockRejectedValue(buildFathomNotFoundError());
125+
126+
expect(await fathomRegisterConnectionHandler(HOOK_PAYLOAD)).toEqual({
127+
success: true,
128+
webhookId: 'webhook-1',
129+
});
130+
expect(sdkMocks.deleteWebhook).toHaveBeenCalledWith({
131+
id: 'stale-webhook',
132+
});
133+
});
134+
135+
it('does not replace an inactive registration when its webhook cannot be deleted', async () => {
136+
sdkMocks.kvGet.mockResolvedValue(INACTIVE_REGISTRATION);
137+
sdkMocks.deleteWebhook.mockRejectedValue(new Error('Fathom unavailable'));
138+
139+
await expect(fathomRegisterConnectionHandler(HOOK_PAYLOAD)).rejects.toThrow(
140+
'Fathom unavailable',
141+
);
142+
expect(sdkMocks.createWebhook).not.toHaveBeenCalled();
143+
});
144+
});

0 commit comments

Comments
 (0)