diff --git a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx
index 5032276247..80cd7f18cd 100644
--- a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx
+++ b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx
@@ -59,7 +59,7 @@ To invoke a route-triggered logic function from a (headless) front component, se
- **cron**: Runs your function on a schedule using a CRON expression.
- **databaseEvent**: Runs on workspace object lifecycle events. When the event operation is `updated`, specific fields to listen to can be specified in the `updatedFields` array. If left undefined or empty, any update will trigger the function.
> e.g. `person.updated`, `*.created`, `company.*`
-- **serverRoute**: Exposes a single registration-scoped HTTP route. A **resolver** function (declared with `serverRouteTriggerSettings`) runs in the owner workspace and returns the target workspace AND the target logic function to dispatch to; the platform acks with `202` and runs that **target** function on the worker queue. See [Server route trigger](#server-route-trigger).
+- **serverRoute**: Exposes a single registration-scoped HTTP route. A **resolver** function (declared with `serverRouteTriggerSettings`) runs in the owner workspace and either returns a sync `Response` or the target workspace AND logic function to enqueue; on the enqueue path the platform acks with `202` and runs that **target** on the worker queue. See [Server route trigger](#server-route-trigger).
You can also manually execute a function using the CLI:
@@ -178,13 +178,17 @@ The status code must be a valid HTTP status code (between 100 and 599). Response
The trigger has two parts:
-1. A **resolver** logic function — declared with `serverRouteTriggerSettings` — runs in your **owner workspace** (the workspace that owns the application registration). It inspects the incoming request and returns `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }`, picking *both* the target workspace and the target function. The resolver is the single point of authorization — the URL only carries the resolver's identifier. **This is the preferred place to verify request signatures**: the resolver runs before any side effect, has access to the original `rawBody` and forwarded headers, and can reject without ever touching the target.
-2. A **target** logic function — a regular per-workspace logic function — then runs in the resolved workspace with the payload returned by the resolver (or the original request payload if the resolver didn't transform it). Its return value becomes the HTTP response.
+1. A **resolver** logic function — declared with `serverRouteTriggerSettings` — runs in your **owner workspace** (the workspace that owns the application registration). It inspects the incoming request and returns either:
+ - `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }` — the platform enqueues that target in the resolved workspace and acks with `202 { queued: true }`, or
+ - a `Response` from `twenty-sdk/logic-function` — the platform echoes that HTTP response **synchronously** and does **not** enqueue a target (use this for challenge handshakes such as Slack `url_verification`).
+
+ The resolver is the single point of authorization — the URL only carries the resolver's identifier. **This is the preferred place to verify request signatures**: the resolver runs before any side effect, has access to the original `rawBody` and forwarded headers, and can reject without ever touching the target.
+2. A **target** logic function — a regular per-workspace logic function — then runs in the resolved workspace with the payload returned by the resolver (or the original request payload if the resolver didn't transform it). Its return value is **not** observed by the HTTP caller when the resolver chose the enqueue path.
```ts src/logic-functions/resolve-server-route.logic-function.ts
import { createHmac, timingSafeEqual } from 'crypto';
import { defineLogicFunction } from 'twenty-sdk/define';
-import type { RoutePayload } from 'twenty-sdk/logic-function';
+import { Response, type RoutePayload } from 'twenty-sdk/logic-function';
// Runs in the owner workspace. Verifies the request signature, picks
// which target function should handle the event, and returns the
@@ -211,12 +215,25 @@ const handler = async (event: RoutePayload) => {
}
const body = (event.body ?? {}) as {
+ challenge?: string;
metadata?: { twentyWorkspaceId?: string };
type?: string;
};
+ // Handshakes must be answered on this same response, so reply from the
+ // resolver instead of returning a dispatch target.
+ if (body.type === 'url_verification') {
+ return new Response({ challenge: body.challenge });
+ }
+
+ const workspaceId = body.metadata?.twentyWorkspaceId;
+
+ if (!workspaceId) {
+ throw new Error('event is not linked to a workspace');
+ }
+
return {
- workspaceId: body.metadata?.twentyWorkspaceId ?? '',
+ workspaceId,
// Route different event types to different target functions.
targetLogicFunctionUniversalIdentifier:
body.type === 'invoice.paid'
@@ -265,7 +282,7 @@ The identifier is the resolver's `universalIdentifier` from your manifest. Regis
**The application must be claimed and installed on its owner workspace.** Because the resolver runs in the **owner workspace** (the workspace that owns the application registration), a server route trigger only works once the application has been *claimed* — i.e. it has an owner workspace — **and** that application is **installed on the owner workspace**. Until both are true the resolver has nowhere to run, so the route cannot be dispatched. An application that exposes a `serverRouteTriggerSettings` logic function therefore cannot be listed in the marketplace until it is claimed and installed on its owner workspace.
-**Resolver contract.** The SDK's `LogicFunctionConfig` type enforces this at compile time: as soon as you set `serverRouteTriggerSettings`, your handler is constrained to return `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (or a `Promise` of it). The `workspaceId` must be a workspace where the target function is installed, otherwise the request is rejected with `404`.
+**Resolver contract.** The SDK's `LogicFunctionConfig` type enforces this at compile time: as soon as you set `serverRouteTriggerSettings`, your handler is constrained to return either a `Response`, or `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (or a `Promise` of either). On the dispatch path, the `workspaceId` must be a workspace where the target function is installed, otherwise the request is rejected with `404`. A result that matches neither shape — including one whose identifiers are not UUIDs — is rejected with `502`.
| Field | Type | Notes |
|-------|------|-------|
@@ -290,7 +307,9 @@ For request signatures, most providers sign with HMAC-SHA256; the parts that dif
The resolver example above already shows the GitHub HMAC-SHA256 flow — adapt the header name, digest encoding, and signed-payload string per the provider you're integrating.
-The route responds `202 { queued: true }` right after the resolver returns and the target runs on the worker queue — the caller never observes the target's latency, result, or failures (those are recorded in execution logs). This keeps sender redeliveries from amplifying processing slowdowns, which is what you want for webhook ingestion. For endpoints whose caller must read the response body (challenge handshakes, Slack commands), use an `httpRouteTriggerSettings` route instead. Keep the resolver fast — some providers (e.g. Slack) time out in a few seconds. Because the resolver is reachable as a public endpoint, protect it with rate limiting at your edge.
+When the resolver returns a dispatch object, the route responds `202 { queued: true }` and the target runs on the worker queue — the caller never observes the target's latency, result, or failures (those are recorded in execution logs). This keeps sender redeliveries from amplifying processing slowdowns, which is what you want for webhook ingestion.
+
+When the caller must read the response body on the same request (challenge handshakes, interactive acknowledgements), return a `Response` from the **resolver** instead. The platform echoes it synchronously and skips the queue; its headers go through the same allow-list as HTTP route responses. Keep the resolver fast — some providers (e.g. Slack) time out in a few seconds. Because the resolver is reachable as a public endpoint, protect it with rate limiting at your edge.
#### Database event trigger payload
diff --git a/packages/twenty-sdk/src/sdk/define/index.ts b/packages/twenty-sdk/src/sdk/define/index.ts
index 55bf7f912c..ba058a4b5e 100644
--- a/packages/twenty-sdk/src/sdk/define/index.ts
+++ b/packages/twenty-sdk/src/sdk/define/index.ts
@@ -92,7 +92,9 @@ export type {
export type {
LogicFunctionConfig,
LogicFunctionHandler,
+ ServerRouteResolverResult,
} from '@/sdk/define/logic-functions/logic-function-config';
+export type { ServerRouteDispatchResult } from 'twenty-shared/application';
export type { CronPayload } from '@/sdk/define/logic-functions/triggers/cron-payload-type';
export type {
DatabaseEventPayload,
diff --git a/packages/twenty-sdk/src/sdk/define/logic-functions/logic-function-config.ts b/packages/twenty-sdk/src/sdk/define/logic-functions/logic-function-config.ts
index e28b92495a..60ec7a8f36 100644
--- a/packages/twenty-sdk/src/sdk/define/logic-functions/logic-function-config.ts
+++ b/packages/twenty-sdk/src/sdk/define/logic-functions/logic-function-config.ts
@@ -1,21 +1,19 @@
import {
type LogicFunctionManifest,
+ type ServerRouteDispatchResult,
type ServerRouteTriggerSettings,
} from 'twenty-shared/application';
+import { type LogicFunctionHttpResponse } from 'twenty-shared/types';
export type LogicFunctionHandler = (...args: any[]) => any | Promise;
-// A resolver function attached to `serverRouteTriggerSettings` runs in the
-// owner workspace and must return BOTH the target workspace and the target
-// logic function to dispatch to. The server contract is
-// `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string;
-// payload?: object }`. The resolver is the single point of authorization —
-// the URL only carries the resolver's universalIdentifier.
-export type ServerRouteResolverResult = {
- workspaceId: string;
- targetLogicFunctionUniversalIdentifier: string;
- payload?: object;
-};
+// A resolver attached to `serverRouteTriggerSettings` runs in the owner workspace and is the single
+// point of authorization: the URL only carries the resolver's universalIdentifier. Returning a
+// dispatch result enqueues the target, returning a `Response` answers the caller synchronously
+// instead, for providers whose webhook URL requires a handshake reply.
+export type ServerRouteResolverResult =
+ | ServerRouteDispatchResult
+ | LogicFunctionHttpResponse;
export type ServerRouteResolverHandler = (
...args: any[]
diff --git a/packages/twenty-sdk/src/sdk/logic-function/index.ts b/packages/twenty-sdk/src/sdk/logic-function/index.ts
index d8c5154f95..cb0484a6e5 100644
--- a/packages/twenty-sdk/src/sdk/logic-function/index.ts
+++ b/packages/twenty-sdk/src/sdk/logic-function/index.ts
@@ -13,7 +13,9 @@
export type {
LogicFunctionConfig,
LogicFunctionHandler,
+ ServerRouteResolverResult,
} from '@/sdk/define/logic-functions/logic-function-config';
+export type { ServerRouteDispatchResult } from 'twenty-shared/application';
export type {
InstallHandler,
diff --git a/packages/twenty-server/src/engine/core-modules/server-route-trigger/__tests__/server-route-trigger.service.spec.ts b/packages/twenty-server/src/engine/core-modules/server-route-trigger/__tests__/server-route-trigger.service.spec.ts
index 75593f1e57..72a0b748bf 100644
--- a/packages/twenty-server/src/engine/core-modules/server-route-trigger/__tests__/server-route-trigger.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/server-route-trigger/__tests__/server-route-trigger.service.spec.ts
@@ -1,4 +1,5 @@
import { type Request } from 'express';
+import { LOGIC_FUNCTION_HTTP_RESPONSE_MARKER } from 'twenty-shared/types';
import { type Repository } from 'typeorm';
import { type LogicFunctionExecuteResult } from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface';
@@ -18,8 +19,9 @@ import {
} from 'src/engine/metadata-modules/logic-function/logic-function.exception';
import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto';
-const RESOLVER_UID = 'resolver-uid';
-const TARGET_UID = 'target-uid';
+const RESOLVER_UID = 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10';
+const TARGET_UID = 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10';
+const TARGET_WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419';
const buildExecuteResult = (
data: object | null,
@@ -116,7 +118,7 @@ describe('ServerRouteTriggerService', () => {
logicFunctionExecutorService = {
execute: jest.fn().mockResolvedValueOnce(
buildExecuteResult({
- workspaceId: 'target-ws',
+ workspaceId: TARGET_WORKSPACE_ID,
targetLogicFunctionUniversalIdentifier: TARGET_UID,
payload: { from: 'resolver' },
}),
@@ -148,7 +150,7 @@ describe('ServerRouteTriggerService', () => {
LogicFunctionTriggerJob.name,
{
logicFunctionId: 'target-id',
- workspaceId: 'target-ws',
+ workspaceId: TARGET_WORKSPACE_ID,
payload: { from: 'resolver' },
},
{ retryLimit: 3 },
@@ -200,7 +202,7 @@ describe('ServerRouteTriggerService', () => {
expect.objectContaining({
where: expect.objectContaining({
universalIdentifier: TARGET_UID,
- workspaceId: 'target-ws',
+ workspaceId: TARGET_WORKSPACE_ID,
application: { applicationRegistrationId: 'reg-1' },
}),
}),
@@ -229,6 +231,26 @@ describe('ServerRouteTriggerService', () => {
expect(logicFunctionExecutorService.execute).not.toHaveBeenCalled();
});
+ it('answers the caller directly and enqueues nothing when the resolver returns an http response', async () => {
+ logicFunctionExecutorService.execute.mockReset();
+ logicFunctionExecutorService.execute.mockResolvedValueOnce(
+ buildExecuteResult({
+ [LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: true,
+ status: 200,
+ body: { challenge: 'abc123' },
+ }),
+ );
+
+ const result = await handle();
+
+ expect(result).toEqual({
+ statusCode: 200,
+ headers: {},
+ body: { challenge: 'abc123' },
+ });
+ expect(messageQueueService.add).not.toHaveBeenCalled();
+ });
+
it('throws RESOLVER_INVALID_RESULT when the resolver does not return a workspaceId', async () => {
logicFunctionExecutorService.execute.mockReset();
logicFunctionExecutorService.execute.mockResolvedValueOnce(
@@ -245,7 +267,7 @@ describe('ServerRouteTriggerService', () => {
it('throws RESOLVER_INVALID_RESULT when the resolver does not return a targetLogicFunctionUniversalIdentifier', async () => {
logicFunctionExecutorService.execute.mockReset();
logicFunctionExecutorService.execute.mockResolvedValueOnce(
- buildExecuteResult({ workspaceId: 'target-ws' }),
+ buildExecuteResult({ workspaceId: TARGET_WORKSPACE_ID }),
);
await expect(handle()).rejects.toMatchObject({
diff --git a/packages/twenty-server/src/engine/core-modules/server-route-trigger/server-route-trigger.service.ts b/packages/twenty-server/src/engine/core-modules/server-route-trigger/server-route-trigger.service.ts
index d65ab3c5de..e56b49f846 100644
--- a/packages/twenty-server/src/engine/core-modules/server-route-trigger/server-route-trigger.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/server-route-trigger/server-route-trigger.service.ts
@@ -1,8 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
-import { isString } from '@sniptt/guards';
import { Request } from 'express';
+import { isLogicFunctionHttpResponse } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -19,23 +19,21 @@ import { buildLogicFunctionEvent } from 'src/engine/core-modules/logic-function/
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
-import { type RouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
+import {
+ buildRouteTriggerResponse,
+ type RouteTriggerResponse,
+} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
import {
ServerRouteTriggerException,
ServerRouteTriggerExceptionCode,
} from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception';
+import { parseResolverDispatchResultOrThrow } from 'src/engine/core-modules/server-route-trigger/utils/parse-resolver-dispatch-result-or-throw.util';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
import {
LogicFunctionException,
LogicFunctionExceptionCode,
} from 'src/engine/metadata-modules/logic-function/logic-function.exception';
-type ResolverResult = {
- workspaceId: string;
- targetLogicFunctionUniversalIdentifier: string;
- payload?: object;
-};
-
const QUEUED_TARGET_RETRY_LIMIT = 3;
@Injectable()
@@ -99,13 +97,27 @@ export class ServerRouteTriggerService {
workspaceId: resolver.workspaceId,
payload: event,
});
- const resolved = this.parseResolverResult(resolverResult);
+
+ if (isDefined(resolverResult.error)) {
+ throw new ServerRouteTriggerException(
+ resolverResult.error.errorMessage,
+ ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR,
+ );
+ }
+
+ if (isLogicFunctionHttpResponse(resolverResult.data)) {
+ return buildRouteTriggerResponse(resolverResult.data);
+ }
+
+ const dispatchResult = parseResolverDispatchResultOrThrow(
+ resolverResult.data,
+ );
return await this.enqueueTargetFunction({
logicFunctionUniversalIdentifier:
- resolved.targetLogicFunctionUniversalIdentifier,
- workspaceId: resolved.workspaceId,
- payload: resolved.payload ?? event,
+ dispatchResult.targetLogicFunctionUniversalIdentifier,
+ workspaceId: dispatchResult.workspaceId,
+ payload: dispatchResult.payload ?? event,
applicationRegistrationId,
});
}
@@ -134,44 +146,6 @@ export class ServerRouteTriggerService {
);
}
- private parseResolverResult(result: {
- data: object | null;
- error?: { errorMessage: string };
- }): ResolverResult {
- if (isDefined(result.error)) {
- throw new ServerRouteTriggerException(
- result.error.errorMessage,
- ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR,
- );
- }
-
- const data = result.data as {
- workspaceId?: unknown;
- targetLogicFunctionUniversalIdentifier?: unknown;
- payload?: unknown;
- };
-
- if (
- !isString(data?.workspaceId) ||
- !isString(data?.targetLogicFunctionUniversalIdentifier)
- ) {
- throw new ServerRouteTriggerException(
- 'Resolver logic function must return { workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }',
- ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT,
- );
- }
-
- return {
- workspaceId: data.workspaceId,
- targetLogicFunctionUniversalIdentifier:
- data.targetLogicFunctionUniversalIdentifier,
- payload:
- typeof data.payload === 'object' && data.payload !== null
- ? (data.payload as object)
- : undefined,
- };
- }
-
private async enqueueTargetFunction({
logicFunctionUniversalIdentifier,
workspaceId,
diff --git a/packages/twenty-server/src/engine/core-modules/server-route-trigger/utils/__tests__/parse-resolver-dispatch-result-or-throw.util.spec.ts b/packages/twenty-server/src/engine/core-modules/server-route-trigger/utils/__tests__/parse-resolver-dispatch-result-or-throw.util.spec.ts
new file mode 100644
index 0000000000..ec1676e8b6
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/server-route-trigger/utils/__tests__/parse-resolver-dispatch-result-or-throw.util.spec.ts
@@ -0,0 +1,93 @@
+import { ServerRouteTriggerExceptionCode } from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception';
+import { parseResolverDispatchResultOrThrow } from 'src/engine/core-modules/server-route-trigger/utils/parse-resolver-dispatch-result-or-throw.util';
+
+const WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419';
+const TARGET_UNIVERSAL_IDENTIFIER = 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10';
+
+const expectInvalidResult = (data: unknown) =>
+ expect(() => parseResolverDispatchResultOrThrow(data)).toThrow(
+ expect.objectContaining({
+ code: ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT,
+ }),
+ );
+
+describe('parseResolverDispatchResultOrThrow', () => {
+ it('should return the dispatch target when the resolver returns one', () => {
+ expect(
+ parseResolverDispatchResultOrThrow({
+ workspaceId: WORKSPACE_ID,
+ targetLogicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER,
+ }),
+ ).toEqual({
+ workspaceId: WORKSPACE_ID,
+ targetLogicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER,
+ });
+ });
+
+ it('should keep the payload when the resolver transforms it', () => {
+ expect(
+ parseResolverDispatchResultOrThrow({
+ workspaceId: WORKSPACE_ID,
+ targetLogicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER,
+ payload: { event: 'invoice.paid' },
+ }).payload,
+ ).toEqual({ event: 'invoice.paid' });
+ });
+
+ it('should accept any object as a payload', () => {
+ expect(
+ parseResolverDispatchResultOrThrow({
+ workspaceId: WORKSPACE_ID,
+ targetLogicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER,
+ payload: [{ event: 'invoice.paid' }],
+ }).payload,
+ ).toEqual([{ event: 'invoice.paid' }]);
+ });
+
+ it('should drop keys that are not part of the dispatch contract', () => {
+ expect(
+ parseResolverDispatchResultOrThrow({
+ workspaceId: WORKSPACE_ID,
+ targetLogicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER,
+ applicationRegistrationId: 'smuggled',
+ }),
+ ).toEqual({
+ workspaceId: WORKSPACE_ID,
+ targetLogicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER,
+ });
+ });
+
+ it('should throw RESOLVER_INVALID_RESULT when the workspaceId is missing', () => {
+ expectInvalidResult({
+ targetLogicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER,
+ });
+ });
+
+ it('should throw RESOLVER_INVALID_RESULT when the target identifier is missing', () => {
+ expectInvalidResult({ workspaceId: WORKSPACE_ID });
+ });
+
+ it('should throw RESOLVER_INVALID_RESULT when an identifier is not a uuid', () => {
+ expectInvalidResult({
+ workspaceId: '',
+ targetLogicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER,
+ });
+ expectInvalidResult({
+ workspaceId: WORKSPACE_ID,
+ targetLogicFunctionUniversalIdentifier: 'handle-invoice-paid',
+ });
+ });
+
+ it('should throw RESOLVER_INVALID_RESULT when the payload is not an object', () => {
+ expectInvalidResult({
+ workspaceId: WORKSPACE_ID,
+ targetLogicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER,
+ payload: 'not-an-object',
+ });
+ });
+
+ it('should throw RESOLVER_INVALID_RESULT when the resolver returns nothing', () => {
+ expectInvalidResult(null);
+ expectInvalidResult(undefined);
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/server-route-trigger/utils/parse-resolver-dispatch-result-or-throw.util.ts b/packages/twenty-server/src/engine/core-modules/server-route-trigger/utils/parse-resolver-dispatch-result-or-throw.util.ts
new file mode 100644
index 0000000000..1e8c68f77e
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/server-route-trigger/utils/parse-resolver-dispatch-result-or-throw.util.ts
@@ -0,0 +1,29 @@
+import { isObject } from '@sniptt/guards';
+import { type ServerRouteDispatchResult } from 'twenty-shared/application';
+import { z } from 'zod';
+
+import {
+ ServerRouteTriggerException,
+ ServerRouteTriggerExceptionCode,
+} from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception';
+
+const resolverDispatchResultSchema = z.object({
+ workspaceId: z.uuid(),
+ targetLogicFunctionUniversalIdentifier: z.uuid(),
+ payload: z.custom