Add Recall webhook status handler to the meeting bot app (#21659)

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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21659?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
nitin
2026-06-16 16:08:15 +05:30
committed by GitHub
parent ee6c9db33a
commit 5cb8a091fc
18 changed files with 1592 additions and 2 deletions
@@ -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)
@@ -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,
},
},
});
@@ -0,0 +1,2 @@
export const RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'9215afe6-1497-4149-a49d-e608e239bbaf';
@@ -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>,
): RecallWebhookRoutePayload =>
({
headers: {},
...overrides,
}) as RecallWebhookRoutePayload;
const buildSignedHeaders = (rawBody: string): Record<string, string> => {
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' });
});
});
@@ -1 +1 @@
export const DEFAULT_RECALL_REGION = 'ap-northeast-1';
export const DEFAULT_RECALL_REGION = 'eu-central-1';
@@ -0,0 +1 @@
export const RECALL_WEBHOOK_SECRET_ENV_VAR_NAME = 'RECALL_WEBHOOK_SECRET';
@@ -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);
});
});
@@ -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, number> = {
[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;
};
@@ -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;
}
};
@@ -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<string, unknown> }> = [];
constructor(callRecordings: CallRecordingNode[]) {
this.callRecordings = callRecordings;
}
async query(query: any): Promise<any> {
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<any> {
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([]);
});
});
@@ -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<RecallWebhookHandlerResult> => {
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<CallRecordingRecord | undefined> => {
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 };
@@ -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);
});
});
@@ -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();
};
@@ -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<string, unknown> | undefined;
bot: Record<string, unknown> | 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);
};
@@ -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<string, string | undefined>;
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',
};
};
@@ -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<RecallWebhookBody>,
): Promise<object> => {
const webhookSecret = getApplicationVariableValue(
RECALL_WEBHOOK_SECRET_ENV_VAR_NAME,
);
if (!isNonEmptyString(webhookSecret)) {
return rejectWebhook(
500,
'RECALL_WEBHOOK_SECRET server variable is not set. A server admin must copy it from the Recall webhook endpoint settings and set it on the Twenty Meeting Bot application registration.',
);
}
const { rawBody } = routePayload;
if (isUndefined(rawBody)) {
return rejectWebhook(
500,
'Raw request body was not forwarded by the server; cannot verify the webhook signature',
);
}
const signatureCheck = verifyRecallWebhookSignature({
rawBody,
headers: routePayload.headers,
secret: webhookSecret,
});
if (!signatureCheck.valid) {
return rejectWebhook(
401,
`Invalid webhook signature: ${signatureCheck.error}`,
);
}
if (isUndefined(routePayload.body) || isNull(routePayload.body)) {
return rejectWebhook(400, 'Webhook payload was empty');
}
return handleRecallWebhook({
client: new CoreApiClient(),
body: routePayload.body,
});
};
export default defineLogicFunction({
universalIdentifier: RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'recall-webhook',
description:
'Receives Recall.ai webhook events and updates the matching CallRecording lifecycle status.',
timeoutSeconds: 30,
handler: recallWebhookRouteHandler,
httpRouteTriggerSettings: {
path: '/webhook/recall',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: [
'webhook-id',
'webhook-timestamp',
'webhook-signature',
'svix-id',
'svix-timestamp',
'svix-signature',
],
},
});
@@ -0,0 +1,6 @@
import { isArray, isObject } from '@sniptt/guards';
export const asRecord = (value: unknown): Record<string, unknown> | undefined =>
isObject(value) && !isArray(value)
? (value as Record<string, unknown>)
: undefined;
@@ -0,0 +1,10 @@
import { asRecord } from 'src/logic-functions/utils/as-record.util';
export const getRecordAtPath = (
record: Record<string, unknown> | undefined,
path: string[],
): unknown =>
path.reduce<unknown>(
(currentValue, pathPart) => asRecord(currentValue)?.[pathPart],
record,
);