diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
index ae44f73366..5ef751705e 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
+++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
@@ -3110,6 +3110,13 @@ type Query {
getViewGroup(id: String!): ViewGroup
myMessageFolders(messageChannelId: UUID): [MessageFolder!]!
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
+ findApplicationRegistrationByClientId(clientId: String!): PublicApplicationRegistration
+ findApplicationRegistrationByUniversalIdentifier(universalIdentifier: String!): ApplicationRegistration
+ findManyApplicationRegistrations: [ApplicationRegistration!]!
+ findOneApplicationRegistration(id: String!): ApplicationRegistration!
+ findApplicationRegistrationStats(id: String!): ApplicationRegistrationStats!
+ findApplicationRegistrationVariables(applicationRegistrationId: String!): [ApplicationRegistrationVariableDTO!]!
+ applicationRegistrationTarballUrl(id: String!): String
minimalMetadata: MinimalMetadata!
appConnections(filter: ListAppConnectionsInput): [AppConnection!]!
appConnection(id: ID!): AppConnection!
@@ -3128,13 +3135,6 @@ type Query {
checkWorkspaceSubdomainAvailability(subdomain: String!): SubdomainAvailabilityDTO!
getWorkspaceCreationDefaults: WorkspaceCreationDefaultsDTO!
validatePasswordResetToken(passwordResetToken: String!): ValidatePasswordResetToken!
- findApplicationRegistrationByClientId(clientId: String!): PublicApplicationRegistration
- findApplicationRegistrationByUniversalIdentifier(universalIdentifier: String!): ApplicationRegistration
- findManyApplicationRegistrations: [ApplicationRegistration!]!
- findOneApplicationRegistration(id: String!): ApplicationRegistration!
- findApplicationRegistrationStats(id: String!): ApplicationRegistrationStats!
- findApplicationRegistrationVariables(applicationRegistrationId: String!): [ApplicationRegistrationVariableDTO!]!
- applicationRegistrationTarballUrl(id: String!): String
currentUser: User!
currentWorkspace: Workspace!
getPublicWorkspaceDataByDomain(origin: String): PublicWorkspaceData!
@@ -3377,6 +3377,15 @@ type Mutation {
updateMessageFolder(input: UpdateMessageFolderInput!): MessageFolder!
updateMessageFolders(input: UpdateMessageFoldersInput!): [MessageFolder!]!
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
+ createApplicationRegistration(input: CreateApplicationRegistrationInput!): CreateApplicationRegistration!
+ updateApplicationRegistration(input: UpdateApplicationRegistrationInput!): ApplicationRegistration!
+ deleteApplicationRegistration(id: String!): Boolean!
+ rotateApplicationRegistrationClientSecret(id: String!): RotateClientSecret!
+ createApplicationRegistrationVariable(input: CreateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable!
+ updateApplicationRegistrationVariable(input: UpdateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable!
+ deleteApplicationRegistrationVariable(id: String!): Boolean!
+ uploadAppTarball(file: Upload!, universalIdentifier: String): ApplicationRegistration!
+ transferApplicationRegistrationOwnership(applicationRegistrationId: String!, targetWorkspaceSubdomain: String!): ApplicationRegistration!
createChatThread: AgentChatThread!
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
stopAgentChatStream(threadId: UUID!): Boolean!
@@ -3410,15 +3419,6 @@ type Mutation {
generatePlaygroundToken: AuthToken!
emailPasswordResetLink(email: String!, workspaceId: UUID): EmailPasswordResetLink!
updatePasswordViaResetToken(passwordResetToken: String!, newPassword: String!): InvalidatePassword!
- createApplicationRegistration(input: CreateApplicationRegistrationInput!): CreateApplicationRegistration!
- updateApplicationRegistration(input: UpdateApplicationRegistrationInput!): ApplicationRegistration!
- deleteApplicationRegistration(id: String!): Boolean!
- rotateApplicationRegistrationClientSecret(id: String!): RotateClientSecret!
- createApplicationRegistrationVariable(input: CreateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable!
- updateApplicationRegistrationVariable(input: UpdateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable!
- deleteApplicationRegistrationVariable(id: String!): Boolean!
- uploadAppTarball(file: Upload!, universalIdentifier: String): ApplicationRegistration!
- transferApplicationRegistrationOwnership(applicationRegistrationId: String!, targetWorkspaceSubdomain: String!): ApplicationRegistration!
initiateOTPProvisioning(loginToken: String!, origin: String!): InitiateTwoFactorAuthenticationProvisioning!
initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioning!
deleteTwoFactorAuthenticationMethod(twoFactorAuthenticationMethodId: UUID!): DeleteTwoFactorAuthenticationMethod!
@@ -4405,40 +4405,6 @@ input UpdateCalendarChannelInputUpdates {
isSyncEnabled: Boolean
}
-input FileAttachmentInput {
- id: UUID!
- filename: String!
-}
-
-input CreateSkillInput {
- id: UUID
- name: String!
- label: String!
- icon: String
- description: String
- content: String!
-}
-
-input UpdateSkillInput {
- id: UUID!
- name: String
- label: String
- icon: String
- description: String
- content: String
- isActive: Boolean
-}
-
-input GetAuthorizationUrlForSSOInput {
- identityProviderId: UUID!
- workspaceInviteHash: String
-}
-
-input SignUpInNewWorkspaceInput {
- displayName: String
- subdomain: String
-}
-
input CreateApplicationRegistrationInput {
name: String!
universalIdentifier: String
@@ -4477,6 +4443,40 @@ input UpdateApplicationRegistrationVariablePayload {
description: String
}
+input FileAttachmentInput {
+ id: UUID!
+ filename: String!
+}
+
+input CreateSkillInput {
+ id: UUID
+ name: String!
+ label: String!
+ icon: String
+ description: String
+ content: String!
+}
+
+input UpdateSkillInput {
+ id: UUID!
+ name: String
+ label: String
+ icon: String
+ description: String
+ content: String
+ isActive: Boolean
+}
+
+input GetAuthorizationUrlForSSOInput {
+ identityProviderId: UUID!
+ workspaceInviteHash: String
+}
+
+input SignUpInNewWorkspaceInput {
+ displayName: String
+ subdomain: String
+}
+
input UpdateWorkspaceMemberSettingsInput {
workspaceMemberId: UUID!
update: JSON!
diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts
index 2b860b0e28..b975490997 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts
+++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts
@@ -2708,6 +2708,13 @@ export interface Query {
getViewGroup?: ViewGroup
myMessageFolders: MessageFolder[]
myCalendarChannels: CalendarChannel[]
+ findApplicationRegistrationByClientId?: PublicApplicationRegistration
+ findApplicationRegistrationByUniversalIdentifier?: ApplicationRegistration
+ findManyApplicationRegistrations: ApplicationRegistration[]
+ findOneApplicationRegistration: ApplicationRegistration
+ findApplicationRegistrationStats: ApplicationRegistrationStats
+ findApplicationRegistrationVariables: ApplicationRegistrationVariableDTO[]
+ applicationRegistrationTarballUrl?: Scalars['String']
minimalMetadata: MinimalMetadata
appConnections: AppConnection[]
appConnection: AppConnection
@@ -2726,13 +2733,6 @@ export interface Query {
checkWorkspaceSubdomainAvailability: SubdomainAvailabilityDTO
getWorkspaceCreationDefaults: WorkspaceCreationDefaultsDTO
validatePasswordResetToken: ValidatePasswordResetToken
- findApplicationRegistrationByClientId?: PublicApplicationRegistration
- findApplicationRegistrationByUniversalIdentifier?: ApplicationRegistration
- findManyApplicationRegistrations: ApplicationRegistration[]
- findOneApplicationRegistration: ApplicationRegistration
- findApplicationRegistrationStats: ApplicationRegistrationStats
- findApplicationRegistrationVariables: ApplicationRegistrationVariableDTO[]
- applicationRegistrationTarballUrl?: Scalars['String']
currentUser: User
currentWorkspace: Workspace
getPublicWorkspaceDataByDomain: PublicWorkspaceData
@@ -2896,6 +2896,15 @@ export interface Mutation {
updateMessageFolder: MessageFolder
updateMessageFolders: MessageFolder[]
updateCalendarChannel: CalendarChannel
+ createApplicationRegistration: CreateApplicationRegistration
+ updateApplicationRegistration: ApplicationRegistration
+ deleteApplicationRegistration: Scalars['Boolean']
+ rotateApplicationRegistrationClientSecret: RotateClientSecret
+ createApplicationRegistrationVariable: ApplicationRegistrationVariable
+ updateApplicationRegistrationVariable: ApplicationRegistrationVariable
+ deleteApplicationRegistrationVariable: Scalars['Boolean']
+ uploadAppTarball: ApplicationRegistration
+ transferApplicationRegistrationOwnership: ApplicationRegistration
createChatThread: AgentChatThread
sendChatMessage: SendChatMessageResult
stopAgentChatStream: Scalars['Boolean']
@@ -2929,15 +2938,6 @@ export interface Mutation {
generatePlaygroundToken: AuthToken
emailPasswordResetLink: EmailPasswordResetLink
updatePasswordViaResetToken: InvalidatePassword
- createApplicationRegistration: CreateApplicationRegistration
- updateApplicationRegistration: ApplicationRegistration
- deleteApplicationRegistration: Scalars['Boolean']
- rotateApplicationRegistrationClientSecret: RotateClientSecret
- createApplicationRegistrationVariable: ApplicationRegistrationVariable
- updateApplicationRegistrationVariable: ApplicationRegistrationVariable
- deleteApplicationRegistrationVariable: Scalars['Boolean']
- uploadAppTarball: ApplicationRegistration
- transferApplicationRegistrationOwnership: ApplicationRegistration
initiateOTPProvisioning: InitiateTwoFactorAuthenticationProvisioning
initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioning
deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethod
@@ -5867,6 +5867,13 @@ export interface QueryGenqlSelection{
getViewGroup?: (ViewGroupGenqlSelection & { __args: {id: Scalars['String']} })
myMessageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} })
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
+ findApplicationRegistrationByClientId?: (PublicApplicationRegistrationGenqlSelection & { __args: {clientId: Scalars['String']} })
+ findApplicationRegistrationByUniversalIdentifier?: (ApplicationRegistrationGenqlSelection & { __args: {universalIdentifier: Scalars['String']} })
+ findManyApplicationRegistrations?: ApplicationRegistrationGenqlSelection
+ findOneApplicationRegistration?: (ApplicationRegistrationGenqlSelection & { __args: {id: Scalars['String']} })
+ findApplicationRegistrationStats?: (ApplicationRegistrationStatsGenqlSelection & { __args: {id: Scalars['String']} })
+ findApplicationRegistrationVariables?: (ApplicationRegistrationVariableDTOGenqlSelection & { __args: {applicationRegistrationId: Scalars['String']} })
+ applicationRegistrationTarballUrl?: { __args: {id: Scalars['String']} }
minimalMetadata?: MinimalMetadataGenqlSelection
appConnections?: (AppConnectionGenqlSelection & { __args?: {filter?: (ListAppConnectionsInput | null)} })
appConnection?: (AppConnectionGenqlSelection & { __args: {id: Scalars['ID']} })
@@ -5885,13 +5892,6 @@ export interface QueryGenqlSelection{
checkWorkspaceSubdomainAvailability?: (SubdomainAvailabilityDTOGenqlSelection & { __args: {subdomain: Scalars['String']} })
getWorkspaceCreationDefaults?: WorkspaceCreationDefaultsDTOGenqlSelection
validatePasswordResetToken?: (ValidatePasswordResetTokenGenqlSelection & { __args: {passwordResetToken: Scalars['String']} })
- findApplicationRegistrationByClientId?: (PublicApplicationRegistrationGenqlSelection & { __args: {clientId: Scalars['String']} })
- findApplicationRegistrationByUniversalIdentifier?: (ApplicationRegistrationGenqlSelection & { __args: {universalIdentifier: Scalars['String']} })
- findManyApplicationRegistrations?: ApplicationRegistrationGenqlSelection
- findOneApplicationRegistration?: (ApplicationRegistrationGenqlSelection & { __args: {id: Scalars['String']} })
- findApplicationRegistrationStats?: (ApplicationRegistrationStatsGenqlSelection & { __args: {id: Scalars['String']} })
- findApplicationRegistrationVariables?: (ApplicationRegistrationVariableDTOGenqlSelection & { __args: {applicationRegistrationId: Scalars['String']} })
- applicationRegistrationTarballUrl?: { __args: {id: Scalars['String']} }
currentUser?: UserGenqlSelection
currentWorkspace?: WorkspaceGenqlSelection
getPublicWorkspaceDataByDomain?: (PublicWorkspaceDataGenqlSelection & { __args?: {origin?: (Scalars['String'] | null)} })
@@ -6080,6 +6080,15 @@ export interface MutationGenqlSelection{
updateMessageFolder?: (MessageFolderGenqlSelection & { __args: {input: UpdateMessageFolderInput} })
updateMessageFolders?: (MessageFolderGenqlSelection & { __args: {input: UpdateMessageFoldersInput} })
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
+ createApplicationRegistration?: (CreateApplicationRegistrationGenqlSelection & { __args: {input: CreateApplicationRegistrationInput} })
+ updateApplicationRegistration?: (ApplicationRegistrationGenqlSelection & { __args: {input: UpdateApplicationRegistrationInput} })
+ deleteApplicationRegistration?: { __args: {id: Scalars['String']} }
+ rotateApplicationRegistrationClientSecret?: (RotateClientSecretGenqlSelection & { __args: {id: Scalars['String']} })
+ createApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: CreateApplicationRegistrationVariableInput} })
+ updateApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: UpdateApplicationRegistrationVariableInput} })
+ deleteApplicationRegistrationVariable?: { __args: {id: Scalars['String']} }
+ uploadAppTarball?: (ApplicationRegistrationGenqlSelection & { __args: {file: Scalars['Upload'], universalIdentifier?: (Scalars['String'] | null)} })
+ transferApplicationRegistrationOwnership?: (ApplicationRegistrationGenqlSelection & { __args: {applicationRegistrationId: Scalars['String'], targetWorkspaceSubdomain: Scalars['String']} })
createChatThread?: AgentChatThreadGenqlSelection
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} })
stopAgentChatStream?: { __args: {threadId: Scalars['UUID']} }
@@ -6113,15 +6122,6 @@ export interface MutationGenqlSelection{
generatePlaygroundToken?: AuthTokenGenqlSelection
emailPasswordResetLink?: (EmailPasswordResetLinkGenqlSelection & { __args: {email: Scalars['String'], workspaceId?: (Scalars['UUID'] | null)} })
updatePasswordViaResetToken?: (InvalidatePasswordGenqlSelection & { __args: {passwordResetToken: Scalars['String'], newPassword: Scalars['String']} })
- createApplicationRegistration?: (CreateApplicationRegistrationGenqlSelection & { __args: {input: CreateApplicationRegistrationInput} })
- updateApplicationRegistration?: (ApplicationRegistrationGenqlSelection & { __args: {input: UpdateApplicationRegistrationInput} })
- deleteApplicationRegistration?: { __args: {id: Scalars['String']} }
- rotateApplicationRegistrationClientSecret?: (RotateClientSecretGenqlSelection & { __args: {id: Scalars['String']} })
- createApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: CreateApplicationRegistrationVariableInput} })
- updateApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: UpdateApplicationRegistrationVariableInput} })
- deleteApplicationRegistrationVariable?: { __args: {id: Scalars['String']} }
- uploadAppTarball?: (ApplicationRegistrationGenqlSelection & { __args: {file: Scalars['Upload'], universalIdentifier?: (Scalars['String'] | null)} })
- transferApplicationRegistrationOwnership?: (ApplicationRegistrationGenqlSelection & { __args: {applicationRegistrationId: Scalars['String'], targetWorkspaceSubdomain: Scalars['String']} })
initiateOTPProvisioning?: (InitiateTwoFactorAuthenticationProvisioningGenqlSelection & { __args: {loginToken: Scalars['String'], origin: Scalars['String']} })
initiateOTPProvisioningForAuthenticatedUser?: InitiateTwoFactorAuthenticationProvisioningGenqlSelection
deleteTwoFactorAuthenticationMethod?: (DeleteTwoFactorAuthenticationMethodGenqlSelection & { __args: {twoFactorAuthenticationMethodId: Scalars['UUID']} })
@@ -6494,16 +6494,6 @@ export interface UpdateCalendarChannelInput {id: Scalars['UUID'],update: UpdateC
export interface UpdateCalendarChannelInputUpdates {visibility?: (CalendarChannelVisibility | null),isContactAutoCreationEnabled?: (Scalars['Boolean'] | null),contactAutoCreationPolicy?: (CalendarChannelContactAutoCreationPolicy | null),isSyncEnabled?: (Scalars['Boolean'] | null)}
-export interface FileAttachmentInput {id: Scalars['UUID'],filename: Scalars['String']}
-
-export interface CreateSkillInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],label: Scalars['String'],icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content: Scalars['String']}
-
-export interface UpdateSkillInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null)}
-
-export interface GetAuthorizationUrlForSSOInput {identityProviderId: Scalars['UUID'],workspaceInviteHash?: (Scalars['String'] | null)}
-
-export interface SignUpInNewWorkspaceInput {displayName?: (Scalars['String'] | null),subdomain?: (Scalars['String'] | null)}
-
export interface CreateApplicationRegistrationInput {name: Scalars['String'],universalIdentifier?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null)}
export interface UpdateApplicationRegistrationInput {id: Scalars['String'],update: UpdateApplicationRegistrationPayload}
@@ -6516,6 +6506,16 @@ export interface UpdateApplicationRegistrationVariableInput {id: Scalars['String
export interface UpdateApplicationRegistrationVariablePayload {value?: (Scalars['String'] | null),resetValue?: (Scalars['Boolean'] | null),description?: (Scalars['String'] | null)}
+export interface FileAttachmentInput {id: Scalars['UUID'],filename: Scalars['String']}
+
+export interface CreateSkillInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],label: Scalars['String'],icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content: Scalars['String']}
+
+export interface UpdateSkillInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null)}
+
+export interface GetAuthorizationUrlForSSOInput {identityProviderId: Scalars['UUID'],workspaceInviteHash?: (Scalars['String'] | null)}
+
+export interface SignUpInNewWorkspaceInput {displayName?: (Scalars['String'] | null),subdomain?: (Scalars['String'] | null)}
+
export interface UpdateWorkspaceMemberSettingsInput {workspaceMemberId: Scalars['UUID'],update: Scalars['JSON']}
export interface ActivateWorkspaceInput {
diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts
index 1890eacf29..6080ff530a 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/types.ts
+++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts
@@ -6418,6 +6418,63 @@ export default {
]
}
],
+ "findApplicationRegistrationByClientId": [
+ 201,
+ {
+ "clientId": [
+ 1,
+ "String!"
+ ]
+ }
+ ],
+ "findApplicationRegistrationByUniversalIdentifier": [
+ 7,
+ {
+ "universalIdentifier": [
+ 1,
+ "String!"
+ ]
+ }
+ ],
+ "findManyApplicationRegistrations": [
+ 7
+ ],
+ "findOneApplicationRegistration": [
+ 7,
+ {
+ "id": [
+ 1,
+ "String!"
+ ]
+ }
+ ],
+ "findApplicationRegistrationStats": [
+ 199,
+ {
+ "id": [
+ 1,
+ "String!"
+ ]
+ }
+ ],
+ "findApplicationRegistrationVariables": [
+ 174,
+ {
+ "applicationRegistrationId": [
+ 1,
+ "String!"
+ ]
+ }
+ ],
+ "applicationRegistrationTarballUrl": [
+ 1,
+ {
+ "id": [
+ 1,
+ "String!"
+ ]
+ }
+ ],
"minimalMetadata": [
336
],
@@ -6546,63 +6603,6 @@ export default {
]
}
],
- "findApplicationRegistrationByClientId": [
- 201,
- {
- "clientId": [
- 1,
- "String!"
- ]
- }
- ],
- "findApplicationRegistrationByUniversalIdentifier": [
- 7,
- {
- "universalIdentifier": [
- 1,
- "String!"
- ]
- }
- ],
- "findManyApplicationRegistrations": [
- 7
- ],
- "findOneApplicationRegistration": [
- 7,
- {
- "id": [
- 1,
- "String!"
- ]
- }
- ],
- "findApplicationRegistrationStats": [
- 199,
- {
- "id": [
- 1,
- "String!"
- ]
- }
- ],
- "findApplicationRegistrationVariables": [
- 174,
- {
- "applicationRegistrationId": [
- 1,
- "String!"
- ]
- }
- ],
- "applicationRegistrationTarballUrl": [
- 1,
- {
- "id": [
- 1,
- "String!"
- ]
- }
- ],
"currentUser": [
79
],
@@ -8157,6 +8157,94 @@ export default {
]
}
],
+ "createApplicationRegistration": [
+ 200,
+ {
+ "input": [
+ 465,
+ "CreateApplicationRegistrationInput!"
+ ]
+ }
+ ],
+ "updateApplicationRegistration": [
+ 7,
+ {
+ "input": [
+ 466,
+ "UpdateApplicationRegistrationInput!"
+ ]
+ }
+ ],
+ "deleteApplicationRegistration": [
+ 6,
+ {
+ "id": [
+ 1,
+ "String!"
+ ]
+ }
+ ],
+ "rotateApplicationRegistrationClientSecret": [
+ 202,
+ {
+ "id": [
+ 1,
+ "String!"
+ ]
+ }
+ ],
+ "createApplicationRegistrationVariable": [
+ 5,
+ {
+ "input": [
+ 468,
+ "CreateApplicationRegistrationVariableInput!"
+ ]
+ }
+ ],
+ "updateApplicationRegistrationVariable": [
+ 5,
+ {
+ "input": [
+ 469,
+ "UpdateApplicationRegistrationVariableInput!"
+ ]
+ }
+ ],
+ "deleteApplicationRegistrationVariable": [
+ 6,
+ {
+ "id": [
+ 1,
+ "String!"
+ ]
+ }
+ ],
+ "uploadAppTarball": [
+ 7,
+ {
+ "file": [
+ 358,
+ "Upload!"
+ ],
+ "universalIdentifier": [
+ 1
+ ]
+ }
+ ],
+ "transferApplicationRegistrationOwnership": [
+ 7,
+ {
+ "applicationRegistrationId": [
+ 1,
+ "String!"
+ ],
+ "targetWorkspaceSubdomain": [
+ 1,
+ "String!"
+ ]
+ }
+ ],
"createChatThread": [
316
],
@@ -8182,7 +8270,7 @@ export default {
1
],
"fileAttachments": [
- 465,
+ 471,
"[FileAttachmentInput!]"
]
}
@@ -8249,7 +8337,7 @@ export default {
314,
{
"input": [
- 466,
+ 472,
"CreateSkillInput!"
]
}
@@ -8258,7 +8346,7 @@ export default {
314,
{
"input": [
- 467,
+ 473,
"UpdateSkillInput!"
]
}
@@ -8316,7 +8404,7 @@ export default {
247,
{
"input": [
- 468,
+ 474,
"GetAuthorizationUrlForSSOInput!"
]
}
@@ -8482,7 +8570,7 @@ export default {
250,
{
"input": [
- 469
+ 475
]
}
],
@@ -8587,94 +8675,6 @@ export default {
]
}
],
- "createApplicationRegistration": [
- 200,
- {
- "input": [
- 470,
- "CreateApplicationRegistrationInput!"
- ]
- }
- ],
- "updateApplicationRegistration": [
- 7,
- {
- "input": [
- 471,
- "UpdateApplicationRegistrationInput!"
- ]
- }
- ],
- "deleteApplicationRegistration": [
- 6,
- {
- "id": [
- 1,
- "String!"
- ]
- }
- ],
- "rotateApplicationRegistrationClientSecret": [
- 202,
- {
- "id": [
- 1,
- "String!"
- ]
- }
- ],
- "createApplicationRegistrationVariable": [
- 5,
- {
- "input": [
- 473,
- "CreateApplicationRegistrationVariableInput!"
- ]
- }
- ],
- "updateApplicationRegistrationVariable": [
- 5,
- {
- "input": [
- 474,
- "UpdateApplicationRegistrationVariableInput!"
- ]
- }
- ],
- "deleteApplicationRegistrationVariable": [
- 6,
- {
- "id": [
- 1,
- "String!"
- ]
- }
- ],
- "uploadAppTarball": [
- 7,
- {
- "file": [
- 358,
- "Upload!"
- ],
- "universalIdentifier": [
- 1
- ]
- }
- ],
- "transferApplicationRegistrationOwnership": [
- 7,
- {
- "applicationRegistrationId": [
- 1,
- "String!"
- ],
- "targetWorkspaceSubdomain": [
- 1,
- "String!"
- ]
- }
- ],
"initiateOTPProvisioning": [
241,
{
@@ -11229,6 +11229,96 @@ export default {
1
]
},
+ "CreateApplicationRegistrationInput": {
+ "name": [
+ 1
+ ],
+ "universalIdentifier": [
+ 1
+ ],
+ "oAuthRedirectUris": [
+ 1
+ ],
+ "oAuthScopes": [
+ 1
+ ],
+ "__typename": [
+ 1
+ ]
+ },
+ "UpdateApplicationRegistrationInput": {
+ "id": [
+ 1
+ ],
+ "update": [
+ 467
+ ],
+ "__typename": [
+ 1
+ ]
+ },
+ "UpdateApplicationRegistrationPayload": {
+ "name": [
+ 1
+ ],
+ "oAuthRedirectUris": [
+ 1
+ ],
+ "oAuthScopes": [
+ 1
+ ],
+ "isListed": [
+ 6
+ ],
+ "__typename": [
+ 1
+ ]
+ },
+ "CreateApplicationRegistrationVariableInput": {
+ "applicationRegistrationId": [
+ 1
+ ],
+ "key": [
+ 1
+ ],
+ "value": [
+ 1
+ ],
+ "description": [
+ 1
+ ],
+ "isSecret": [
+ 6
+ ],
+ "__typename": [
+ 1
+ ]
+ },
+ "UpdateApplicationRegistrationVariableInput": {
+ "id": [
+ 1
+ ],
+ "update": [
+ 470
+ ],
+ "__typename": [
+ 1
+ ]
+ },
+ "UpdateApplicationRegistrationVariablePayload": {
+ "value": [
+ 1
+ ],
+ "resetValue": [
+ 6
+ ],
+ "description": [
+ 1
+ ],
+ "__typename": [
+ 1
+ ]
+ },
"FileAttachmentInput": {
"id": [
3
@@ -11311,96 +11401,6 @@ export default {
1
]
},
- "CreateApplicationRegistrationInput": {
- "name": [
- 1
- ],
- "universalIdentifier": [
- 1
- ],
- "oAuthRedirectUris": [
- 1
- ],
- "oAuthScopes": [
- 1
- ],
- "__typename": [
- 1
- ]
- },
- "UpdateApplicationRegistrationInput": {
- "id": [
- 1
- ],
- "update": [
- 472
- ],
- "__typename": [
- 1
- ]
- },
- "UpdateApplicationRegistrationPayload": {
- "name": [
- 1
- ],
- "oAuthRedirectUris": [
- 1
- ],
- "oAuthScopes": [
- 1
- ],
- "isListed": [
- 6
- ],
- "__typename": [
- 1
- ]
- },
- "CreateApplicationRegistrationVariableInput": {
- "applicationRegistrationId": [
- 1
- ],
- "key": [
- 1
- ],
- "value": [
- 1
- ],
- "description": [
- 1
- ],
- "isSecret": [
- 6
- ],
- "__typename": [
- 1
- ]
- },
- "UpdateApplicationRegistrationVariableInput": {
- "id": [
- 1
- ],
- "update": [
- 475
- ],
- "__typename": [
- 1
- ]
- },
- "UpdateApplicationRegistrationVariablePayload": {
- "value": [
- 1
- ],
- "resetValue": [
- 6
- ],
- "description": [
- 1
- ],
- "__typename": [
- 1
- ]
- },
"UpdateWorkspaceMemberSettingsInput": {
"workspaceMemberId": [
3
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 a5de428fd9..31e4e1d22e 100644
--- a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx
+++ b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx
@@ -60,6 +60,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.*`
+- **serverWebhook**: Receives inbound webhooks from a third-party service (Stripe, GitHub, Svix, …) at a single registration-scoped endpoint and resolves the target workspace from the payload. See [Server webhook trigger](#server-webhook-trigger).
You can also manually execute a function using the CLI:
@@ -172,6 +173,90 @@ For security reasons, response headers are restricted to an allow-list. Any head
The status code must be a valid HTTP status code (between 100 and 599). Response header names are matched case-insensitively.
+#### Server webhook trigger
+
+`httpRouteTriggerSettings` exposes a function under `/s/` and resolves the workspace from the request host — which works when each workspace has its own domain. Third-party providers, however, deliver every tenant's events to **one** webhook URL. For that case, use `serverWebhookTriggerSettings`: the function is reachable at a registration-scoped endpoint and the workspace is resolved from the payload.
+
+```ts src/logic-functions/handle-provider-webhook.logic-function.ts
+import { defineLogicFunction } from 'twenty-sdk/define';
+import type { RoutePayload } from 'twenty-sdk/logic-function';
+import { Response } from 'twenty-sdk/logic-function';
+
+const handler = async (event: RoutePayload) => {
+ // Verify the signature yourself before doing anything (see below).
+ // Return a non-2xx Response to make the provider retry.
+ return { received: true };
+};
+
+export default defineLogicFunction({
+ universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
+ name: 'handle-provider-webhook',
+ handler,
+ serverWebhookTriggerSettings: {
+ workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
+ forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'],
+ },
+});
+```
+
+The function is reachable at:
+
+```
+POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier
+```
+
+Both identifiers are the `universalIdentifier`s from your manifest — the application registration's and this logic function's. Register that URL with the provider.
+
+**Workspace resolution.** Because one endpoint serves every workspace, your integration must put the target `workspaceId` somewhere in the delivery, and `workspaceIdResolver.{ source, path }` tells the platform where to read it:
+
+| Field | Values | Notes |
+|-------|--------|-------|
+| `source` | `body` \| `query` \| `header` | `body` reads the parsed JSON. `query` is the most universal — you usually control the callback URL you register, so append `?twentyWorkspaceId=…`. |
+| `path` | dot-path, e.g. `metadata.twentyWorkspaceId` | Restricted to alphanumeric / `_` / `-` segments; prototype keys are rejected. |
+
+The resolved value must be a valid workspace UUID **and** your app must be installed in that workspace, otherwise the request is rejected before the function runs.
+
+
+**Signature verification is your responsibility.** The platform does not verify webhook signatures for this trigger — it only resolves the workspace and runs your function. Your handler must verify the signature itself using `event.rawBody` and the headers you listed in `forwardedRequestHeaders`, comparing against a secret stored as a server/application variable. Always verify **before** any side effect, and use a constant-time comparison.
+
+
+Most providers sign with HMAC-SHA256; the parts that differ are the header name, the digest encoding, and the signed-payload string. A few examples:
+
+| Provider | Headers to forward | Signed string | Digest |
+|----------|--------------------|---------------|--------|
+| Svix (Recall, Resend, Clerk) | `webhook-id`, `webhook-timestamp`, `webhook-signature` | `{id}.{timestamp}.{rawBody}` | base64 (secret is base64 after stripping `whsec_`) |
+| Stripe | `stripe-signature` | `{timestamp}.{rawBody}` | hex |
+| GitHub | `x-hub-signature-256` | `{rawBody}` | hex (prefixed `sha256=`) |
+| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
+| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex (prefixed `v0=`) |
+
+```ts
+import { createHmac, timingSafeEqual } from 'crypto';
+
+const handler = async (event: RoutePayload) => {
+ const signature = event.headers['x-hub-signature-256'] ?? '';
+ const expected =
+ 'sha256=' +
+ createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '')
+ .update(event.rawBody ?? '')
+ .digest('hex');
+
+ const a = Buffer.from(signature);
+ const b = Buffer.from(expected);
+
+ if (a.length !== b.length || !timingSafeEqual(a, b)) {
+ return new Response({ error: 'invalid signature' }, { status: 401 });
+ }
+
+ // ...handle the verified event
+ return { received: true };
+};
+```
+
+
+The function runs **synchronously** and your returned value becomes the HTTP response, so providers see your status code and can retry on non-2xx. Keep handlers fast — some providers (e.g. Slack) time out in a few seconds. Because the function runs before the signature is checked, protect this endpoint with rate limiting at your edge.
+
+
#### Database event trigger payload
When a database event trigger invokes your logic function, it receives one `DatabaseEventPayload` per changed record. The payload combines metadata about the source workspace and object with the record-level event.
diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.module.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.module.ts
index f23e2b384e..9475c3a504 100644
--- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.module.ts
@@ -7,6 +7,7 @@ import { LogicFunctionTriggerJob } from 'src/engine/core-modules/logic-function/
import { CronTriggerCronCommand } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.command';
import { CronTriggerCronJob } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.job';
import { CallDatabaseEventTriggerJobsJob } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/call-database-event-trigger-jobs.job';
+import { LogicFunctionTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service';
import { RouteTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
@@ -24,8 +25,13 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
CronTriggerCronJob,
CronTriggerCronCommand,
CallDatabaseEventTriggerJobsJob,
+ LogicFunctionTriggerService,
+ RouteTriggerService,
+ ],
+ exports: [
+ CronTriggerCronCommand,
+ LogicFunctionTriggerService,
RouteTriggerService,
],
- exports: [CronTriggerCronCommand, RouteTriggerService],
})
export class LogicFunctionTriggerModule {}
diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service.ts
new file mode 100644
index 0000000000..dc6b030d42
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service.ts
@@ -0,0 +1,67 @@
+import { Injectable } from '@nestjs/common';
+
+import { Request } from 'express';
+import { isDefined } from 'twenty-shared/utils';
+
+import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
+import { buildLogicFunctionEvent } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util';
+import {
+ RouteTriggerResponse,
+ buildRouteTriggerResponse,
+} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
+import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
+
+export type LogicFunctionTriggerOutcome =
+ | { kind: 'response'; response: RouteTriggerResponse }
+ | { kind: 'userError'; errorMessage: string };
+
+@Injectable()
+export class LogicFunctionTriggerService {
+ constructor(
+ private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
+ ) {}
+
+ async run({
+ logicFunction,
+ request,
+ pathParameters,
+ forwardedRequestHeaders,
+ userId,
+ userWorkspaceId,
+ }: {
+ logicFunction: LogicFunctionEntity;
+ request: Request;
+ pathParameters: Record;
+ forwardedRequestHeaders: string[];
+ userId?: string | null;
+ userWorkspaceId?: string | null;
+ }): Promise {
+ const event = buildLogicFunctionEvent({
+ request,
+ pathParameters,
+ forwardedRequestHeaders,
+ userWorkspaceId: userWorkspaceId ?? null,
+ });
+
+ const result = await this.logicFunctionExecutorService.execute({
+ logicFunctionId: logicFunction.id,
+ workspaceId: logicFunction.workspaceId,
+ payload: event,
+ ...(isDefined(userId) ? { userId } : {}),
+ ...(isDefined(userWorkspaceId) ? { userWorkspaceId } : {}),
+ });
+
+ if (!isDefined(result)) {
+ return { kind: 'response', response: buildRouteTriggerResponse(result) };
+ }
+
+ if (result.error) {
+ return { kind: 'userError', errorMessage: result.error.errorMessage };
+ }
+
+ return {
+ kind: 'response',
+ response: buildRouteTriggerResponse(result.data),
+ };
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/__tests__/route-trigger.service.spec.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/__tests__/route-trigger.service.spec.ts
index 582c1267b0..d1908d114a 100644
--- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/__tests__/route-trigger.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/__tests__/route-trigger.service.spec.ts
@@ -1,6 +1,6 @@
import { LOGIC_FUNCTION_HTTP_RESPONSE_MARKER } from 'twenty-shared/types';
-import { buildRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
+import { buildRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
describe('buildRouteTriggerResponse', () => {
it('wraps a plain body with status 200 and no headers', () => {
diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts
index af7b5a8e45..4a4cc764d6 100644
--- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts
@@ -5,7 +5,7 @@ import { Request } from 'express';
import { match } from 'path-to-regexp';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { IsNull, Not, Repository } from 'typeorm';
-import { HTTPMethod, isLogicFunctionHttpResponse } from 'twenty-shared/types';
+import { HTTPMethod } from 'twenty-shared/types';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
@@ -13,7 +13,7 @@ import {
RouteTriggerException,
RouteTriggerExceptionCode,
} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception';
-import { buildLogicFunctionEvent } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util';
+import { LogicFunctionTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service';
import {
LogicFunctionException,
LogicFunctionExceptionCode,
@@ -22,37 +22,16 @@ import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/
import {
LogicFunctionExecutionException,
LogicFunctionExecutionExceptionCode,
- LogicFunctionExecutorService,
} from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
import { CustomException } from 'src/utils/custom-exception';
-export type RouteTriggerResponse = {
- statusCode: number;
- headers: Record;
- body: unknown;
-};
-
-export const buildRouteTriggerResponse = (
- data: unknown,
-): RouteTriggerResponse => {
- if (isLogicFunctionHttpResponse(data)) {
- return {
- statusCode: data.status ?? 200,
- headers: data.headers ?? {},
- body: data.body,
- };
- }
-
- return { statusCode: 200, headers: {}, body: data };
-};
-
@Injectable()
export class RouteTriggerService {
private readonly logger = new Logger(RouteTriggerService.name);
constructor(
private readonly accessTokenService: AccessTokenService,
- private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
+ private readonly logicFunctionTriggerService: LogicFunctionTriggerService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
@InjectRepository(LogicFunctionEntity)
private readonly logicFunctionRepository: Repository,
@@ -203,22 +182,17 @@ export class RouteTriggerService {
userId = authContext.user?.id ?? null;
}
- const event = buildLogicFunctionEvent({
- request,
- pathParameters: pathParams,
- forwardedRequestHeaders: httpRouteSettings?.forwardedRequestHeaders ?? [],
- userWorkspaceId,
- });
-
- let result;
+ let outcome;
try {
- result = await this.logicFunctionExecutorService.execute({
- logicFunctionId: logicFunction.id,
- workspaceId: logicFunction.workspaceId,
- payload: event,
- ...(userId ? { userId } : {}),
- ...(userWorkspaceId ? { userWorkspaceId } : {}),
+ outcome = await this.logicFunctionTriggerService.run({
+ logicFunction,
+ request,
+ pathParameters: pathParams,
+ forwardedRequestHeaders:
+ httpRouteSettings?.forwardedRequestHeaders ?? [],
+ userId,
+ userWorkspaceId,
});
} catch (error) {
if (error instanceof RouteTriggerException) {
@@ -244,17 +218,13 @@ export class RouteTriggerService {
);
}
- if (!isDefined(result)) {
- return buildRouteTriggerResponse(result);
- }
-
- if (result.error) {
+ if (outcome.kind === 'userError') {
throw new RouteTriggerException(
- result.error.errorMessage,
+ outcome.errorMessage,
RouteTriggerExceptionCode.ROUTE_TRIGGER_USER_UNCAUGHT_ERROR,
);
}
- return buildRouteTriggerResponse(result.data);
+ return outcome.response;
}
}
diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts
index af641c3b81..7cd767632c 100644
--- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts
+++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts
@@ -2,6 +2,7 @@ import { type RawBodyRequest } from '@nestjs/common';
import { type Request } from 'express';
import { type LogicFunctionEvent } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
+import { isObject, isString } from '@sniptt/guards';
export const filterRequestHeaders = ({
requestHeaders,
@@ -40,15 +41,15 @@ export const extractRawBody = (request: Request): string | undefined => {
};
export const extractBody = (request: Request): object | null => {
- if (request.body === undefined || request.body === null) {
+ if (!isDefined(request.body)) {
return null;
}
- if (typeof request.body === 'object' && !Buffer.isBuffer(request.body)) {
+ if (isObject(request.body) && !Buffer.isBuffer(request.body)) {
return request.body;
}
- if (typeof request.body === 'string') {
+ if (isString(request.body)) {
try {
return JSON.parse(request.body);
} catch {
diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util.ts
new file mode 100644
index 0000000000..7f6e725a5c
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util.ts
@@ -0,0 +1,70 @@
+import { type Response } from 'express';
+import { isLogicFunctionHttpResponse } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
+
+export type RouteTriggerResponse = {
+ statusCode: number;
+ headers: Record;
+ body: unknown;
+};
+
+const ALLOWED_RESPONSE_HEADERS = new Set([
+ 'content-type',
+ 'content-language',
+ 'content-disposition',
+ 'cache-control',
+ 'retry-after',
+]);
+
+export const buildRouteTriggerResponse = (
+ data: unknown,
+): RouteTriggerResponse => {
+ if (isLogicFunctionHttpResponse(data)) {
+ return {
+ statusCode: data.status ?? 200,
+ headers: data.headers ?? {},
+ body: data.body,
+ };
+ }
+
+ return { statusCode: 200, headers: {}, body: data };
+};
+
+export const sendRouteTriggerResponse = (
+ response: Response,
+ { statusCode, headers, body }: RouteTriggerResponse,
+) => {
+ response.status(statusCode);
+
+ for (const [key, value] of Object.entries(headers)) {
+ if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) {
+ response.setHeader(key, value);
+ }
+ }
+
+ if (!isDefined(body)) {
+ response.send();
+
+ return;
+ }
+
+ const hasContentType = isDefined(response.getHeader('content-type'));
+
+ if (typeof body === 'string') {
+ if (!hasContentType) {
+ response.setHeader('content-type', 'text/plain');
+ }
+
+ response.send(body);
+
+ return;
+ }
+
+ if (hasContentType) {
+ response.send(JSON.stringify(body));
+
+ return;
+ }
+
+ response.json(body);
+};
diff --git a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/__tests__/server-webhook-trigger.service.spec.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/__tests__/server-webhook-trigger.service.spec.ts
new file mode 100644
index 0000000000..7aadf845ba
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/__tests__/server-webhook-trigger.service.spec.ts
@@ -0,0 +1,172 @@
+import { type Request } from 'express';
+import { type Repository } from 'typeorm';
+
+import { ServerWebhookTriggerService } from 'src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service';
+import { ServerWebhookTriggerExceptionCode } from 'src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception';
+import { type ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
+import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
+import { type LogicFunctionTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service';
+import { type LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
+
+const REGISTRATION_UID = 'reg-universal-id';
+const LOGIC_FUNCTION_UID = 'lf-universal-id';
+const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
+
+const buildRequest = (body: object | null): Request =>
+ ({
+ method: 'POST',
+ path: `/webhooks/server/${REGISTRATION_UID}/${LOGIC_FUNCTION_UID}`,
+ query: {},
+ headers: {},
+ rawBody: Buffer.from(JSON.stringify(body ?? {}), 'utf-8'),
+ body,
+ }) as unknown as Request;
+
+type RegistrationResult = Awaited<
+ ReturnType
+>;
+
+const asRegistration = (value: object): RegistrationResult =>
+ value as unknown as RegistrationResult;
+
+const REGISTRATION_WITH_TRIGGER = asRegistration({
+ id: 'reg-1',
+ manifest: {
+ logicFunctions: [
+ {
+ universalIdentifier: LOGIC_FUNCTION_UID,
+ serverWebhookTriggerSettings: {
+ workspaceIdResolver: {
+ source: 'body',
+ path: 'metadata.twentyWorkspaceId',
+ },
+ },
+ },
+ ],
+ },
+});
+
+describe('ServerWebhookTriggerService', () => {
+ let service: ServerWebhookTriggerService;
+ let applicationRegistrationService: jest.Mocked<
+ Pick
+ >;
+ let logicFunctionTriggerService: jest.Mocked<
+ Pick
+ >;
+ let logicFunctionRepository: jest.Mocked<
+ Pick, 'findOne'>
+ >;
+ let applicationRepository: jest.Mocked<
+ Pick, 'findOne'>
+ >;
+
+ const handle = (
+ body: object | null = { metadata: { twentyWorkspaceId: WORKSPACE_ID } },
+ ) =>
+ service.handle({
+ request: buildRequest(body),
+ applicationRegistrationUniversalIdentifier: REGISTRATION_UID,
+ logicFunctionUniversalIdentifier: LOGIC_FUNCTION_UID,
+ });
+
+ beforeEach(() => {
+ applicationRegistrationService = {
+ findOneByUniversalIdentifier: jest
+ .fn()
+ .mockResolvedValue(REGISTRATION_WITH_TRIGGER),
+ };
+ logicFunctionTriggerService = {
+ run: jest.fn().mockResolvedValue({
+ kind: 'response',
+ response: { statusCode: 200, headers: {}, body: { ok: true } },
+ }),
+ };
+ logicFunctionRepository = {
+ findOne: jest.fn().mockResolvedValue({ id: 'lf-1' }),
+ };
+ applicationRepository = {
+ findOne: jest
+ .fn()
+ .mockResolvedValue({ id: 'app-1', workspaceId: WORKSPACE_ID }),
+ };
+
+ service = new ServerWebhookTriggerService(
+ applicationRegistrationService as unknown as ApplicationRegistrationService,
+ logicFunctionTriggerService as unknown as LogicFunctionTriggerService,
+ logicFunctionRepository as unknown as Repository,
+ applicationRepository as unknown as Repository,
+ );
+ });
+
+ it('resolves the workspace and runs the function synchronously', async () => {
+ const result = await handle();
+
+ expect(applicationRepository.findOne).toHaveBeenCalledWith({
+ where: { workspaceId: WORKSPACE_ID, applicationRegistrationId: 'reg-1' },
+ });
+ expect(logicFunctionTriggerService.run).toHaveBeenCalledWith(
+ expect.objectContaining({ logicFunction: { id: 'lf-1' } }),
+ );
+ expect(result).toEqual({
+ statusCode: 200,
+ headers: {},
+ body: { ok: true },
+ });
+ });
+
+ it('throws when the application registration does not exist', async () => {
+ applicationRegistrationService.findOneByUniversalIdentifier.mockResolvedValue(
+ null,
+ );
+
+ await expect(handle()).rejects.toMatchObject({
+ code: ServerWebhookTriggerExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
+ });
+ });
+
+ it('throws when the logic function has no webhook trigger settings', async () => {
+ applicationRegistrationService.findOneByUniversalIdentifier.mockResolvedValue(
+ asRegistration({ id: 'reg-1', manifest: { logicFunctions: [] } }),
+ );
+
+ await expect(handle()).rejects.toMatchObject({
+ code: ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED,
+ });
+ });
+
+ it('throws when the resolved workspaceId is not a valid uuid', async () => {
+ await expect(
+ handle({ metadata: { twentyWorkspaceId: 'not-a-uuid' } }),
+ ).rejects.toMatchObject({
+ code: ServerWebhookTriggerExceptionCode.WORKSPACE_ID_NOT_RESOLVED,
+ });
+ });
+
+ it('throws when the app is not installed in the resolved workspace', async () => {
+ applicationRepository.findOne.mockResolvedValue(null);
+
+ await expect(handle()).rejects.toMatchObject({
+ code: ServerWebhookTriggerExceptionCode.APPLICATION_NOT_INSTALLED,
+ });
+ });
+
+ it('throws when the function is not installed in the workspace', async () => {
+ logicFunctionRepository.findOne.mockResolvedValue(null);
+
+ await expect(handle()).rejects.toMatchObject({
+ code: ServerWebhookTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
+ });
+ });
+
+ it('surfaces a user uncaught error from the function', async () => {
+ logicFunctionTriggerService.run.mockResolvedValue({
+ kind: 'userError',
+ errorMessage: 'boom',
+ });
+
+ await expect(handle()).rejects.toMatchObject({
+ code: ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_USER_UNCAUGHT_ERROR,
+ });
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger-rest-api-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger-rest-api-exception-filter.ts
new file mode 100644
index 0000000000..1d2d3c57a3
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger-rest-api-exception-filter.ts
@@ -0,0 +1,66 @@
+import {
+ type ArgumentsHost,
+ Catch,
+ type ExceptionFilter,
+} from '@nestjs/common';
+
+import type { Response } from 'express';
+
+import {
+ ServerWebhookTriggerException,
+ ServerWebhookTriggerExceptionCode,
+} from 'src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception';
+import type { CustomException } from 'src/utils/custom-exception';
+import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
+
+@Catch(ServerWebhookTriggerException)
+export class ServerWebhookTriggerRestApiExceptionFilter implements ExceptionFilter {
+ constructor(
+ private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
+ ) {}
+
+ catch(exception: ServerWebhookTriggerException, host: ArgumentsHost) {
+ const ctx = host.switchToHttp();
+ const response = ctx.getResponse();
+
+ switch (exception.code) {
+ case ServerWebhookTriggerExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND:
+ case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED:
+ case ServerWebhookTriggerExceptionCode.APPLICATION_NOT_INSTALLED:
+ case ServerWebhookTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
+ return this.httpExceptionHandlerService.handleError(
+ exception as CustomException,
+ response,
+ 404,
+ );
+ case ServerWebhookTriggerExceptionCode.WORKSPACE_ID_NOT_RESOLVED:
+ return this.httpExceptionHandlerService.handleError(
+ exception as CustomException,
+ response,
+ 400,
+ );
+ case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_USER_UNCAUGHT_ERROR:
+ return this.httpExceptionHandlerService.handleError(
+ exception as CustomException,
+ response,
+ 500,
+ undefined,
+ undefined,
+ { shouldBeCapturedBySentry: false },
+ );
+ case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_PLATFORM_ERROR:
+ return this.httpExceptionHandlerService.handleError(
+ exception as CustomException,
+ response,
+ 500,
+ );
+ default: {
+ return this.httpExceptionHandlerService.handleError(
+ exception as CustomException,
+ response,
+ 400,
+ );
+ }
+ }
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception.ts
new file mode 100644
index 0000000000..4c55e6d713
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception.ts
@@ -0,0 +1,52 @@
+import { type MessageDescriptor } from '@lingui/core';
+import { msg } from '@lingui/core/macro';
+import { assertUnreachable } from 'twenty-shared/utils';
+
+import { CustomException } from 'src/utils/custom-exception';
+
+export enum ServerWebhookTriggerExceptionCode {
+ APPLICATION_REGISTRATION_NOT_FOUND = 'APPLICATION_REGISTRATION_NOT_FOUND',
+ SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED = 'SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED',
+ WORKSPACE_ID_NOT_RESOLVED = 'WORKSPACE_ID_NOT_RESOLVED',
+ APPLICATION_NOT_INSTALLED = 'APPLICATION_NOT_INSTALLED',
+ LOGIC_FUNCTION_NOT_FOUND = 'LOGIC_FUNCTION_NOT_FOUND',
+ SERVER_WEBHOOK_USER_UNCAUGHT_ERROR = 'SERVER_WEBHOOK_USER_UNCAUGHT_ERROR',
+ SERVER_WEBHOOK_PLATFORM_ERROR = 'SERVER_WEBHOOK_PLATFORM_ERROR',
+}
+
+const getServerWebhookTriggerExceptionUserFriendlyMessage = (
+ code: ServerWebhookTriggerExceptionCode,
+) => {
+ switch (code) {
+ case ServerWebhookTriggerExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND:
+ return msg`Application registration not found.`;
+ case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED:
+ return msg`Server webhook trigger is not configured for this application registration.`;
+ case ServerWebhookTriggerExceptionCode.WORKSPACE_ID_NOT_RESOLVED:
+ return msg`Could not resolve a workspace from the webhook payload.`;
+ case ServerWebhookTriggerExceptionCode.APPLICATION_NOT_INSTALLED:
+ return msg`Application is not installed in this workspace.`;
+ case ServerWebhookTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
+ return msg`Logic function not found.`;
+ case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_USER_UNCAUGHT_ERROR:
+ return msg`Logic function execution failed.`;
+ case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_PLATFORM_ERROR:
+ return msg`An unexpected error occurred while handling the webhook.`;
+ default:
+ assertUnreachable(code);
+ }
+};
+
+export class ServerWebhookTriggerException extends CustomException {
+ constructor(
+ message: string,
+ code: ServerWebhookTriggerExceptionCode,
+ { userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
+ ) {
+ super(message, code, {
+ userFriendlyMessage:
+ userFriendlyMessage ??
+ getServerWebhookTriggerExceptionUserFriendlyMessage(code),
+ });
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.controller.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.controller.ts
new file mode 100644
index 0000000000..a10e713527
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.controller.ts
@@ -0,0 +1,47 @@
+import {
+ Controller,
+ Param,
+ Post,
+ Req,
+ Res,
+ UseFilters,
+ UseGuards,
+} from '@nestjs/common';
+
+import { Request, Response } from 'express';
+
+import { ServerWebhookTriggerRestApiExceptionFilter } from 'src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger-rest-api-exception-filter';
+import { ServerWebhookTriggerService } from 'src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service';
+import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
+import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
+import { sendRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
+
+@Controller('webhooks/server')
+@UseGuards(PublicEndpointGuard, NoPermissionGuard)
+@UseFilters(ServerWebhookTriggerRestApiExceptionFilter)
+export class ServerWebhookTriggerController {
+ constructor(
+ private readonly serverWebhookTriggerService: ServerWebhookTriggerService,
+ ) {}
+
+ @Post(
+ ':applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier',
+ )
+ async post(
+ @Param('applicationRegistrationUniversalIdentifier')
+ applicationRegistrationUniversalIdentifier: string,
+ @Param('logicFunctionUniversalIdentifier')
+ logicFunctionUniversalIdentifier: string,
+ @Req() request: Request,
+ @Res() response: Response,
+ ) {
+ sendRouteTriggerResponse(
+ response,
+ await this.serverWebhookTriggerService.handle({
+ request,
+ applicationRegistrationUniversalIdentifier,
+ logicFunctionUniversalIdentifier,
+ }),
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.module.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.module.ts
new file mode 100644
index 0000000000..b95700c68d
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.module.ts
@@ -0,0 +1,20 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
+import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
+import { ServerWebhookTriggerController } from 'src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.controller';
+import { ServerWebhookTriggerService } from 'src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service';
+import { LogicFunctionTriggerModule } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.module';
+import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([LogicFunctionEntity, ApplicationEntity]),
+ ApplicationRegistrationModule,
+ LogicFunctionTriggerModule,
+ ],
+ controllers: [ServerWebhookTriggerController],
+ providers: [ServerWebhookTriggerService],
+})
+export class ServerWebhookTriggerModule {}
diff --git a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service.ts
new file mode 100644
index 0000000000..66703ff5bf
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service.ts
@@ -0,0 +1,184 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+
+import { Request } from 'express';
+import { type ServerWebhookTriggerSettings } from 'twenty-shared/application';
+import { isDefined } from 'twenty-shared/utils';
+import { Repository } from 'typeorm';
+import { validate as uuidValidate } from 'uuid';
+
+import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
+import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
+import {
+ ServerWebhookTriggerException,
+ ServerWebhookTriggerExceptionCode,
+} from 'src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception';
+import { resolveWorkspaceIdFromRequest } from 'src/engine/core-modules/server-webhook-trigger/utils/resolve-workspace-id-from-request.util';
+import {
+ LogicFunctionExecutionException,
+ LogicFunctionExecutionExceptionCode,
+} from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
+import { LogicFunctionTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service';
+import { type RouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
+import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
+
+const WEBHOOK_WORKSPACE_ID_SOURCES = new Set(['body', 'query', 'header']);
+
+@Injectable()
+export class ServerWebhookTriggerService {
+ private readonly logger = new Logger(ServerWebhookTriggerService.name);
+
+ constructor(
+ private readonly applicationRegistrationService: ApplicationRegistrationService,
+ private readonly logicFunctionTriggerService: LogicFunctionTriggerService,
+ @InjectRepository(LogicFunctionEntity)
+ private readonly logicFunctionRepository: Repository,
+ @InjectRepository(ApplicationEntity)
+ private readonly applicationRepository: Repository,
+ ) {}
+
+ private getServerWebhookTriggerSettingsOrThrow(
+ settings: ServerWebhookTriggerSettings | undefined,
+ ): ServerWebhookTriggerSettings {
+ const resolver = settings?.workspaceIdResolver;
+
+ if (
+ !isDefined(resolver) ||
+ !WEBHOOK_WORKSPACE_ID_SOURCES.has(resolver.source) ||
+ typeof resolver.path !== 'string' ||
+ resolver.path.length === 0
+ ) {
+ throw new ServerWebhookTriggerException(
+ 'Server webhook trigger is not configured for this logic function',
+ ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED,
+ );
+ }
+
+ return settings as ServerWebhookTriggerSettings;
+ }
+
+ async handle({
+ request,
+ applicationRegistrationUniversalIdentifier,
+ logicFunctionUniversalIdentifier,
+ }: {
+ request: Request;
+ applicationRegistrationUniversalIdentifier: string;
+ logicFunctionUniversalIdentifier: string;
+ }): Promise {
+ const applicationRegistration =
+ await this.applicationRegistrationService.findOneByUniversalIdentifier(
+ applicationRegistrationUniversalIdentifier,
+ );
+
+ if (!isDefined(applicationRegistration)) {
+ throw new ServerWebhookTriggerException(
+ `Application registration ${applicationRegistrationUniversalIdentifier} not found`,
+ ServerWebhookTriggerExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
+ );
+ }
+
+ const logicFunctionManifest =
+ applicationRegistration.manifest?.logicFunctions?.find(
+ (candidate) =>
+ candidate.universalIdentifier === logicFunctionUniversalIdentifier,
+ );
+
+ const serverWebhookTriggerSettings =
+ this.getServerWebhookTriggerSettingsOrThrow(
+ logicFunctionManifest?.serverWebhookTriggerSettings,
+ );
+
+ const workspaceId = resolveWorkspaceIdFromRequest({
+ resolver: serverWebhookTriggerSettings.workspaceIdResolver,
+ request,
+ });
+
+ if (!isDefined(workspaceId) || !uuidValidate(workspaceId)) {
+ throw new ServerWebhookTriggerException(
+ 'Could not resolve a valid workspaceId from the webhook payload',
+ ServerWebhookTriggerExceptionCode.WORKSPACE_ID_NOT_RESOLVED,
+ );
+ }
+
+ const application = await this.applicationRepository.findOne({
+ where: {
+ workspaceId,
+ applicationRegistrationId: applicationRegistration.id,
+ },
+ });
+
+ if (!isDefined(application)) {
+ throw new ServerWebhookTriggerException(
+ `Application is not installed in workspace ${workspaceId} for this registration`,
+ ServerWebhookTriggerExceptionCode.APPLICATION_NOT_INSTALLED,
+ );
+ }
+
+ const logicFunction = await this.logicFunctionRepository.findOne({
+ where: {
+ workspaceId,
+ applicationId: application.id,
+ universalIdentifier: logicFunctionUniversalIdentifier,
+ },
+ });
+
+ if (!isDefined(logicFunction)) {
+ throw new ServerWebhookTriggerException(
+ `Logic function ${logicFunctionUniversalIdentifier} is not installed in workspace ${workspaceId}`,
+ ServerWebhookTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
+ );
+ }
+
+ let outcome;
+
+ try {
+ outcome = await this.logicFunctionTriggerService.run({
+ logicFunction,
+ request,
+ pathParameters: {},
+ forwardedRequestHeaders:
+ serverWebhookTriggerSettings.forwardedRequestHeaders ?? [],
+ userId: null,
+ userWorkspaceId: null,
+ });
+ } catch (error) {
+ if (error instanceof ServerWebhookTriggerException) {
+ throw error;
+ }
+
+ this.logger.error(
+ `Unexpected error executing logic function ${logicFunction.id}: ${error instanceof Error ? error.message : String(error)}`,
+ error instanceof Error ? error.stack : undefined,
+ );
+
+ throw new ServerWebhookTriggerException(
+ `Logic function execution failed for ${logicFunction.id}`,
+ this.mapErrorToWebhookCode(error),
+ );
+ }
+
+ if (outcome.kind === 'userError') {
+ throw new ServerWebhookTriggerException(
+ outcome.errorMessage,
+ ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_USER_UNCAUGHT_ERROR,
+ );
+ }
+
+ return outcome.response;
+ }
+
+ private mapErrorToWebhookCode(
+ error: unknown,
+ ): ServerWebhookTriggerExceptionCode {
+ if (
+ error instanceof LogicFunctionExecutionException &&
+ error.code ===
+ LogicFunctionExecutionExceptionCode.LOGIC_FUNCTION_NOT_FOUND
+ ) {
+ return ServerWebhookTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND;
+ }
+
+ return ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_PLATFORM_ERROR;
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/__tests__/resolve-workspace-id-from-request.util.spec.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/__tests__/resolve-workspace-id-from-request.util.spec.ts
new file mode 100644
index 0000000000..4fe16a2610
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/__tests__/resolve-workspace-id-from-request.util.spec.ts
@@ -0,0 +1,89 @@
+import { type Request } from 'express';
+
+import { resolveWorkspaceIdFromRequest } from 'src/engine/core-modules/server-webhook-trigger/utils/resolve-workspace-id-from-request.util';
+
+const buildRequest = (overrides: Partial): Request =>
+ ({ query: {}, headers: {}, body: null, ...overrides }) as unknown as Request;
+
+describe('resolveWorkspaceIdFromRequest', () => {
+ it('resolves a nested value from the body', () => {
+ const request = buildRequest({
+ body: { metadata: { twentyWorkspaceId: 'ws-1' } },
+ });
+
+ expect(
+ resolveWorkspaceIdFromRequest({
+ resolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
+ request,
+ }),
+ ).toBe('ws-1');
+ });
+
+ it('resolves from a query parameter', () => {
+ const request = buildRequest({ query: { twentyWorkspaceId: 'ws-2' } });
+
+ expect(
+ resolveWorkspaceIdFromRequest({
+ resolver: { source: 'query', path: 'twentyWorkspaceId' },
+ request,
+ }),
+ ).toBe('ws-2');
+ });
+
+ it('resolves from a header (taking the first value of an array)', () => {
+ const request = buildRequest({
+ headers: { 'x-workspace-id': ['ws-3', 'ws-other'] } as Request['headers'],
+ });
+
+ expect(
+ resolveWorkspaceIdFromRequest({
+ resolver: { source: 'header', path: 'x-workspace-id' },
+ request,
+ }),
+ ).toBe('ws-3');
+ });
+
+ it('rejects prototype-pollution path segments', () => {
+ const request = buildRequest({ body: { metadata: { id: 'ws-4' } } });
+
+ expect(
+ resolveWorkspaceIdFromRequest({
+ resolver: { source: 'body', path: '__proto__.id' },
+ request,
+ }),
+ ).toBeUndefined();
+ });
+
+ it('resolves kebab-case keys (e.g. header names)', () => {
+ const request = buildRequest({ body: { 'a-b': 'ws-5' } });
+
+ expect(
+ resolveWorkspaceIdFromRequest({
+ resolver: { source: 'body', path: 'a-b' },
+ request,
+ }),
+ ).toBe('ws-5');
+ });
+
+ it('rejects path segments with unsafe characters', () => {
+ const request = buildRequest({ body: { 'a b': 'ws-6' } });
+
+ expect(
+ resolveWorkspaceIdFromRequest({
+ resolver: { source: 'body', path: 'a b' },
+ request,
+ }),
+ ).toBeUndefined();
+ });
+
+ it('returns undefined when the value is absent', () => {
+ const request = buildRequest({ body: { metadata: {} } });
+
+ expect(
+ resolveWorkspaceIdFromRequest({
+ resolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
+ request,
+ }),
+ ).toBeUndefined();
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/resolve-workspace-id-from-request.util.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/resolve-workspace-id-from-request.util.ts
new file mode 100644
index 0000000000..2c8ea0fadd
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/resolve-workspace-id-from-request.util.ts
@@ -0,0 +1,79 @@
+import { isArray, isObject, isString } from '@sniptt/guards';
+import { type Request } from 'express';
+import { type ServerWebhookTriggerSettings } from 'twenty-shared/application';
+import { isDefined } from 'twenty-shared/utils';
+
+import { extractBody } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util';
+
+const SAFE_PATH_SEGMENT = /^[A-Za-z0-9_-]+$/;
+const FORBIDDEN_PATH_SEGMENTS = new Set([
+ '__proto__',
+ 'prototype',
+ 'constructor',
+]);
+
+const asRecord = (value: unknown): Record | undefined =>
+ isObject(value) && !isArray(value)
+ ? (value as Record)
+ : undefined;
+
+const asNonEmptyString = (value: unknown): string | undefined =>
+ isString(value) && value.length > 0 ? value : undefined;
+
+const asString = (value: unknown): string | undefined => {
+ if (isArray(value)) {
+ return asNonEmptyString(value[0]);
+ }
+
+ return asNonEmptyString(value);
+};
+
+const getResolverRoot = (
+ source: ServerWebhookTriggerSettings['workspaceIdResolver']['source'],
+ request: Request,
+): Record | undefined => {
+ switch (source) {
+ case 'body':
+ return asRecord(extractBody(request));
+ case 'query':
+ return asRecord(request.query);
+ case 'header':
+ return asRecord(request.headers);
+ default:
+ return undefined;
+ }
+};
+
+export const resolveWorkspaceIdFromRequest = ({
+ resolver,
+ request,
+}: {
+ resolver: ServerWebhookTriggerSettings['workspaceIdResolver'];
+ request: Request;
+}): string | undefined => {
+ const segments = resolver.path.split('.');
+
+ if (
+ segments.length === 0 ||
+ segments.some(
+ (segment) =>
+ !SAFE_PATH_SEGMENT.test(segment) ||
+ FORBIDDEN_PATH_SEGMENTS.has(segment),
+ )
+ ) {
+ return undefined;
+ }
+
+ const root = getResolverRoot(resolver.source, request);
+
+ if (!isDefined(root)) {
+ return undefined;
+ }
+
+ const value = segments.reduce(
+ (current, key) => asRecord(current)?.[key],
+ root,
+ );
+
+ return asString(value);
+};
diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts
index c2ec2f13c0..c4989e1ed3 100644
--- a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts
@@ -22,6 +22,7 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat
import { PermissionFlagModule } from 'src/engine/metadata-modules/permission-flag/permission-flag.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { MinimalMetadataModule } from 'src/engine/metadata-modules/minimal-metadata/minimal-metadata.module';
+import { ServerWebhookTriggerModule } from 'src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { RouteTriggerModule } from 'src/engine/metadata-modules/route-trigger/route-trigger.module';
import { SearchFieldMetadataModule } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.module';
@@ -54,6 +55,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work
PermissionsModule,
PermissionFlagModule,
RouteTriggerModule,
+ ServerWebhookTriggerModule,
WebhookModule,
ConnectedAccountMetadataModule,
MessageChannelMetadataModule,
diff --git a/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts b/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts
index 869fb6212a..446ea4fa87 100644
--- a/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts
@@ -13,23 +13,12 @@ import {
import { Request, Response } from 'express';
import { HTTPMethod } from 'twenty-shared/types';
-import { isDefined } from 'twenty-shared/utils';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { RouteTriggerRestApiExceptionFilter } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter';
-import {
- RouteTriggerResponse,
- RouteTriggerService,
-} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
-
-const ALLOWED_RESPONSE_HEADERS = new Set([
- 'content-type',
- 'content-language',
- 'content-disposition',
- 'cache-control',
- 'retry-after',
-]);
+import { RouteTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
+import { sendRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
@Controller('s')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@@ -39,7 +28,7 @@ export class RouteTriggerController {
@Get('*path')
async get(@Req() request: Request, @Res() response: Response) {
- this.sendResponse(
+ sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
@@ -50,7 +39,7 @@ export class RouteTriggerController {
@Post('*path')
async post(@Req() request: Request, @Res() response: Response) {
- this.sendResponse(
+ sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
@@ -61,7 +50,7 @@ export class RouteTriggerController {
@Put('*path')
async put(@Req() request: Request, @Res() response: Response) {
- this.sendResponse(
+ sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
@@ -72,7 +61,7 @@ export class RouteTriggerController {
@Patch('*path')
async patch(@Req() request: Request, @Res() response: Response) {
- this.sendResponse(
+ sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
@@ -83,7 +72,7 @@ export class RouteTriggerController {
@Delete('*path')
async delete(@Req() request: Request, @Res() response: Response) {
- this.sendResponse(
+ sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
@@ -91,43 +80,4 @@ export class RouteTriggerController {
}),
);
}
-
- private sendResponse(
- response: Response,
- { statusCode, headers, body }: RouteTriggerResponse,
- ) {
- response.status(statusCode);
-
- for (const [key, value] of Object.entries(headers)) {
- if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) {
- response.setHeader(key, value);
- }
- }
-
- if (!isDefined(body)) {
- response.send();
-
- return;
- }
-
- const hasContentType = isDefined(response.getHeader('content-type'));
-
- if (typeof body === 'string') {
- if (!hasContentType) {
- response.setHeader('content-type', 'text/plain');
- }
-
- response.send(body);
-
- return;
- }
-
- if (hasContentType) {
- response.send(JSON.stringify(body));
-
- return;
- }
-
- response.json(body);
- }
}
diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts
index b58faed2f2..5096398907 100644
--- a/packages/twenty-shared/src/application/index.ts
+++ b/packages/twenty-shared/src/application/index.ts
@@ -68,6 +68,10 @@ export type {
} from './roleManifestType';
export type { RunAgentInput, RunAgentResult } from './runAgentType';
export type { ServerVariables } from './server-variables.type';
+export type {
+ WebhookWorkspaceIdSource,
+ ServerWebhookTriggerSettings,
+} from './serverWebhookTriggerSettingsType';
export type { SkillManifest } from './skillManifestType';
export type { StoredOAuthConnectionProviderConfig } from './storedOAuthConnectionProviderConfigType';
export type { SyncableEntityOptions } from './syncableEntityOptionsType';
diff --git a/packages/twenty-shared/src/application/logicFunctionManifestType.ts b/packages/twenty-shared/src/application/logicFunctionManifestType.ts
index 3350fe39b8..17b5163938 100644
--- a/packages/twenty-shared/src/application/logicFunctionManifestType.ts
+++ b/packages/twenty-shared/src/application/logicFunctionManifestType.ts
@@ -1,3 +1,4 @@
+import { type ServerWebhookTriggerSettings } from '@/application/serverWebhookTriggerSettingsType';
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
import { type ToolTriggerSettings } from '@/application/toolTriggerSettingsType';
import { type WorkflowActionTriggerSettings } from '@/application/workflowActionTriggerSettingsType';
@@ -10,6 +11,7 @@ export type LogicFunctionManifest = SyncableEntityOptions & {
cronTriggerSettings?: CronTriggerSettings;
databaseEventTriggerSettings?: DatabaseEventTriggerSettings;
httpRouteTriggerSettings?: HttpRouteTriggerSettings;
+ serverWebhookTriggerSettings?: ServerWebhookTriggerSettings;
toolTriggerSettings?: ToolTriggerSettings;
workflowActionTriggerSettings?: WorkflowActionTriggerSettings;
sourceHandlerPath: string;
diff --git a/packages/twenty-shared/src/application/serverWebhookTriggerSettingsType.ts b/packages/twenty-shared/src/application/serverWebhookTriggerSettingsType.ts
new file mode 100644
index 0000000000..521dbdb828
--- /dev/null
+++ b/packages/twenty-shared/src/application/serverWebhookTriggerSettingsType.ts
@@ -0,0 +1,9 @@
+export type WebhookWorkspaceIdSource = 'body' | 'query' | 'header';
+
+export type ServerWebhookTriggerSettings = {
+ workspaceIdResolver: {
+ source: WebhookWorkspaceIdSource;
+ path: string;
+ };
+ forwardedRequestHeaders?: string[];
+};