From 5cb8a091fc506b640ff670b1d6a106daf008e004 Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Tue, 16 Jun 2026 16:08:15 +0530
Subject: [PATCH] Add Recall webhook status handler to the meeting bot app
(#21659)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a `recall-webhook` logic function (`POST /webhook/recall`,
unauthenticated) to the `twenty-meeting-bot` app. It verifies the
Recall/Svix `whsec_` signature over the raw body, parses bot lifecycle
events, matches the corresponding `CallRecording` (by
`twentyCallRecordingId` metadata, falling back to `externalBotId`), and
updates lifecycle fields — `status`, `externalBotId`,
`externalRecordingId`, and `startedAt`/`endedAt` (only when unset) —
guarded against stale out-of-order events that would move the status
backwards. Adds the required `RECALL_WEBHOOK_SECRET` server variable.
This opens the real provider test path: install the app → schedule a bot
through the existing calendar-event flow → point a Recall webhook
endpoint at Twenty → bot lifecycle events update the matching
`CallRecording`.
Unit tests cover signature verification, status mapping, the downgrade
guard, metadata/bot-id matching, and timestamp fill.
Deferred to later PRs:
- transcript/media ingestion, file uploads, and the completion charge
(so `COMPLETED` is never set here)
- repair/reconcile cron jobs
Also flips `DEFAULT_RECALL_REGION` to `eu-central-1` (separate commit)
to match the Recall account region.
---
.../internal/twenty-meeting-bot/README.md | 20 +
.../src/application-config.ts | 9 +-
...ook-logic-function-universal-identifier.ts | 2 +
.../__tests__/recall-webhook.test.ts | 152 ++++
.../constants/default-recall-region.ts | 2 +-
.../recall-webhook-secret-env-var-name.ts | 1 +
...is-call-recording-status-downgrade.test.ts | 37 +
...is-call-recording-status-downgrade.util.ts | 37 +
...atus-code-to-call-recording-status.util.ts | 26 +
.../__tests__/handle-recall-webhook.test.ts | 669 ++++++++++++++++++
.../flows/handle-recall-webhook.util.ts | 193 +++++
.../verify-recall-webhook-signature.test.ts | 122 ++++
.../normalize-recall-timestamp.util.ts | 14 +
.../parse-recall-webhook-event.util.ts | 98 +++
.../verify-recall-webhook-signature.util.ts | 109 +++
.../src/logic-functions/recall-webhook.ts | 87 +++
.../logic-functions/utils/as-record.util.ts | 6 +
.../utils/get-record-at-path.util.ts | 10 +
18 files changed, 1592 insertions(+), 2 deletions(-)
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-webhook-logic-function-universal-identifier.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/__tests__/recall-webhook.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-webhook-secret-env-var-name.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/verify-recall-webhook-signature.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/normalize-recall-timestamp.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/verify-recall-webhook-signature.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-webhook.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/as-record.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-record-at-path.util.ts
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/README.md b/packages/twenty-apps/internal/twenty-meeting-bot/README.md
index 4728d9ffa0..1c7028611d 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/README.md
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/README.md
@@ -15,6 +15,26 @@ Run `yarn twenty help` to list all available commands.
- `yarn twenty docker:start` - Start the local Twenty server
- `yarn test` - Run integration tests
+## Recall.ai configuration
+
+This app schedules Recall.ai meeting bots and ingests their lifecycle events. A server admin configures it through server variables on the application registration (Settings → Applications → Twenty Meeting Bot):
+
+| Server variable | Required | Purpose |
+| --- | --- | --- |
+| `RECALL_API_KEY` | Yes | Recall.ai API key for the configured region; used to schedule, update, and cancel bots. |
+| `RECALL_REGION` | No | Recall.ai region for API requests. Defaults to `eu-central-1`. |
+| `RECALL_WEBHOOK_SECRET` | Yes | Svix signing secret (`whsec_…`) used to verify incoming Recall webhooks. |
+
+### Configuring the webhook
+
+The app exposes an unauthenticated route, `POST /webhook/recall`, that verifies the Recall/Svix signature and updates the matching `CallRecording`'s lifecycle status (`JOINING` → `RECORDING` → `PROCESSING`, or `FAILED_UNKNOWN`).
+
+1. In the Recall.ai dashboard, create a webhook endpoint (Status Change Webhooks) pointing at the public URL of this app's `POST /webhook/recall` route.
+2. Copy the endpoint's signing secret — it starts with `whsec_`.
+3. Set it as the `RECALL_WEBHOOK_SECRET` server variable on the Twenty Meeting Bot application registration.
+
+The handler ignores out-of-order or duplicate deliveries (it never moves a recording's status backwards) and returns a non-2xx response on signature failures so Recall retries.
+
## Learn More
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/application-config.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/application-config.ts
index 514c3dacad..dc6c252918 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/application-config.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/application-config.ts
@@ -9,6 +9,7 @@ import { DEFAULT_RECALL_REGION } from 'src/logic-functions/constants/default-rec
import { RECALL_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-api-key-env-var-name';
import { RECALL_BOT_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-bot-name-env-var-name';
import { RECALL_REGION_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-region-env-var-name';
+import { RECALL_WEBHOOK_SECRET_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-webhook-secret-env-var-name';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
@@ -31,8 +32,14 @@ export default defineApplication({
isRequired: true,
},
[RECALL_REGION_ENV_VAR_NAME]: {
- description: `Recall.ai region used for API requests. Defaults to ${DEFAULT_RECALL_REGION} when unset. Asia Pacific Tokyo is ap-northeast-1.`,
+ description: `Recall.ai region used for API requests. Defaults to ${DEFAULT_RECALL_REGION} when unset. Europe Frankfurt is eu-central-1.`,
isSecret: false,
},
+ [RECALL_WEBHOOK_SECRET_ENV_VAR_NAME]: {
+ description:
+ 'Recall.ai webhook signing secret (whsec_...). Set by the server admin from the Recall webhook endpoint settings; used to verify the Svix signature of incoming Recall webhook deliveries.',
+ isSecret: true,
+ isRequired: true,
+ },
},
});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-webhook-logic-function-universal-identifier.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-webhook-logic-function-universal-identifier.ts
new file mode 100644
index 0000000000..98e5e559be
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-webhook-logic-function-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
+ '9215afe6-1497-4149-a49d-e608e239bbaf';
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/__tests__/recall-webhook.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/__tests__/recall-webhook.test.ts
new file mode 100644
index 0000000000..313c796d6a
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/__tests__/recall-webhook.test.ts
@@ -0,0 +1,152 @@
+import { createHmac } from 'crypto';
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { recallWebhookRouteHandler } from 'src/logic-functions/recall-webhook';
+
+const getApplicationVariableValueMock = vi.hoisted(() => vi.fn());
+const handleRecallWebhookMock = vi.hoisted(() => vi.fn());
+
+vi.mock(
+ 'src/logic-functions/utils/get-application-variable-value.util',
+ () => ({
+ getApplicationVariableValue: getApplicationVariableValueMock,
+ }),
+);
+
+vi.mock('src/logic-functions/flows/handle-recall-webhook.util', () => ({
+ handleRecallWebhook: handleRecallWebhookMock,
+}));
+
+vi.mock('twenty-client-sdk/core', () => ({
+ CoreApiClient: vi.fn(),
+}));
+
+const SECRET_BYTES = Buffer.from('entry-test-secret');
+const SECRET = `whsec_${SECRET_BYTES.toString('base64')}`;
+
+type RecallWebhookRoutePayload = Parameters<
+ typeof recallWebhookRouteHandler
+>[0];
+
+const buildRoutePayload = (
+ overrides: Partial,
+): RecallWebhookRoutePayload =>
+ ({
+ headers: {},
+ ...overrides,
+ }) as RecallWebhookRoutePayload;
+
+const buildSignedHeaders = (rawBody: string): Record => {
+ const webhookId = 'msg_entry_test';
+ const webhookTimestamp = Math.floor(Date.now() / 1000).toString();
+ const signature = createHmac('sha256', SECRET_BYTES)
+ .update(`${webhookId}.${webhookTimestamp}.${rawBody}`)
+ .digest('base64');
+
+ return {
+ 'webhook-id': webhookId,
+ 'webhook-timestamp': webhookTimestamp,
+ 'webhook-signature': `v1,${signature}`,
+ };
+};
+
+describe('recallWebhookRouteHandler', () => {
+ beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ getApplicationVariableValueMock.mockReset();
+ getApplicationVariableValueMock.mockReturnValue(SECRET);
+ handleRecallWebhookMock.mockReset();
+ handleRecallWebhookMock.mockResolvedValue({ status: 'updated' });
+ });
+
+ it('responds 500 when the webhook secret is not configured', async () => {
+ getApplicationVariableValueMock.mockReturnValue(undefined);
+
+ const result = await recallWebhookRouteHandler(
+ buildRoutePayload({ rawBody: '{}', body: {} }),
+ );
+
+ expect(result).toMatchObject({
+ __twentyHttpResponse: true,
+ status: 500,
+ body: {
+ error: expect.stringContaining('RECALL_WEBHOOK_SECRET'),
+ },
+ });
+ });
+
+ it('responds 500 when the raw body is not forwarded', async () => {
+ const result = await recallWebhookRouteHandler(
+ buildRoutePayload({ body: {} }),
+ );
+
+ expect(result).toMatchObject({
+ __twentyHttpResponse: true,
+ status: 500,
+ body: {
+ error: expect.stringContaining('Raw request body'),
+ },
+ });
+ });
+
+ it('responds 401 when the signature is invalid', async () => {
+ const result = await recallWebhookRouteHandler(
+ buildRoutePayload({
+ rawBody: '{}',
+ body: {},
+ headers: {
+ 'webhook-id': 'msg_entry_test',
+ 'webhook-timestamp': Math.floor(Date.now() / 1000).toString(),
+ 'webhook-signature': 'v1,not-a-real-signature',
+ },
+ }),
+ );
+
+ expect(result).toMatchObject({
+ __twentyHttpResponse: true,
+ status: 401,
+ body: {
+ error: expect.stringContaining('Invalid webhook signature'),
+ },
+ });
+ });
+
+ it('responds 400 when a correctly signed payload is empty', async () => {
+ const rawBody = 'null';
+
+ const result = await recallWebhookRouteHandler(
+ buildRoutePayload({
+ rawBody,
+ body: null,
+ headers: buildSignedHeaders(rawBody),
+ }),
+ );
+
+ expect(result).toMatchObject({
+ __twentyHttpResponse: true,
+ status: 400,
+ body: {
+ error: 'Webhook payload was empty',
+ },
+ });
+ });
+
+ it('dispatches a correctly signed payload to the handler', async () => {
+ const rawBody = JSON.stringify({ event: 'recording.done' });
+
+ const result = await recallWebhookRouteHandler(
+ buildRoutePayload({
+ rawBody,
+ body: { event: 'recording.done' },
+ headers: buildSignedHeaders(rawBody),
+ }),
+ );
+
+ expect(handleRecallWebhookMock).toHaveBeenCalledTimes(1);
+ expect(handleRecallWebhookMock).toHaveBeenCalledWith(
+ expect.objectContaining({ body: { event: 'recording.done' } }),
+ );
+ expect(result).toEqual({ status: 'updated' });
+ });
+});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-region.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-region.ts
index dd19a5e7a3..fdafca5053 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-region.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-region.ts
@@ -1 +1 @@
-export const DEFAULT_RECALL_REGION = 'ap-northeast-1';
+export const DEFAULT_RECALL_REGION = 'eu-central-1';
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-webhook-secret-env-var-name.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-webhook-secret-env-var-name.ts
new file mode 100644
index 0000000000..520dfd2ff1
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-webhook-secret-env-var-name.ts
@@ -0,0 +1 @@
+export const RECALL_WEBHOOK_SECRET_ENV_VAR_NAME = 'RECALL_WEBHOOK_SECRET';
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts
new file mode 100644
index 0000000000..8d43cedad9
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+
+import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util';
+
+describe('isCallRecordingStatusDowngrade', () => {
+ it.each([
+ ['SCHEDULED', 'JOINING', false],
+ ['JOINING', 'RECORDING', false],
+ ['RECORDING', 'PROCESSING', false],
+ ['PROCESSING', 'FAILED_UNKNOWN', false],
+ ['PROCESSING', 'COMPLETED', false],
+ ['RECORDING', 'RECORDING', false],
+ ['COMPLETED', 'RECORDING', true],
+ ['PROCESSING', 'JOINING', true],
+ ['FAILED_UNKNOWN', 'RECORDING', true],
+ ['JOINING', 'SCHEDULED', true],
+ ])('from %s to %s -> %s', (fromStatus, toStatus, expected) => {
+ expect(isCallRecordingStatusDowngrade({ fromStatus, toStatus })).toBe(
+ expected,
+ );
+ });
+
+ it('never treats transitions from unknown statuses as downgrades', () => {
+ expect(
+ isCallRecordingStatusDowngrade({
+ fromStatus: undefined,
+ toStatus: 'COMPLETED',
+ }),
+ ).toBe(false);
+ expect(
+ isCallRecordingStatusDowngrade({
+ fromStatus: 'NOT_A_STATUS',
+ toStatus: 'SCHEDULED',
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts
new file mode 100644
index 0000000000..ad2371f4c9
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts
@@ -0,0 +1,37 @@
+import { isUndefined } from '@sniptt/guards';
+
+import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
+
+// Deliveries are unordered; a late event must never move status backwards.
+const CALL_RECORDING_STATUS_PROGRESSION: Record = {
+ [CallRecordingStatus.SCHEDULED]: 0,
+ [CallRecordingStatus.JOINING]: 1,
+ [CallRecordingStatus.RECORDING]: 2,
+ [CallRecordingStatus.PROCESSING]: 3,
+ [CallRecordingStatus.FAILED_UNKNOWN]: 4,
+ [CallRecordingStatus.COMPLETED]: 5,
+};
+
+const getCallRecordingStatusRank = (status: string): number | undefined =>
+ status in CALL_RECORDING_STATUS_PROGRESSION
+ ? CALL_RECORDING_STATUS_PROGRESSION[status as CallRecordingStatus]
+ : undefined;
+
+export const isCallRecordingStatusDowngrade = ({
+ fromStatus,
+ toStatus,
+}: {
+ fromStatus: string | undefined;
+ toStatus: string;
+}): boolean => {
+ const fromRank = isUndefined(fromStatus)
+ ? undefined
+ : getCallRecordingStatusRank(fromStatus);
+ const toRank = getCallRecordingStatusRank(toStatus);
+
+ if (isUndefined(fromRank) || isUndefined(toRank)) {
+ return false;
+ }
+
+ return toRank < fromRank;
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts
new file mode 100644
index 0000000000..ab3448e811
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts
@@ -0,0 +1,26 @@
+import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
+
+export const mapRecallStatusCodeToCallRecordingStatus = (
+ statusCode: string | undefined,
+): CallRecordingStatus | undefined => {
+ switch (statusCode) {
+ case 'joining_call':
+ case 'in_waiting_room':
+ return CallRecordingStatus.JOINING;
+ case 'in_call_not_recording':
+ case 'recording_permission_allowed':
+ case 'in_call_recording':
+ return CallRecordingStatus.RECORDING;
+ // 'done' stays PROCESSING: COMPLETED is set only after all artifacts are ingested.
+ case 'call_ended':
+ case 'analysis_done':
+ case 'done':
+ return CallRecordingStatus.PROCESSING;
+ case 'fatal':
+ case 'analysis_failed':
+ case 'recording_permission_denied':
+ return CallRecordingStatus.FAILED_UNKNOWN;
+ default:
+ return undefined;
+ }
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts
new file mode 100644
index 0000000000..8b9d7148d2
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts
@@ -0,0 +1,669 @@
+import { type CoreApiClient } from 'twenty-client-sdk/core';
+import { describe, expect, it } from 'vitest';
+
+import { handleRecallWebhook } from 'src/logic-functions/flows/handle-recall-webhook.util';
+
+type CallRecordingNode = {
+ id: string;
+ status?: string | null;
+ externalBotId?: string | null;
+ externalRecordingId?: string | null;
+ startedAt?: string | null;
+ endedAt?: string | null;
+};
+
+class FakeCoreApiClient {
+ callRecordings: CallRecordingNode[];
+ mutations: Array<{ id: string; data: Record }> = [];
+
+ constructor(callRecordings: CallRecordingNode[]) {
+ this.callRecordings = callRecordings;
+ }
+
+ async query(query: any): Promise {
+ if (query.callRecordings !== undefined) {
+ const filter = query.callRecordings.__args.filter;
+
+ return {
+ callRecordings: {
+ edges: this.filterCallRecordings(filter).map((callRecording) => ({
+ node: callRecording,
+ })),
+ },
+ };
+ }
+
+ throw new Error(`Unhandled query: ${JSON.stringify(query)}`);
+ }
+
+ async mutation(mutation: any): Promise {
+ if (mutation.updateCallRecording !== undefined) {
+ const { id, data } = mutation.updateCallRecording.__args;
+
+ this.mutations.push({ id, data });
+
+ return {
+ updateCallRecording: {
+ id,
+ },
+ };
+ }
+
+ throw new Error(`Unhandled mutation: ${JSON.stringify(mutation)}`);
+ }
+
+ private filterCallRecordings(filter: any): CallRecordingNode[] {
+ if (filter.id?.eq !== undefined) {
+ return this.callRecordings.filter(
+ (callRecording) => callRecording.id === filter.id.eq,
+ );
+ }
+
+ if (filter.externalBotId?.eq !== undefined) {
+ return this.callRecordings.filter(
+ (callRecording) =>
+ callRecording.externalBotId === filter.externalBotId.eq,
+ );
+ }
+
+ throw new Error(
+ `Unhandled call recording filter: ${JSON.stringify(filter)}`,
+ );
+ }
+}
+
+describe('handleRecallWebhook', () => {
+ it('updates a call recording from bot metadata on status change events', async () => {
+ const client = new FakeCoreApiClient([
+ {
+ id: 'call-recording-1',
+ status: 'JOINING',
+ externalBotId: 'recall-bot-1',
+ },
+ ]);
+
+ const result = await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'bot.status_change',
+ data: {
+ bot: {
+ id: 'recall-bot-1',
+ metadata: {
+ twentyCallRecordingId: 'call-recording-1',
+ },
+ },
+ status: {
+ code: 'in_call_recording',
+ },
+ },
+ },
+ });
+
+ expect(result).toEqual({
+ status: 'updated',
+ event: 'bot.status_change',
+ callRecordingId: 'call-recording-1',
+ callRecordingStatus: 'RECORDING',
+ });
+ expect(client.mutations).toEqual([
+ {
+ id: 'call-recording-1',
+ data: {
+ status: 'RECORDING',
+ externalBotId: 'recall-bot-1',
+ },
+ },
+ ]);
+ });
+
+ it('reads bot metadata nested under data when a top-level bot has none', async () => {
+ const client = new FakeCoreApiClient([
+ {
+ id: 'call-recording-1',
+ status: 'JOINING',
+ externalBotId: 'recall-bot-1',
+ },
+ ]);
+
+ const result = await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'bot.status_change',
+ bot: {
+ id: 'recall-bot-1',
+ },
+ data: {
+ bot: {
+ id: 'recall-bot-1',
+ metadata: {
+ twentyCallRecordingId: 'call-recording-1',
+ },
+ },
+ status: {
+ code: 'in_call_recording',
+ },
+ },
+ },
+ });
+
+ expect(result).toEqual({
+ status: 'updated',
+ event: 'bot.status_change',
+ callRecordingId: 'call-recording-1',
+ callRecordingStatus: 'RECORDING',
+ });
+ });
+
+ it('matches by metadata id when the recording carries no external bot id', async () => {
+ const client = new FakeCoreApiClient([
+ {
+ id: 'call-recording-1',
+ status: 'SCHEDULED',
+ externalBotId: null,
+ },
+ ]);
+
+ const result = await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'bot.status_change',
+ data: {
+ bot: {
+ id: 'recall-bot-1',
+ metadata: {
+ twentyCallRecordingId: 'call-recording-1',
+ },
+ },
+ status: {
+ code: 'in_call_recording',
+ },
+ },
+ },
+ });
+
+ expect(result).toEqual({
+ status: 'updated',
+ event: 'bot.status_change',
+ callRecordingId: 'call-recording-1',
+ callRecordingStatus: 'RECORDING',
+ });
+ expect(client.mutations).toEqual([
+ {
+ id: 'call-recording-1',
+ data: {
+ status: 'RECORDING',
+ externalBotId: 'recall-bot-1',
+ },
+ },
+ ]);
+ });
+
+ it('prefers the metadata id over a different recording carrying the bot id', async () => {
+ const client = new FakeCoreApiClient([
+ {
+ id: 'call-recording-stale',
+ status: 'SCHEDULED',
+ externalBotId: 'recall-bot-1',
+ },
+ {
+ id: 'call-recording-current',
+ status: 'SCHEDULED',
+ externalBotId: null,
+ },
+ ]);
+
+ const result = await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'bot.status_change',
+ data: {
+ bot: {
+ id: 'recall-bot-1',
+ metadata: {
+ twentyCallRecordingId: 'call-recording-current',
+ },
+ },
+ status: {
+ code: 'in_call_recording',
+ },
+ },
+ },
+ });
+
+ expect(result).toEqual({
+ status: 'updated',
+ event: 'bot.status_change',
+ callRecordingId: 'call-recording-current',
+ callRecordingStatus: 'RECORDING',
+ });
+ expect(client.mutations).toEqual([
+ {
+ id: 'call-recording-current',
+ data: {
+ status: 'RECORDING',
+ externalBotId: 'recall-bot-1',
+ },
+ },
+ ]);
+ });
+
+ it('falls back to external bot id matching when metadata is absent', async () => {
+ const client = new FakeCoreApiClient([
+ {
+ id: 'call-recording-1',
+ status: 'PROCESSING',
+ externalBotId: 'recall-bot-1',
+ },
+ ]);
+
+ const result = await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'recording.done',
+ data: {
+ bot_id: 'recall-bot-1',
+ recording: {
+ id: 'recall-recording-1',
+ },
+ },
+ },
+ });
+
+ expect(result).toEqual({
+ status: 'updated',
+ event: 'recording.done',
+ callRecordingId: 'call-recording-1',
+ callRecordingStatus: 'PROCESSING',
+ });
+ expect(client.mutations).toEqual([
+ {
+ id: 'call-recording-1',
+ data: {
+ status: 'PROCESSING',
+ externalBotId: 'recall-bot-1',
+ externalRecordingId: 'recall-recording-1',
+ },
+ },
+ ]);
+ });
+
+ it('fills startedAt from the status timestamp when the bot starts recording', async () => {
+ const client = new FakeCoreApiClient([
+ {
+ id: 'call-recording-1',
+ status: 'JOINING',
+ externalBotId: 'recall-bot-1',
+ },
+ ]);
+
+ const result = await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'bot.status_change',
+ data: {
+ bot: {
+ id: 'recall-bot-1',
+ metadata: {
+ twentyCallRecordingId: 'call-recording-1',
+ },
+ },
+ status: {
+ code: 'in_call_recording',
+ created_at: '2026-01-01T13:02:00.000Z',
+ },
+ },
+ },
+ });
+
+ expect(result).toEqual({
+ status: 'updated',
+ event: 'bot.status_change',
+ callRecordingId: 'call-recording-1',
+ callRecordingStatus: 'RECORDING',
+ });
+ expect(client.mutations).toEqual([
+ {
+ id: 'call-recording-1',
+ data: {
+ status: 'RECORDING',
+ externalBotId: 'recall-bot-1',
+ startedAt: '2026-01-01T13:02:00.000Z',
+ },
+ },
+ ]);
+ });
+
+ it('fills endedAt from the status timestamp when the recording is done', async () => {
+ const client = new FakeCoreApiClient([
+ {
+ id: 'call-recording-1',
+ status: 'PROCESSING',
+ externalBotId: 'recall-bot-1',
+ startedAt: '2026-01-01T13:02:00.000Z',
+ },
+ ]);
+
+ const result = await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'bot.status_change',
+ data: {
+ bot: {
+ id: 'recall-bot-1',
+ metadata: {
+ twentyCallRecordingId: 'call-recording-1',
+ },
+ },
+ status: {
+ code: 'done',
+ created_at: '2026-01-01T14:05:00.000Z',
+ },
+ },
+ },
+ });
+
+ expect(result).toEqual({
+ status: 'updated',
+ event: 'bot.status_change',
+ callRecordingId: 'call-recording-1',
+ callRecordingStatus: 'PROCESSING',
+ });
+ expect(client.mutations).toEqual([
+ {
+ id: 'call-recording-1',
+ data: {
+ status: 'PROCESSING',
+ externalBotId: 'recall-bot-1',
+ endedAt: '2026-01-01T14:05:00.000Z',
+ },
+ },
+ ]);
+ });
+
+ it('normalizes microsecond-precision Recall timestamps before writing them', async () => {
+ const client = new FakeCoreApiClient([
+ {
+ id: 'call-recording-1',
+ status: 'PROCESSING',
+ externalBotId: 'recall-bot-1',
+ startedAt: '2026-06-10T11:02:00.000Z',
+ },
+ ]);
+
+ await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'bot.status_change',
+ data: {
+ bot: {
+ id: 'recall-bot-1',
+ metadata: {
+ twentyCallRecordingId: 'call-recording-1',
+ },
+ },
+ status: {
+ code: 'done',
+ created_at: '2026-06-10T12:17:28.281597+00:00',
+ },
+ },
+ },
+ });
+
+ expect(client.mutations).toEqual([
+ {
+ id: 'call-recording-1',
+ data: {
+ status: 'PROCESSING',
+ externalBotId: 'recall-bot-1',
+ endedAt: '2026-06-10T12:17:28.281Z',
+ },
+ },
+ ]);
+ });
+
+ it('does not overwrite an already-set startedAt on a redelivered recording event', async () => {
+ const client = new FakeCoreApiClient([
+ {
+ id: 'call-recording-1',
+ status: 'RECORDING',
+ externalBotId: 'recall-bot-1',
+ startedAt: '2026-01-01T13:02:00.000Z',
+ },
+ ]);
+
+ await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'bot.status_change',
+ data: {
+ bot: {
+ id: 'recall-bot-1',
+ metadata: {
+ twentyCallRecordingId: 'call-recording-1',
+ },
+ },
+ status: {
+ code: 'in_call_recording',
+ created_at: '2026-01-01T13:09:00.000Z',
+ },
+ },
+ },
+ });
+
+ expect(client.mutations).toEqual([
+ {
+ id: 'call-recording-1',
+ data: {
+ status: 'RECORDING',
+ externalBotId: 'recall-bot-1',
+ },
+ },
+ ]);
+ });
+
+ it('does not overwrite an already-set endedAt on a redelivered done event', async () => {
+ const client = new FakeCoreApiClient([
+ {
+ id: 'call-recording-1',
+ status: 'PROCESSING',
+ externalBotId: 'recall-bot-1',
+ startedAt: '2026-01-01T13:02:00.000Z',
+ endedAt: '2026-01-01T14:05:00.000Z',
+ },
+ ]);
+
+ await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'bot.status_change',
+ data: {
+ bot: {
+ id: 'recall-bot-1',
+ metadata: {
+ twentyCallRecordingId: 'call-recording-1',
+ },
+ },
+ status: {
+ code: 'done',
+ created_at: '2026-01-01T14:11:00.000Z',
+ },
+ },
+ },
+ });
+
+ expect(client.mutations).toEqual([
+ {
+ id: 'call-recording-1',
+ data: {
+ status: 'PROCESSING',
+ externalBotId: 'recall-bot-1',
+ },
+ },
+ ]);
+ });
+
+ it('maps a fatal bot status to FAILED_UNKNOWN', async () => {
+ const client = new FakeCoreApiClient([
+ {
+ id: 'call-recording-1',
+ status: 'RECORDING',
+ externalBotId: 'recall-bot-1',
+ },
+ ]);
+
+ const result = await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'bot.status_change',
+ data: {
+ bot: {
+ id: 'recall-bot-1',
+ metadata: {
+ twentyCallRecordingId: 'call-recording-1',
+ },
+ },
+ status: {
+ code: 'fatal',
+ },
+ },
+ },
+ });
+
+ expect(result).toEqual({
+ status: 'updated',
+ event: 'bot.status_change',
+ callRecordingId: 'call-recording-1',
+ callRecordingStatus: 'FAILED_UNKNOWN',
+ });
+ expect(client.mutations).toEqual([
+ {
+ id: 'call-recording-1',
+ data: {
+ status: 'FAILED_UNKNOWN',
+ externalBotId: 'recall-bot-1',
+ },
+ },
+ ]);
+ });
+
+ it('skips a late done event once the recording is COMPLETED', async () => {
+ const client = new FakeCoreApiClient([
+ {
+ id: 'call-recording-1',
+ status: 'COMPLETED',
+ externalBotId: 'recall-bot-1',
+ startedAt: '2026-01-01T13:02:00.000Z',
+ endedAt: '2026-01-01T14:05:00.000Z',
+ },
+ ]);
+
+ const result = await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'bot.status_change',
+ data: {
+ bot: {
+ id: 'recall-bot-1',
+ metadata: {
+ twentyCallRecordingId: 'call-recording-1',
+ },
+ },
+ status: {
+ code: 'done',
+ created_at: '2026-01-01T14:11:00.000Z',
+ },
+ },
+ },
+ });
+
+ expect(result).toEqual({
+ status: 'skipped',
+ event: 'bot.status_change',
+ reason: 'stale status event (COMPLETED -> PROCESSING)',
+ });
+ expect(client.mutations).toEqual([]);
+ });
+
+ it('skips out-of-order events that would move the status backwards', async () => {
+ const client = new FakeCoreApiClient([
+ {
+ id: 'call-recording-1',
+ status: 'COMPLETED',
+ externalBotId: 'recall-bot-1',
+ },
+ ]);
+
+ const result = await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'bot.status_change',
+ data: {
+ bot: {
+ id: 'recall-bot-1',
+ metadata: {
+ twentyCallRecordingId: 'call-recording-1',
+ },
+ },
+ status: {
+ code: 'in_call_recording',
+ },
+ },
+ },
+ });
+
+ expect(result).toEqual({
+ status: 'skipped',
+ event: 'bot.status_change',
+ reason: 'stale status event (COMPLETED -> RECORDING)',
+ });
+ expect(client.mutations).toEqual([]);
+ });
+
+ it('skips events whose metadata points at a missing call recording', async () => {
+ const client = new FakeCoreApiClient([]);
+
+ const result = await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'bot.status_change',
+ data: {
+ bot: {
+ metadata: {
+ twentyCallRecordingId: 'call-recording-deleted',
+ },
+ },
+ status: {
+ code: 'in_call_recording',
+ },
+ },
+ },
+ });
+
+ expect(result).toEqual({
+ status: 'skipped',
+ event: 'bot.status_change',
+ reason: 'no matching call recording',
+ });
+ expect(client.mutations).toEqual([]);
+ });
+
+ it('skips unsupported events', async () => {
+ const client = new FakeCoreApiClient([]);
+
+ const result = await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'participant_events.done',
+ data: {},
+ },
+ });
+
+ expect(result).toEqual({
+ status: 'skipped',
+ event: 'participant_events.done',
+ reason: 'unsupported Recall event status participant_events.done',
+ });
+ expect(client.mutations).toEqual([]);
+ });
+});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts
new file mode 100644
index 0000000000..99992f9cb8
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts
@@ -0,0 +1,193 @@
+import { isUndefined } from '@sniptt/guards';
+import { CoreApiClient } from 'twenty-client-sdk/core';
+
+import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
+import { findCallRecordingsByFilter } from 'src/logic-functions/data/find-call-recordings-by-filter.util';
+import {
+ updateCallRecording,
+ type CallRecordingUpdateFields,
+} from 'src/logic-functions/data/update-call-recording.util';
+import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util';
+import { mapRecallStatusCodeToCallRecordingStatus } from 'src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util';
+import {
+ parseRecallWebhookEvent,
+ type RecallWebhookBody,
+ type RecallWebhookEvent,
+} from 'src/logic-functions/recall-api/parse-recall-webhook-event.util';
+import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type';
+
+type RecallWebhookHandlerResult =
+ | {
+ status: 'updated';
+ callRecordingId: string;
+ event: string;
+ callRecordingStatus: string;
+ }
+ | {
+ status: 'skipped';
+ event: string | null;
+ reason: string;
+ };
+
+export const handleRecallWebhook = async ({
+ client,
+ body,
+}: {
+ client: CoreApiClient;
+ body: RecallWebhookBody;
+}): Promise => {
+ const webhookEvent = parseRecallWebhookEvent(body);
+
+ if (isUndefined(webhookEvent)) {
+ return {
+ status: 'skipped',
+ event: null,
+ reason: 'missing event type',
+ };
+ }
+
+ const { event, statusCode } = webhookEvent;
+ const callRecordingStatus = mapRecallEventToCallRecordingStatus({
+ event,
+ statusCode,
+ });
+
+ if (isUndefined(callRecordingStatus)) {
+ return {
+ status: 'skipped',
+ event,
+ reason: `unsupported Recall event status ${statusCode ?? event}`,
+ };
+ }
+
+ const callRecording = await findMatchingCallRecording({
+ client,
+ webhookEvent,
+ });
+
+ if (isUndefined(callRecording)) {
+ return {
+ status: 'skipped',
+ event,
+ reason: 'no matching call recording',
+ };
+ }
+
+ if (
+ isCallRecordingStatusDowngrade({
+ fromStatus: callRecording.status,
+ toStatus: callRecordingStatus,
+ })
+ ) {
+ return {
+ status: 'skipped',
+ event,
+ reason: `stale status event (${callRecording.status} -> ${callRecordingStatus})`,
+ };
+ }
+
+ const updateData: CallRecordingUpdateFields = {
+ status: callRecordingStatus,
+ ...(isUndefined(webhookEvent.externalBotId)
+ ? {}
+ : { externalBotId: webhookEvent.externalBotId }),
+ ...buildExternalRecordingIdUpdate(webhookEvent),
+ ...buildRecordingTimestampsUpdate({ webhookEvent, callRecording }),
+ };
+
+ await updateCallRecording(client, {
+ id: callRecording.id,
+ data: updateData,
+ });
+
+ return {
+ status: 'updated',
+ event,
+ callRecordingId: callRecording.id,
+ callRecordingStatus,
+ };
+};
+
+const findMatchingCallRecording = async ({
+ client,
+ webhookEvent,
+}: {
+ client: CoreApiClient;
+ webhookEvent: RecallWebhookEvent;
+}): Promise => {
+ if (!isUndefined(webhookEvent.callRecordingIdFromMetadata)) {
+ const [callRecording] = await findCallRecordingsByFilter(client, {
+ id: { eq: webhookEvent.callRecordingIdFromMetadata },
+ });
+
+ return callRecording;
+ }
+
+ if (isUndefined(webhookEvent.externalBotId)) {
+ return undefined;
+ }
+
+ const [callRecording] = await findCallRecordingsByFilter(client, {
+ externalBotId: { eq: webhookEvent.externalBotId },
+ });
+
+ return callRecording;
+};
+
+const mapRecallEventToCallRecordingStatus = ({
+ event,
+ statusCode,
+}: {
+ event: string;
+ statusCode: string | undefined;
+}): CallRecordingStatus | undefined => {
+ if (event === 'recording.done') {
+ return CallRecordingStatus.PROCESSING;
+ }
+
+ if (event === 'recording.failed') {
+ return CallRecordingStatus.FAILED_UNKNOWN;
+ }
+
+ return mapRecallStatusCodeToCallRecordingStatus(statusCode);
+};
+
+// Never overwrite an already-set actual time; redeliveries must stay idempotent.
+const buildRecordingTimestampsUpdate = ({
+ webhookEvent,
+ callRecording,
+}: {
+ webhookEvent: RecallWebhookEvent;
+ callRecording: CallRecordingRecord;
+}): { startedAt?: string; endedAt?: string } => {
+ const { event, statusCode, statusTimestamp } = webhookEvent;
+
+ const impliesRecordingStarted = statusCode === 'in_call_recording';
+ const impliesRecordingEnded =
+ event === 'recording.done' ||
+ statusCode === 'call_ended' ||
+ statusCode === 'done';
+
+ const startedAt =
+ webhookEvent.recordingStartedAt ??
+ (impliesRecordingStarted ? statusTimestamp : undefined);
+ const endedAt =
+ webhookEvent.recordingEndedAt ??
+ (impliesRecordingEnded ? statusTimestamp : undefined);
+
+ return {
+ ...(!isUndefined(startedAt) && isUndefined(callRecording.startedAt)
+ ? { startedAt }
+ : {}),
+ ...(!isUndefined(endedAt) && isUndefined(callRecording.endedAt)
+ ? { endedAt }
+ : {}),
+ };
+};
+
+const buildExternalRecordingIdUpdate = (
+ webhookEvent: RecallWebhookEvent,
+): { externalRecordingId?: string } =>
+ isUndefined(webhookEvent.externalRecordingId)
+ ? {}
+ : { externalRecordingId: webhookEvent.externalRecordingId };
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/verify-recall-webhook-signature.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/verify-recall-webhook-signature.test.ts
new file mode 100644
index 0000000000..b70c0fb2cd
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/verify-recall-webhook-signature.test.ts
@@ -0,0 +1,122 @@
+import { createHmac } from 'crypto';
+
+import { describe, expect, it } from 'vitest';
+
+import { verifyRecallWebhookSignature } from 'src/logic-functions/recall-api/verify-recall-webhook-signature.util';
+
+const SECRET_BYTES = Buffer.from('test-secret-abc123');
+const SECRET = `whsec_${SECRET_BYTES.toString('base64')}`;
+const WEBHOOK_ID = 'msg_123';
+const WEBHOOK_TIMESTAMP = '1760000000';
+const NOW = new Date(Number(WEBHOOK_TIMESTAMP) * 1000);
+
+const sign = (body: string): string =>
+ createHmac('sha256', SECRET_BYTES)
+ .update(`${WEBHOOK_ID}.${WEBHOOK_TIMESTAMP}.${body}`)
+ .digest('base64');
+
+describe('verifyRecallWebhookSignature', () => {
+ it('accepts valid Recall webhook-* signature headers', () => {
+ const body = JSON.stringify({ event: 'recording.done' });
+
+ const result = verifyRecallWebhookSignature({
+ rawBody: body,
+ secret: SECRET,
+ now: NOW,
+ headers: {
+ 'webhook-id': WEBHOOK_ID,
+ 'webhook-timestamp': WEBHOOK_TIMESTAMP,
+ 'webhook-signature': `v1,${sign(body)}`,
+ },
+ });
+
+ expect(result).toEqual({ valid: true });
+ });
+
+ it('accepts valid svix-* signature headers', () => {
+ const body = JSON.stringify({ event: 'recording.done' });
+
+ const result = verifyRecallWebhookSignature({
+ rawBody: body,
+ secret: SECRET,
+ now: NOW,
+ headers: {
+ 'svix-id': WEBHOOK_ID,
+ 'svix-timestamp': WEBHOOK_TIMESTAMP,
+ 'svix-signature': `v1,${sign(body)}`,
+ },
+ });
+
+ expect(result).toEqual({ valid: true });
+ });
+
+ it('rejects deliveries whose timestamp is outside of the tolerance', () => {
+ const body = JSON.stringify({ event: 'recording.done' });
+
+ const result = verifyRecallWebhookSignature({
+ rawBody: body,
+ secret: SECRET,
+ now: new Date(Number(WEBHOOK_TIMESTAMP) * 1000 + 6 * 60 * 1000),
+ headers: {
+ 'webhook-id': WEBHOOK_ID,
+ 'webhook-timestamp': WEBHOOK_TIMESTAMP,
+ 'webhook-signature': `v1,${sign(body)}`,
+ },
+ });
+
+ expect(result).toEqual({
+ valid: false,
+ error: 'Webhook timestamp is outside of the allowed tolerance',
+ });
+ });
+
+ it('rejects non-numeric timestamps', () => {
+ const body = JSON.stringify({ event: 'recording.done' });
+
+ const result = verifyRecallWebhookSignature({
+ rawBody: body,
+ secret: SECRET,
+ now: NOW,
+ headers: {
+ 'webhook-id': WEBHOOK_ID,
+ 'webhook-timestamp': 'not-a-timestamp',
+ 'webhook-signature': `v1,${sign(body)}`,
+ },
+ });
+
+ expect(result).toEqual({
+ valid: false,
+ error: 'Invalid webhook timestamp',
+ });
+ });
+
+ it('rejects missing signature headers', () => {
+ const result = verifyRecallWebhookSignature({
+ rawBody: '{}',
+ secret: SECRET,
+ headers: {},
+ });
+
+ expect(result).toEqual({
+ valid: false,
+ error: 'Missing webhook signature headers',
+ });
+ });
+
+ it('rejects signatures computed from a different body', () => {
+ const body = JSON.stringify({ event: 'recording.done' });
+
+ const result = verifyRecallWebhookSignature({
+ rawBody: JSON.stringify({ event: 'recording.failed' }),
+ secret: SECRET,
+ now: NOW,
+ headers: {
+ 'webhook-id': WEBHOOK_ID,
+ 'webhook-timestamp': WEBHOOK_TIMESTAMP,
+ 'webhook-signature': `v1,${sign(body)}`,
+ },
+ });
+
+ expect(result.valid).toBe(false);
+ });
+});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/normalize-recall-timestamp.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/normalize-recall-timestamp.util.ts
new file mode 100644
index 0000000000..1c2dd44cde
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/normalize-recall-timestamp.util.ts
@@ -0,0 +1,14 @@
+import { isUndefined } from '@sniptt/guards';
+
+// Twenty rejects Recall's microsecond precision; truncate to millisecond ISO.
+export const normalizeRecallTimestamp = (
+ value: string | undefined,
+): string | undefined => {
+ if (isUndefined(value)) {
+ return undefined;
+ }
+
+ const parsed = new Date(value);
+
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts
new file mode 100644
index 0000000000..1f339350c8
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts
@@ -0,0 +1,98 @@
+import { isUndefined } from '@sniptt/guards';
+
+import { asRecord } from 'src/logic-functions/utils/as-record.util';
+import { getRecordAtPath } from 'src/logic-functions/utils/get-record-at-path.util';
+import { getString } from 'src/logic-functions/utils/get-string.util';
+import { normalizeRecallTimestamp } from 'src/logic-functions/recall-api/normalize-recall-timestamp.util';
+
+export type RecallWebhookBody = {
+ event?: unknown;
+ type?: unknown;
+ data?: unknown;
+ bot?: unknown;
+};
+
+export type RecallWebhookEvent = {
+ event: string;
+ statusCode: string | undefined;
+ statusTimestamp: string | undefined;
+ externalBotId: string | undefined;
+ externalRecordingId: string | undefined;
+ callRecordingIdFromMetadata: string | undefined;
+ recordingStartedAt: string | undefined;
+ recordingEndedAt: string | undefined;
+};
+
+// The only reader of raw webhook payloads; Recall delivers several body shapes per event family.
+export const parseRecallWebhookEvent = (
+ body: RecallWebhookBody,
+): RecallWebhookEvent | undefined => {
+ const event = getString(body.event) ?? getString(body.type);
+
+ if (isUndefined(event)) {
+ return undefined;
+ }
+
+ const data = asRecord(body.data);
+ const bot = asRecord(body.bot);
+
+ return {
+ event,
+ statusCode:
+ getString(getRecordAtPath(data, ['status', 'code'])) ??
+ getString(getRecordAtPath(data, ['data', 'code'])) ??
+ getString(getRecordAtPath(bot, ['status', 'code'])) ??
+ getStatusCodeFromEventName(event),
+ statusTimestamp: normalizeRecallTimestamp(
+ getString(getRecordAtPath(data, ['status', 'created_at'])) ??
+ getString(getRecordAtPath(data, ['data', 'updated_at'])) ??
+ getString(getRecordAtPath(bot, ['status', 'created_at'])),
+ ),
+ externalBotId:
+ getString(data?.bot_id) ??
+ getString(getRecordAtPath(data, ['bot', 'id'])) ??
+ getString(getRecordAtPath(data, ['recording', 'bot_id'])) ??
+ getString(getRecordAtPath(data, ['recording', 'bot', 'id'])) ??
+ getString(bot?.id),
+ externalRecordingId:
+ getString(getRecordAtPath(data, ['status', 'recording_id'])) ??
+ getString(getRecordAtPath(data, ['recording', 'id'])) ??
+ getString(data?.recording_id),
+ callRecordingIdFromMetadata: extractCallRecordingIdFromMetadata({
+ data,
+ bot,
+ }),
+ recordingStartedAt: normalizeRecallTimestamp(
+ getString(getRecordAtPath(data, ['recording', 'started_at'])),
+ ),
+ recordingEndedAt: normalizeRecallTimestamp(
+ getString(getRecordAtPath(data, ['recording', 'completed_at'])),
+ ),
+ };
+};
+
+const getStatusCodeFromEventName = (event: string): string | undefined => {
+ if (!event.startsWith('bot.')) {
+ return undefined;
+ }
+
+ const statusCode = event.slice('bot.'.length);
+
+ return statusCode === 'status_change' ? undefined : statusCode;
+};
+
+const extractCallRecordingIdFromMetadata = ({
+ data,
+ bot,
+}: {
+ data: Record | undefined;
+ bot: Record | undefined;
+}): string | undefined => {
+ const metadata =
+ asRecord(bot?.metadata) ??
+ asRecord(getRecordAtPath(data, ['bot', 'metadata'])) ??
+ asRecord(getRecordAtPath(data, ['recording', 'metadata'])) ??
+ asRecord(data?.metadata);
+
+ return getString(metadata?.twentyCallRecordingId);
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/verify-recall-webhook-signature.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/verify-recall-webhook-signature.util.ts
new file mode 100644
index 0000000000..f42106aae5
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/verify-recall-webhook-signature.util.ts
@@ -0,0 +1,109 @@
+import { createHmac, timingSafeEqual } from 'crypto';
+
+import { isUndefined } from '@sniptt/guards';
+
+const RECALL_WEBHOOK_SECRET_PREFIX = 'whsec_';
+const RECALL_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 5 * 60;
+
+export const verifyRecallWebhookSignature = ({
+ rawBody,
+ headers,
+ secret,
+ now = new Date(),
+}: {
+ rawBody: string;
+ headers: Record;
+ secret: string;
+ now?: Date;
+}): { valid: true } | { valid: false; error: string } => {
+ if (!secret.startsWith(RECALL_WEBHOOK_SECRET_PREFIX)) {
+ return {
+ valid: false,
+ error: 'Webhook secret must start with whsec_',
+ };
+ }
+
+ const webhookId = headers['webhook-id'] ?? headers['svix-id'];
+ const webhookTimestamp =
+ headers['webhook-timestamp'] ?? headers['svix-timestamp'];
+ const webhookSignature =
+ headers['webhook-signature'] ?? headers['svix-signature'];
+
+ if (
+ isUndefined(webhookId) ||
+ isUndefined(webhookTimestamp) ||
+ isUndefined(webhookSignature)
+ ) {
+ return {
+ valid: false,
+ error: 'Missing webhook signature headers',
+ };
+ }
+
+ const webhookTimestampSeconds = Number(webhookTimestamp);
+
+ if (!Number.isInteger(webhookTimestampSeconds)) {
+ return {
+ valid: false,
+ error: 'Invalid webhook timestamp',
+ };
+ }
+
+ const nowSeconds = Math.floor(now.getTime() / 1000);
+
+ if (
+ Math.abs(nowSeconds - webhookTimestampSeconds) >
+ RECALL_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS
+ ) {
+ return {
+ valid: false,
+ error: 'Webhook timestamp is outside of the allowed tolerance',
+ };
+ }
+
+ const secretBytes = Buffer.from(
+ secret.slice(RECALL_WEBHOOK_SECRET_PREFIX.length),
+ 'base64',
+ );
+ const expectedSignature = createHmac('sha256', secretBytes)
+ .update(`${webhookId}.${webhookTimestamp}.${rawBody}`)
+ .digest('base64');
+ const providedSignatures = webhookSignature
+ .split(' ')
+ .map((signaturePart) => signaturePart.trim())
+ .filter((signaturePart) => signaturePart !== '')
+ .flatMap((signaturePart) => {
+ if (signaturePart.startsWith('v1,') || signaturePart.startsWith('v1=')) {
+ return [signaturePart.slice(3).trim()];
+ }
+
+ return [];
+ })
+ .filter((signaturePart) => signaturePart !== '');
+
+ if (providedSignatures.length === 0) {
+ return {
+ valid: false,
+ error: 'Missing v1 signature',
+ };
+ }
+
+ const expectedSignatureBuffer = Buffer.from(expectedSignature, 'base64');
+
+ for (const providedSignature of providedSignatures) {
+ const providedSignatureBuffer = Buffer.from(providedSignature, 'base64');
+
+ if (providedSignatureBuffer.length !== expectedSignatureBuffer.length) {
+ continue;
+ }
+
+ if (timingSafeEqual(providedSignatureBuffer, expectedSignatureBuffer)) {
+ return { valid: true };
+ }
+ }
+
+ return {
+ valid: false,
+ error: 'Signature verification failed',
+ };
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-webhook.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-webhook.ts
new file mode 100644
index 0000000000..e1368efa91
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-webhook.ts
@@ -0,0 +1,87 @@
+import { isNull, isUndefined } from '@sniptt/guards';
+import { CoreApiClient } from 'twenty-client-sdk/core';
+import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
+import { Response } from 'twenty-sdk/logic-function';
+
+import { RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/recall-webhook-logic-function-universal-identifier';
+import { RECALL_WEBHOOK_SECRET_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-webhook-secret-env-var-name';
+import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util';
+import { handleRecallWebhook } from 'src/logic-functions/flows/handle-recall-webhook.util';
+import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
+import { type RecallWebhookBody } from 'src/logic-functions/recall-api/parse-recall-webhook-event.util';
+import { verifyRecallWebhookSignature } from 'src/logic-functions/recall-api/verify-recall-webhook-signature.util';
+
+// Non-2xx makes Svix retry; a returned plain object would 200-ack permanently.
+const rejectWebhook = (status: number, error: string): Response => {
+ console.error(`[twenty-meeting-bot] webhook rejected: ${error}`);
+
+ return new Response({ error }, { status });
+};
+
+export const recallWebhookRouteHandler = async (
+ routePayload: RoutePayload,
+): Promise