From 1044e26293437855919dec2e274ac09f490a6103 Mon Sep 17 00:00:00 2001
From: Janelle Tam <64625892+janelletam@users.noreply.github.com>
Date: Tue, 19 May 2026 14:22:15 -0400
Subject: [PATCH 1/2] feat(integrations/freshdesk): add Freshdesk integration
(#15184)
---
integrations/freshdesk/definitions/actions.ts | 323 ++++++++++++++++++
.../freshdesk/definitions/configuration.ts | 25 ++
integrations/freshdesk/definitions/events.ts | 47 +++
integrations/freshdesk/definitions/index.ts | 3 +
integrations/freshdesk/hub.md | 88 +++++
integrations/freshdesk/icon.svg | 1 +
.../freshdesk/integration.definition.ts | 21 ++
integrations/freshdesk/package.json | 20 ++
integrations/freshdesk/src/events/mappers.ts | 29 ++
integrations/freshdesk/src/events/schemas.ts | 28 ++
.../freshdesk/src/events/ticketCreated.ts | 33 ++
.../freshdesk/src/events/ticketReplied.ts | 33 ++
.../freshdesk/src/events/ticketUpdated.ts | 33 ++
.../src/freshdesk-client/FreshdeskClient.ts | 157 +++++++++
.../actions/action-wrapper.ts | 41 +++
.../src/freshdesk-client/actions/errors.ts | 6 +
.../actions/implementations/addNote.ts | 16 +
.../actions/implementations/const.ts | 4 +
.../actions/implementations/createTicket.ts | 26 ++
.../actions/implementations/deleteTicket.ts | 9 +
.../actions/implementations/getContact.ts | 18 +
.../actions/implementations/getTicket.ts | 23 ++
.../actions/implementations/index.ts | 22 ++
.../actions/implementations/listTickets.ts | 24 ++
.../actions/implementations/searchContacts.ts | 50 +++
.../actions/implementations/searchTickets.ts | 44 +++
.../actions/implementations/updateTicket.ts | 22 ++
.../freshdesk/src/freshdesk-client/types.ts | 113 ++++++
integrations/freshdesk/src/handler.ts | 66 ++++
integrations/freshdesk/src/index.ts | 28 ++
integrations/freshdesk/tsconfig.json | 8 +
pnpm-lock.yaml | 25 +-
32 files changed, 1383 insertions(+), 3 deletions(-)
create mode 100644 integrations/freshdesk/definitions/actions.ts
create mode 100644 integrations/freshdesk/definitions/configuration.ts
create mode 100644 integrations/freshdesk/definitions/events.ts
create mode 100644 integrations/freshdesk/definitions/index.ts
create mode 100644 integrations/freshdesk/hub.md
create mode 100644 integrations/freshdesk/icon.svg
create mode 100644 integrations/freshdesk/integration.definition.ts
create mode 100644 integrations/freshdesk/package.json
create mode 100644 integrations/freshdesk/src/events/mappers.ts
create mode 100644 integrations/freshdesk/src/events/schemas.ts
create mode 100644 integrations/freshdesk/src/events/ticketCreated.ts
create mode 100644 integrations/freshdesk/src/events/ticketReplied.ts
create mode 100644 integrations/freshdesk/src/events/ticketUpdated.ts
create mode 100644 integrations/freshdesk/src/freshdesk-client/FreshdeskClient.ts
create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/action-wrapper.ts
create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/errors.ts
create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/addNote.ts
create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/const.ts
create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/createTicket.ts
create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/deleteTicket.ts
create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/getContact.ts
create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/getTicket.ts
create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/index.ts
create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/listTickets.ts
create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/searchContacts.ts
create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/searchTickets.ts
create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/updateTicket.ts
create mode 100644 integrations/freshdesk/src/freshdesk-client/types.ts
create mode 100644 integrations/freshdesk/src/handler.ts
create mode 100644 integrations/freshdesk/src/index.ts
create mode 100644 integrations/freshdesk/tsconfig.json
diff --git a/integrations/freshdesk/definitions/actions.ts b/integrations/freshdesk/definitions/actions.ts
new file mode 100644
index 00000000000..c6574c571bf
--- /dev/null
+++ b/integrations/freshdesk/definitions/actions.ts
@@ -0,0 +1,323 @@
+import { IntegrationDefinitionProps, z } from '@botpress/sdk'
+
+const ticketSchema = z.object({
+ id: z.number().title('ID').describe('Unique Freshdesk ticket ID.'),
+ subject: z.string().title('Subject').describe('Subject of the ticket.'),
+ description: z.string().nullish().title('Description').describe('HTML content of the ticket description.'),
+ description_text: z.string().nullish().title('Description Text').describe('Plain-text ticket description.'),
+ status: z.enum(['open', 'pending', 'resolved', 'closed']).title('Status').describe('Ticket status.'),
+ priority: z.enum(['low', 'medium', 'high', 'urgent']).title('Priority').describe('Ticket priority.'),
+ source: z.number().nullish().title('Source').describe('Channel through which the ticket was created.'),
+ email: z.string().nullish().title('Email').describe('Requester email address.'),
+ name: z.string().nullish().title('Name').describe('Requester name.'),
+ requester_id: z.number().nullish().title('Requester ID').describe('Freshdesk requester user ID.'),
+ responder_id: z.number().nullish().title('Responder ID').describe('Agent assigned to the ticket.'),
+ group_id: z.number().nullish().title('Group ID').describe('Group the ticket is assigned to.'),
+ type: z.string().nullish().title('Type').describe('Ticket category type.'),
+ tags: z.array(z.string()).nullish().title('Tags').describe('Tags associated with the ticket.'),
+ cc_emails: z.array(z.string()).nullish().title('CC Emails').describe('List of CC email addresses.'),
+ due_by: z.string().nullish().title('Due By').describe('ISO 8601 timestamp for when the ticket is due.'),
+ created_at: z.string().title('Created At').describe('ISO 8601 timestamp of ticket creation.'),
+ updated_at: z.string().title('Updated At').describe('ISO 8601 timestamp of last update.'),
+ custom_fields: z
+ .record(z.string(), z.unknown())
+ .nullish()
+ .title('Custom Fields')
+ .describe('Custom field key-value pairs.'),
+})
+
+export const actions = {
+ createTicket: {
+ title: 'Create Ticket',
+ description: 'Creates a new ticket in Freshdesk.',
+ input: {
+ schema: z.object({
+ subject: z.string().title('Subject').describe('Subject of the ticket.'),
+ description: z.string().title('Description').describe('HTML content of the ticket description.'),
+ email: z.string().title('Email').describe('Requester email address.'),
+ priority: z
+ .enum(['low', 'medium', 'high', 'urgent'])
+ .default('medium')
+ .title('Priority')
+ .describe('Ticket priority: "low", "medium", "high", or "urgent".'),
+ status: z
+ .enum(['open', 'pending', 'resolved', 'closed'])
+ .default('open')
+ .title('Status')
+ .describe('Ticket status: "open", "pending", "resolved", or "closed".'),
+ tags: z.array(z.string()).optional().title('Tags').describe('Tags to associate with the ticket.'),
+ custom_fields: z
+ .record(z.string(), z.unknown())
+ .optional()
+ .title('Custom Fields')
+ .describe('Custom field key-value pairs.'),
+ }),
+ },
+ output: {
+ schema: z.object({
+ id: z.number().title('ID').describe('Unique Freshdesk ticket ID.'),
+ subject: z.string().title('Subject').describe('Subject of the ticket.'),
+ status: z.enum(['open', 'pending', 'resolved', 'closed']).title('Status').describe('Ticket status.'),
+ priority: z.enum(['low', 'medium', 'high', 'urgent']).title('Priority').describe('Ticket priority.'),
+ createdAt: z.string().title('Created At').describe('ISO 8601 timestamp of ticket creation.'),
+ url: z.string().title('URL').describe('URL to view the ticket in Freshdesk.'),
+ }),
+ },
+ },
+ getTicket: {
+ title: 'Get Ticket',
+ description: 'Retrieves a single Freshdesk ticket by ID.',
+ input: {
+ schema: z.object({
+ ticketId: z.coerce.number().int().positive().title('Ticket ID').describe('The Freshdesk ticket ID.'),
+ }),
+ },
+ output: {
+ schema: z.object({
+ id: z.number().title('ID').describe('Unique Freshdesk ticket ID.'),
+ subject: z.string().title('Subject').describe('Subject of the ticket.'),
+ description: z.string().nullish().title('Description').describe('HTML content of the ticket description.'),
+ status: z.enum(['open', 'pending', 'resolved', 'closed']).title('Status').describe('Ticket status.'),
+ priority: z.enum(['low', 'medium', 'high', 'urgent']).title('Priority').describe('Ticket priority.'),
+ requesterId: z.number().nullish().title('Requester ID').describe('Freshdesk requester user ID.'),
+ responderId: z.number().nullish().title('Responder ID').describe('Agent assigned to the ticket.'),
+ groupId: z.number().nullish().title('Group ID').describe('Group the ticket is assigned to.'),
+ createdAt: z.string().title('Created At').describe('ISO 8601 timestamp of ticket creation.'),
+ updatedAt: z.string().title('Updated At').describe('ISO 8601 timestamp of last update.'),
+ tags: z.array(z.string()).nullish().title('Tags').describe('Tags associated with the ticket.'),
+ customFields: z
+ .record(z.string(), z.unknown())
+ .nullish()
+ .title('Custom Fields')
+ .describe('Custom field key-value pairs.'),
+ }),
+ },
+ },
+ listTickets: {
+ title: 'List Tickets',
+ description: 'Lists Freshdesk tickets with optional filters and pagination.',
+ input: {
+ schema: z.object({
+ filter: z
+ .string()
+ .optional()
+ .title('Filter')
+ .describe('Predefined filter name: new_and_my_open, watching, spam, deleted.'),
+ order_by: z
+ .string()
+ .optional()
+ .title('Order By')
+ .describe('Field to sort by: created_at, due_by, updated_at, status.'),
+ order_type: z
+ .enum(['asc', 'desc'])
+ .optional()
+ .title('Order Type')
+ .describe('Sort direction. Defaults to desc.'),
+ per_page: z.number().optional().title('Per Page').describe('Tickets per page (max 100, default 30).'),
+ nextToken: z
+ .string()
+ .optional()
+ .title('Next Token')
+ .describe('Token to continue from the previous page of results.'),
+ }),
+ },
+ output: {
+ schema: z.object({
+ tickets: z.array(ticketSchema).title('Tickets').describe('List of matching tickets.'),
+ nextToken: z
+ .string()
+ .optional()
+ .title('Next Token')
+ .describe('Token to fetch the next page. Absent when there are no more results.'),
+ }),
+ },
+ },
+ updateTicket: {
+ title: 'Update Ticket',
+ description: 'Updates an existing Freshdesk ticket.',
+ input: {
+ schema: z.object({
+ ticketId: z.coerce.number().int().positive().title('Ticket ID').describe('The Freshdesk ticket ID to update.'),
+ status: z
+ .enum(['open', 'pending', 'resolved', 'closed'])
+ .optional()
+ .title('Status')
+ .describe('Updated ticket status: "open", "pending", "resolved", or "closed".'),
+ priority: z
+ .enum(['low', 'medium', 'high', 'urgent'])
+ .optional()
+ .title('Priority')
+ .describe('Updated ticket priority: "low", "medium", "high", or "urgent".'),
+ responderId: z.number().optional().title('Responder ID').describe('ID of the agent to assign the ticket to.'),
+ groupId: z.number().optional().title('Group ID').describe('ID of the group to assign the ticket to.'),
+ custom_fields: z
+ .record(z.string(), z.unknown())
+ .optional()
+ .title('Custom Fields')
+ .describe('Custom field key-value pairs.'),
+ }),
+ },
+ output: {
+ schema: z.object({
+ id: z.number().title('ID').describe('Unique Freshdesk ticket ID.'),
+ status: z.enum(['open', 'pending', 'resolved', 'closed']).title('Status').describe('Updated ticket status.'),
+ priority: z.enum(['low', 'medium', 'high', 'urgent']).title('Priority').describe('Updated ticket priority.'),
+ updatedAt: z.string().title('Updated At').describe('ISO 8601 timestamp of last update.'),
+ }),
+ },
+ },
+ deleteTicket: {
+ title: 'Delete Ticket',
+ description: 'Deletes a Freshdesk ticket. Deleted tickets can be restored from the Freshdesk UI.',
+ input: {
+ schema: z.object({
+ ticketId: z.coerce.number().int().positive().title('Ticket ID').describe('The Freshdesk ticket ID to delete.'),
+ }),
+ },
+ output: {
+ schema: z.object({}),
+ },
+ },
+ searchTickets: {
+ title: 'Search Tickets',
+ description: 'Searches Freshdesk tickets by agent, tag, status, or priority.',
+ input: {
+ schema: z.object({
+ agent_id: z
+ .number()
+ .optional()
+ .title('Agent ID')
+ .describe('Filter by the ID of the agent the ticket is assigned to.'),
+ tag: z.string().optional().title('Tag').describe('Filter by a tag associated with the ticket.'),
+ status: z
+ .enum(['open', 'pending', 'resolved', 'closed'])
+ .optional()
+ .title('Status')
+ .describe('Filter by ticket status: "open", "pending", "resolved", or "closed".'),
+ priority: z
+ .enum(['low', 'medium', 'high', 'urgent'])
+ .optional()
+ .title('Priority')
+ .describe('Filter by ticket priority: "low", "medium", "high", or "urgent".'),
+ limit: z
+ .number()
+ .default(20)
+ .title('Limit')
+ .describe('Maximum number of tickets to return (default 20, max 100).'),
+ nextToken: z
+ .string()
+ .optional()
+ .title('Next Token')
+ .describe('Token to continue from the previous page of results.'),
+ }),
+ },
+ output: {
+ schema: z.object({
+ tickets: z
+ .array(
+ z.object({
+ id: z.number().title('ID').describe('Unique Freshdesk ticket ID.'),
+ subject: z.string().title('Subject').describe('Subject of the ticket.'),
+ status: z.enum(['open', 'pending', 'resolved', 'closed']).title('Status').describe('Ticket status.'),
+ priority: z.enum(['low', 'medium', 'high', 'urgent']).title('Priority').describe('Ticket priority.'),
+ createdAt: z.string().title('Created At').describe('ISO 8601 timestamp of ticket creation.'),
+ requesterEmail: z.string().nullish().title('Requester Email').describe('Email address of the requester.'),
+ })
+ )
+ .title('Tickets')
+ .describe('Matching tickets.'),
+ nextToken: z
+ .string()
+ .optional()
+ .title('Next Token')
+ .describe('Token to fetch the next page. Absent when there are no more results.'),
+ }),
+ },
+ },
+ // TODO: re-add replyToTicket action
+ addNote: {
+ title: 'Add Note',
+ description: 'Adds an internal note to a Freshdesk ticket (not visible to the requester by default).',
+ input: {
+ schema: z.object({
+ ticketId: z.coerce
+ .number()
+ .int()
+ .positive()
+ .title('Ticket ID')
+ .describe('The Freshdesk ticket ID to add a note to.'),
+ body: z.string().title('Body').describe('HTML content of the note.'),
+ private: z
+ .boolean()
+ .optional()
+ .default(true)
+ .title('Private')
+ .describe('Set to false to make the note public. Defaults to true.'),
+ }),
+ },
+ output: {
+ schema: z.object({
+ id: z.number().title('ID').describe('Unique ID of the note.'),
+ body: z.string().title('Body').describe('HTML content of the note.'),
+ createdAt: z.string().title('Created At').describe('ISO 8601 timestamp of note creation.'),
+ }),
+ },
+ },
+ getContact: {
+ title: 'Get Contact',
+ description: 'Retrieves a Freshdesk contact by ID.',
+ input: {
+ schema: z.object({
+ contactId: z.coerce.number().int().positive().title('Contact ID').describe('The Freshdesk contact ID.'),
+ }),
+ },
+ output: {
+ schema: z.object({
+ id: z.number().title('ID').describe('Unique Freshdesk contact ID.'),
+ name: z.string().title('Name').describe('Full name of the contact.'),
+ email: z.string().nullish().title('Email').describe('Email address of the contact.'),
+ phone: z.string().nullish().title('Phone').describe('Phone number of the contact.'),
+ mobile: z.string().nullish().title('Mobile').describe('Mobile number of the contact.'),
+ companyId: z.number().nullish().title('Company ID').describe('ID of the associated company.'),
+ tags: z.array(z.string()).nullish().title('Tags').describe('Tags associated with the contact.'),
+ createdAt: z.string().title('Created At').describe('ISO 8601 timestamp of contact creation.'),
+ }),
+ },
+ },
+ searchContacts: {
+ title: 'Search Contacts',
+ description: 'Finds Freshdesk contacts by email or name.',
+ input: {
+ schema: z.object({
+ email: z.string().optional().title('Email').describe('Filter contacts by exact email address.'),
+ name: z.string().optional().title('Name').describe('Search contacts by name prefix (case-insensitive).'),
+ nextToken: z
+ .string()
+ .optional()
+ .title('Next Token')
+ .describe('Token to continue from the previous page of results.'),
+ }),
+ },
+ output: {
+ schema: z.object({
+ contacts: z
+ .array(
+ z.object({
+ id: z.number().title('ID').describe('Unique Freshdesk contact ID.'),
+ name: z.string().title('Name').describe('Full name of the contact.'),
+ email: z.string().nullish().title('Email').describe('Email address of the contact.'),
+ phone: z.string().nullish().title('Phone').describe('Phone number of the contact.'),
+ companyId: z.number().nullish().title('Company ID').describe('ID of the associated company.'),
+ })
+ )
+ .title('Contacts')
+ .describe('Matching contacts.'),
+ nextToken: z
+ .string()
+ .optional()
+ .title('Next Token')
+ .describe('Token to fetch the next page. Absent when there are no more results.'),
+ }),
+ },
+ },
+} as const satisfies IntegrationDefinitionProps['actions']
diff --git a/integrations/freshdesk/definitions/configuration.ts b/integrations/freshdesk/definitions/configuration.ts
new file mode 100644
index 00000000000..9637ac24885
--- /dev/null
+++ b/integrations/freshdesk/definitions/configuration.ts
@@ -0,0 +1,25 @@
+import { IntegrationDefinitionProps, z } from '@botpress/sdk'
+
+export const configuration = {
+ schema: z.object({
+ domain: z
+ .string()
+ .min(1)
+ .title('Freshdesk Subdomain')
+ .describe('E.g. "yourcompany" from yourcompany.freshdesk.com'),
+ apiKey: z
+ .string()
+ .secret()
+ .min(1)
+ .title('API Key')
+ .describe('Your Freshdesk API key, found under Profile Settings.'),
+ webhookSecret: z
+ .string()
+ .secret()
+ .optional()
+ .title('Webhook Secret')
+ .describe(
+ 'Optional shared secret to authenticate incoming webhooks. Set this value and add it as the X-Webhook-Secret header in each Freshdesk Automation webhook action.'
+ ),
+ }),
+} as const satisfies IntegrationDefinitionProps['configuration']
diff --git a/integrations/freshdesk/definitions/events.ts b/integrations/freshdesk/definitions/events.ts
new file mode 100644
index 00000000000..f32fb89ae48
--- /dev/null
+++ b/integrations/freshdesk/definitions/events.ts
@@ -0,0 +1,47 @@
+import { IntegrationDefinitionProps, z } from '@botpress/sdk'
+
+export const ticketEventSchema = z.object({
+ id: z.number().title('Ticket ID').describe('Freshdesk ticket ID.'),
+ subject: z.string().nullish().title('Subject').describe('Ticket subject.'),
+ status: z.enum(['open', 'pending', 'resolved', 'closed']).nullish().title('Status').describe('Ticket status.'),
+ priority: z.enum(['low', 'medium', 'high', 'urgent']).nullish().title('Priority').describe('Ticket priority.'),
+ requesterId: z.number().nullish().title('Requester ID').describe('Freshdesk requester user ID.'),
+ responderId: z.number().nullish().title('Responder ID').describe('Agent assigned to the ticket.'),
+ groupId: z.number().nullish().title('Group ID').describe('Group the ticket is assigned to.'),
+ type: z.string().nullish().title('Type').describe('Ticket category type.'),
+ tags: z.array(z.string()).nullish().title('Tags').describe('Tags associated with the ticket.'),
+})
+
+export const replyEventSchema = z.object({
+ body: z.string().title('Body').describe('HTML content of the reply.'),
+ bodyText: z.string().optional().title('Body Text').describe('Plain-text content of the reply.'),
+ customerEmail: z.string().optional().title('Customer Email').describe('Email of the customer who replied.'),
+})
+
+export const events = {
+ ticketCreated: {
+ title: 'Ticket Created',
+ description: 'Triggered when a new ticket is created in Freshdesk.',
+ schema: z.object({
+ ticket: ticketEventSchema.title('Ticket').describe('The newly created ticket.'),
+ }),
+ ui: {},
+ },
+ ticketUpdated: {
+ title: 'Ticket Updated',
+ description: 'Triggered when a ticket is updated (status, priority, or assignment change).',
+ schema: z.object({
+ ticket: ticketEventSchema.title('Ticket').describe('The updated ticket.'),
+ }),
+ ui: {},
+ },
+ ticketReplied: {
+ title: 'Ticket Replied',
+ description: 'Triggered when a customer adds a reply to a ticket.',
+ schema: z.object({
+ ticket: ticketEventSchema.title('Ticket').describe('The ticket that received the reply.'),
+ reply: replyEventSchema.title('Reply').describe('The reply that was added.'),
+ }),
+ ui: {},
+ },
+} as const satisfies IntegrationDefinitionProps['events']
diff --git a/integrations/freshdesk/definitions/index.ts b/integrations/freshdesk/definitions/index.ts
new file mode 100644
index 00000000000..f377da9f8d8
--- /dev/null
+++ b/integrations/freshdesk/definitions/index.ts
@@ -0,0 +1,3 @@
+export { actions } from './actions'
+export { configuration } from './configuration'
+export { events } from './events'
diff --git a/integrations/freshdesk/hub.md b/integrations/freshdesk/hub.md
new file mode 100644
index 00000000000..da4250342d2
--- /dev/null
+++ b/integrations/freshdesk/hub.md
@@ -0,0 +1,88 @@
+# Freshdesk
+
+Connect Botpress to Freshdesk to manage support tickets and react to ticket lifecycle events from your bots.
+
+## Ticket Properties
+
+`status` and `priority` are represented as string enums. The integration handles conversion to Freshdesk's internal numeric values.
+
+| Status | String value | Numeric value |
+| -------- | ------------ | ------------- |
+| Open | `open` | 2 |
+| Pending | `pending` | 3 |
+| Resolved | `resolved` | 4 |
+| Closed | `closed` | 5 |
+
+| Priority | String value | Numeric value |
+| -------- | ------------ | ------------- |
+| Low | `low` | 1 |
+| Medium | `medium` | 2 |
+| High | `high` | 3 |
+| Urgent | `urgent` | 4 |
+
+## Events
+
+Events are triggered by Freshdesk **Automation Rules** which you configure manually. Each event corresponds to a different webhook path.
+
+### Webhook setup
+
+1. In your Freshdesk dashboard, go to **Admin → Automations**
+2. Create a new rule for each event you want to receive
+3. Under the rule's **Actions**, add a **Trigger Webhook** action
+4. Set the **Request Type** to `POST` and **encoding** to `json`
+5. Use the following URLs (replace `{webhook-url}` with the URL shown in Botpress after installing the integration):
+
+| Event | Webhook URL |
+| ------------- | ------------------------------ |
+| ticketCreated | `{webhook-url}/ticket-created` |
+| ticketUpdated | `{webhook-url}/ticket-updated` |
+| ticketReplied | `{webhook-url}/ticket-replied` |
+
+6. _(Recommended)_ If you set a **Webhook Secret** in the integration configuration, add a custom request header named `X-Webhook-Secret` with that same value in each Freshdesk Automation webhook action. The integration will reject any webhook that omits or mismatches the secret.
+
+7. In the webhook body, include at minimum the ticket fields your bot needs. The `ticket.id` field is **required** — webhooks missing it will be rejected. All other ticket fields are optional.
+
+ For `ticketCreated` and `ticketUpdated`:
+
+```json
+{
+ "ticket": {
+ "id": "{{ticket.id}}",
+ "subject": "{{ticket.subject}}",
+ "status": "{{ticket.status_id}}",
+ "priority": "{{ticket.priority_id}}",
+ "requester_id": "{{ticket.requester.id}}",
+ "responder_id": "{{ticket.agent.id}}",
+ "group_id": "{{ticket.group.id}}",
+ "type": "{{ticket.ticket_type}}"
+ }
+}
+```
+
+For `ticketReplied`, also include reply fields. The `reply.body` field is **required**:
+
+```json
+{
+ "ticket": {
+ "id": "{{ticket.id}}",
+ "status": "{{ticket.status_id}}",
+ "requester_id": "{{ticket.requester.id}}"
+ },
+ "reply": {
+ "body": "{{ticket.latest_public_comment_html}}",
+ "body_text": "{{ticket.latest_public_comment}}",
+ "customer_email": "{{ticket.contact.email}}"
+ }
+}
+```
+
+## Limitations
+
+- The Search Tickets action scans up to 4 pages (120 results) of Freshdesk search results before applying the `limit` cap
+- Freshdesk webhook setup requires manual configuration via Automation Rules. The integration cannot create them automatically
+- Deleted tickets can be found in the trash page of the Freshdesk UI and can be restored for up to 30 days
+- Ticket attachments are not supported in this integration
+
+## Changelog
+
+- 0.1.0: Initial release with `createTicket`, `getTicket`, `listTickets`, `updateTicket`, `deleteTicket`, `addNote`, `searchTickets`, `searchContacts`, `getContact` actions and `ticketCreated`, `ticketUpdated`, `ticketReplied` events.
diff --git a/integrations/freshdesk/icon.svg b/integrations/freshdesk/icon.svg
new file mode 100644
index 00000000000..ecb5c6e0c6f
--- /dev/null
+++ b/integrations/freshdesk/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/integrations/freshdesk/integration.definition.ts b/integrations/freshdesk/integration.definition.ts
new file mode 100644
index 00000000000..e6eb846d90b
--- /dev/null
+++ b/integrations/freshdesk/integration.definition.ts
@@ -0,0 +1,21 @@
+import { IntegrationDefinition } from '@botpress/sdk'
+import { actions, configuration, events } from './definitions'
+
+// TODO(HITL): add back the ticket channel with freshdeskTicketId conversation tag to enable the bot to send messages into ticket threads
+
+export default new IntegrationDefinition({
+ name: 'freshdesk',
+ title: 'Freshdesk',
+ description: 'Connect Botpress to Freshdesk to create, read, update, delete, and search support tickets.',
+ version: '0.1.0',
+ readme: 'hub.md',
+ icon: 'icon.svg',
+ configuration,
+ actions,
+ events,
+ user: {
+ tags: {
+ freshdeskRequesterId: { title: 'Freshdesk Requester ID', description: 'The ID of the requester in Freshdesk' },
+ },
+ },
+})
diff --git a/integrations/freshdesk/package.json b/integrations/freshdesk/package.json
new file mode 100644
index 00000000000..d1058a0e55a
--- /dev/null
+++ b/integrations/freshdesk/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@botpresshub/freshdesk",
+ "integrationName": "freshdesk/freshdesk",
+ "scripts": {
+ "check:type": "tsc --noEmit",
+ "build": "bp add -y && bp build",
+ "check:bplint": "bp lint"
+ },
+ "private": true,
+ "dependencies": {
+ "@botpress/client": "workspace:*",
+ "@botpress/common": "workspace:*",
+ "@botpress/sdk": "workspace:*",
+ "@botpress/sdk-addons": "workspace:*"
+ },
+ "devDependencies": {
+ "@botpress/cli": "workspace:*",
+ "@botpress/sdk": "workspace:*"
+ }
+}
diff --git a/integrations/freshdesk/src/events/mappers.ts b/integrations/freshdesk/src/events/mappers.ts
new file mode 100644
index 00000000000..b0bd45a4102
--- /dev/null
+++ b/integrations/freshdesk/src/events/mappers.ts
@@ -0,0 +1,29 @@
+import { z } from '@botpress/sdk'
+import { ticketEventSchema, replyEventSchema } from '../../definitions/events'
+import { freshdeskWebhookTicketSchema, freshdeskWebhookReplySchema } from './schemas'
+
+const STATUS_MAP = { 2: 'open', 3: 'pending', 4: 'resolved', 5: 'closed' } as const
+const PRIORITY_MAP = { 1: 'low', 2: 'medium', 3: 'high', 4: 'urgent' } as const
+
+type FreshdeskTicket = z.infer
+type FreshdeskReply = z.infer
+type TicketEvent = z.infer
+type ReplyEvent = z.infer
+
+export const mapTicket = (ticket: FreshdeskTicket): TicketEvent => ({
+ id: ticket.id,
+ subject: ticket.subject,
+ status: ticket.status != null ? (STATUS_MAP[ticket.status as keyof typeof STATUS_MAP] ?? null) : null,
+ priority: ticket.priority != null ? (PRIORITY_MAP[ticket.priority as keyof typeof PRIORITY_MAP] ?? null) : null,
+ requesterId: ticket.requester_id,
+ responderId: ticket.responder_id,
+ groupId: ticket.group_id,
+ type: ticket.type,
+ tags: ticket.tags,
+})
+
+export const mapReply = (reply: FreshdeskReply): ReplyEvent => ({
+ body: reply.body,
+ bodyText: reply.body_text,
+ customerEmail: reply.customer_email,
+})
diff --git a/integrations/freshdesk/src/events/schemas.ts b/integrations/freshdesk/src/events/schemas.ts
new file mode 100644
index 00000000000..e5905593826
--- /dev/null
+++ b/integrations/freshdesk/src/events/schemas.ts
@@ -0,0 +1,28 @@
+import { z } from '@botpress/sdk'
+
+// Raw Freshdesk webhook payload shapes — numeric fields are coerced because
+// Freshdesk sends all template values as strings.
+export const freshdeskWebhookTicketSchema = z.object({
+ id: z.coerce.number(),
+ subject: z.string().nullish(),
+ status: z.coerce.number().nullish(),
+ priority: z.coerce.number().nullish(),
+ requester_id: z.coerce.number().nullish(),
+ responder_id: z.coerce.number().nullish(),
+ group_id: z.coerce.number().nullish(),
+ type: z.string().nullish(),
+ tags: z.array(z.string()).nullish(),
+})
+
+export const freshdeskWebhookReplySchema = z.object({
+ body: z.string(),
+ body_text: z.string().optional(),
+ customer_email: z.string().optional(),
+})
+
+export const ticketCreatedBodySchema = z.object({ ticket: freshdeskWebhookTicketSchema })
+export const ticketUpdatedBodySchema = z.object({ ticket: freshdeskWebhookTicketSchema })
+export const ticketRepliedBodySchema = z.object({
+ ticket: freshdeskWebhookTicketSchema,
+ reply: freshdeskWebhookReplySchema,
+})
diff --git a/integrations/freshdesk/src/events/ticketCreated.ts b/integrations/freshdesk/src/events/ticketCreated.ts
new file mode 100644
index 00000000000..9fbd4a33c10
--- /dev/null
+++ b/integrations/freshdesk/src/events/ticketCreated.ts
@@ -0,0 +1,33 @@
+import { mapTicket } from './mappers'
+import { ticketCreatedBodySchema } from './schemas'
+import * as bp from '.botpress'
+
+type HandlerProps = Parameters[0]
+
+export const executeTicketCreated = async (props: HandlerProps & { body: Record }) => {
+ const { client, body, logger } = props
+ const log = logger.forBot()
+
+ const parsed = ticketCreatedBodySchema.safeParse(body)
+ if (!parsed.success) {
+ log.warn(`ticketCreated webhook has invalid payload: ${parsed.error.message}`)
+ return
+ }
+ const { ticket } = parsed.data
+
+ if (!ticket.requester_id) {
+ log.warn('ticketCreated webhook has no requester_id, skipping event')
+ return
+ }
+
+ const { user } = await client.getOrCreateUser({
+ tags: { freshdeskRequesterId: String(ticket.requester_id) },
+ })
+
+ // TODO(HITL): get or create a conversation on the ticket channel and pass conversationId to createEvent
+ await client.createEvent({
+ type: 'ticketCreated',
+ payload: { ticket: mapTicket(ticket) },
+ userId: user.id,
+ })
+}
diff --git a/integrations/freshdesk/src/events/ticketReplied.ts b/integrations/freshdesk/src/events/ticketReplied.ts
new file mode 100644
index 00000000000..a72503560e5
--- /dev/null
+++ b/integrations/freshdesk/src/events/ticketReplied.ts
@@ -0,0 +1,33 @@
+import { mapTicket, mapReply } from './mappers'
+import { ticketRepliedBodySchema } from './schemas'
+import * as bp from '.botpress'
+
+type HandlerProps = Parameters[0]
+
+export const executeTicketReplied = async (props: HandlerProps & { body: Record }) => {
+ const { client, body, logger } = props
+ const log = logger.forBot()
+
+ const parsed = ticketRepliedBodySchema.safeParse(body)
+ if (!parsed.success) {
+ log.warn(`ticketReplied webhook has invalid payload: ${parsed.error.message}`)
+ return
+ }
+ const { ticket, reply } = parsed.data
+
+ if (!ticket.requester_id) {
+ log.warn('ticketReplied webhook has no requester_id, skipping event')
+ return
+ }
+
+ const { user } = await client.getOrCreateUser({
+ tags: { freshdeskRequesterId: String(ticket.requester_id) },
+ })
+
+ // TODO(HITL): get or create a conversation on the ticket channel and pass conversationId to createEvent
+ await client.createEvent({
+ type: 'ticketReplied',
+ payload: { ticket: mapTicket(ticket), reply: mapReply(reply) },
+ userId: user.id,
+ })
+}
diff --git a/integrations/freshdesk/src/events/ticketUpdated.ts b/integrations/freshdesk/src/events/ticketUpdated.ts
new file mode 100644
index 00000000000..df387266137
--- /dev/null
+++ b/integrations/freshdesk/src/events/ticketUpdated.ts
@@ -0,0 +1,33 @@
+import { mapTicket } from './mappers'
+import { ticketUpdatedBodySchema } from './schemas'
+import * as bp from '.botpress'
+
+type HandlerProps = Parameters[0]
+
+export const executeTicketUpdated = async (props: HandlerProps & { body: Record }) => {
+ const { client, body, logger } = props
+ const log = logger.forBot()
+
+ const parsed = ticketUpdatedBodySchema.safeParse(body)
+ if (!parsed.success) {
+ log.warn(`ticketUpdated webhook has invalid payload: ${parsed.error.message}`)
+ return
+ }
+ const { ticket } = parsed.data
+
+ if (!ticket.requester_id) {
+ log.warn('ticketUpdated webhook has no requester_id, skipping event')
+ return
+ }
+
+ const { user } = await client.getOrCreateUser({
+ tags: { freshdeskRequesterId: String(ticket.requester_id) },
+ })
+
+ // TODO(HITL): get or create a conversation on the ticket channel and pass conversationId to createEvent
+ await client.createEvent({
+ type: 'ticketUpdated',
+ payload: { ticket: mapTicket(ticket) },
+ userId: user.id,
+ })
+}
diff --git a/integrations/freshdesk/src/freshdesk-client/FreshdeskClient.ts b/integrations/freshdesk/src/freshdesk-client/FreshdeskClient.ts
new file mode 100644
index 00000000000..4a5289f945b
--- /dev/null
+++ b/integrations/freshdesk/src/freshdesk-client/FreshdeskClient.ts
@@ -0,0 +1,157 @@
+import * as sdk from '@botpress/sdk'
+import type {
+ AddNoteInput,
+ CreateTicketInput,
+ DeleteTicketInput,
+ FreshdeskContact,
+ FreshdeskConversation,
+ FreshdeskTicket,
+ GetTicketInput,
+ ListTicketsInput,
+ SearchTicketsInput,
+ SearchTicketsOutput,
+ UpdateTicketInput,
+} from './types'
+
+const REQUESTER_FIELDS = ['email', 'phone', 'twitter_id', 'facebook_id', 'unique_external_id', 'requester_id'] as const
+
+export class FreshdeskClient {
+ private _baseUrl: string
+ private _authHeader: string
+
+ public constructor(domain: string, apiKey: string) {
+ this._baseUrl = `https://${domain}.freshdesk.com/api/v2`
+ this._authHeader = `Basic ${Buffer.from(`${apiKey}:X`).toString('base64')}`
+ }
+
+ private _getHeaders(): Record {
+ return {
+ 'Content-Type': 'application/json',
+ Authorization: this._authHeader,
+ }
+ }
+
+ private async _request(
+ method: string,
+ path: string,
+ params?: Record,
+ body?: object
+ ): Promise {
+ let url = `${this._baseUrl}${path}`
+
+ if (params) {
+ const searchParams = new URLSearchParams()
+ for (const [key, value] of Object.entries(params)) {
+ if (value != null && value !== '') {
+ searchParams.set(key, String(value))
+ }
+ }
+ const qs = searchParams.toString()
+ if (qs) {
+ url = `${url}?${qs}`
+ }
+ }
+
+ const response = await fetch(url, {
+ method,
+ headers: this._getHeaders(),
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ })
+
+ if (!response.ok) {
+ const text = await response.text()
+ let detail = text
+ try {
+ const parsed = JSON.parse(text) as Record
+ if (typeof parsed['description'] === 'string') {
+ detail = parsed['description']
+ }
+ if (Array.isArray(parsed['errors']) && parsed['errors'].length > 0) {
+ detail += ` | errors: ${JSON.stringify(parsed['errors'])}`
+ }
+ } catch {
+ // use raw text
+ }
+ if (response.status === 429) {
+ throw new sdk.RuntimeError(`Freshdesk rate limit reached. ${detail}`)
+ }
+ throw new sdk.RuntimeError(`Freshdesk API error ${response.status}: ${detail}`)
+ }
+
+ return response.json() as Promise
+ }
+
+ private async _delete(path: string): Promise {
+ const url = `${this._baseUrl}${path}`
+ const response = await fetch(url, { method: 'DELETE', headers: this._getHeaders() })
+
+ if (!response.ok) {
+ const text = await response.text()
+ throw new sdk.RuntimeError(`Freshdesk API error ${response.status}: ${text}`)
+ }
+ }
+
+ public async validateCredentials(): Promise {
+ await this._request('GET', '/tickets', { per_page: 1 })
+ }
+
+ public async createTicket(input: CreateTicketInput): Promise {
+ const hasRequester = REQUESTER_FIELDS.some((f) => (input as Record)[f] != null)
+ if (!hasRequester) {
+ throw new sdk.RuntimeError(
+ 'At least one requester field must be provided: email, phone, twitter_id, facebook_id, unique_external_id, or requester_id.'
+ )
+ }
+ const body = {
+ status: 2,
+ priority: 1,
+ ...Object.fromEntries(Object.entries(input).filter(([, v]) => v != null && v !== '')),
+ }
+ return this._request('POST', '/tickets', undefined, body)
+ }
+
+ public async getTicket(input: GetTicketInput): Promise {
+ const params = input.include ? { include: input.include } : undefined
+ return this._request('GET', `/tickets/${input.id}`, params)
+ }
+
+ public async listTickets(input: ListTicketsInput): Promise {
+ const { filter, order_by, order_type, page, per_page } = input
+ return this._request('GET', '/tickets', { filter, order_by, order_type, page, per_page })
+ }
+
+ public async updateTicket(input: UpdateTicketInput): Promise {
+ const { id, ...rest } = input
+ const body = Object.fromEntries(Object.entries(rest).filter(([, v]) => v != null && v !== ''))
+ return this._request('PUT', `/tickets/${id}`, undefined, body)
+ }
+
+ public async deleteTicket(input: DeleteTicketInput): Promise {
+ await this._delete(`/tickets/${input.id}`)
+ }
+
+ public async searchTickets(input: SearchTicketsInput): Promise {
+ const query = input.query.startsWith('"') ? input.query : `"${input.query}"`
+ const params: Record = { query, page: input.page }
+ return this._request('GET', '/search/tickets', params)
+ }
+
+ public async addNote(ticketId: number, input: AddNoteInput): Promise {
+ return this._request('POST', `/tickets/${ticketId}/notes`, undefined, {
+ private: true,
+ ...input,
+ })
+ }
+
+ public async getContact(id: number): Promise {
+ return this._request('GET', `/contacts/${id}`)
+ }
+
+ public async searchContactsByEmail(email: string, page?: number): Promise {
+ return this._request('GET', '/contacts', { email, page })
+ }
+
+ public async searchContactsByName(term: string): Promise> {
+ return this._request>('GET', '/contacts/autocomplete', { term })
+ }
+}
diff --git a/integrations/freshdesk/src/freshdesk-client/actions/action-wrapper.ts b/integrations/freshdesk/src/freshdesk-client/actions/action-wrapper.ts
new file mode 100644
index 00000000000..6d270249466
--- /dev/null
+++ b/integrations/freshdesk/src/freshdesk-client/actions/action-wrapper.ts
@@ -0,0 +1,41 @@
+import { createActionWrapper, createAsyncFnWrapperWithErrorRedaction } from '@botpress/common'
+import * as sdk from '@botpress/sdk'
+import { FreshdeskClient } from '../FreshdeskClient'
+import { createFreshdeskRuntimeError } from './errors'
+import * as bp from '.botpress'
+
+export const wrapAction: typeof _wrapAction = (meta, actionImpl) =>
+ _wrapAction(meta, (props) => {
+ const logger = props.logger.forBot()
+
+ logger.debug(`Running action "${meta.actionName}" [bot id: ${props.ctx.botId}]`, { input: props.input })
+
+ return _wrapAsyncFnWithTryCatch(async () => {
+ try {
+ return await actionImpl(props as Parameters[0], props.input)
+ } catch (thrown: unknown) {
+ if (!(thrown instanceof sdk.RuntimeError)) {
+ logger.warn(`Action Error: ${meta.errorMessage}`, {
+ error: thrown instanceof Error ? thrown.message : String(thrown),
+ })
+ }
+ throw thrown
+ }
+ }, `Action Error: ${meta.errorMessage}`)() as ReturnType
+ })
+
+const _wrapAction = createActionWrapper()({
+ toolFactories: {
+ freshdeskClient: ({ ctx }) => new FreshdeskClient(ctx.configuration.domain, ctx.configuration.apiKey),
+ },
+ extraMetadata: {} as {
+ errorMessage: string
+ },
+})
+
+const _wrapAsyncFnWithTryCatch = createAsyncFnWrapperWithErrorRedaction((error: Error) => {
+ if (error instanceof sdk.RuntimeError) {
+ return error
+ }
+ return createFreshdeskRuntimeError(error)
+})
diff --git a/integrations/freshdesk/src/freshdesk-client/actions/errors.ts b/integrations/freshdesk/src/freshdesk-client/actions/errors.ts
new file mode 100644
index 00000000000..5c81d37967b
--- /dev/null
+++ b/integrations/freshdesk/src/freshdesk-client/actions/errors.ts
@@ -0,0 +1,6 @@
+import * as sdk from '@botpress/sdk'
+
+export const getErrorMessage = (thrown: unknown): string => (thrown instanceof Error ? thrown.message : String(thrown))
+
+export const createFreshdeskRuntimeError = (thrown: unknown): sdk.RuntimeError =>
+ new sdk.RuntimeError(getErrorMessage(thrown))
diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/addNote.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/addNote.ts
new file mode 100644
index 00000000000..81b2df3c7e9
--- /dev/null
+++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/addNote.ts
@@ -0,0 +1,16 @@
+import { wrapAction } from '../action-wrapper'
+
+export const addNote = wrapAction(
+ { actionName: 'addNote', errorMessage: 'Failed to add note to Freshdesk ticket' },
+ async ({ freshdeskClient }, input) => {
+ const note = await freshdeskClient.addNote(input.ticketId, {
+ body: input.body,
+ private: input.private ?? true,
+ })
+ return {
+ id: note.id,
+ body: note.body,
+ createdAt: note.created_at,
+ }
+ }
+)
diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/const.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/const.ts
new file mode 100644
index 00000000000..583873c7380
--- /dev/null
+++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/const.ts
@@ -0,0 +1,4 @@
+export const PRIORITY_TO_NUM = { low: 1, medium: 2, high: 3, urgent: 4 } as const
+export const STATUS_TO_NUM = { open: 2, pending: 3, resolved: 4, closed: 5 } as const
+export const NUM_TO_PRIORITY = { 1: 'low', 2: 'medium', 3: 'high', 4: 'urgent' } as const
+export const NUM_TO_STATUS = { 2: 'open', 3: 'pending', 4: 'resolved', 5: 'closed' } as const
diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/createTicket.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/createTicket.ts
new file mode 100644
index 00000000000..eae4087d393
--- /dev/null
+++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/createTicket.ts
@@ -0,0 +1,26 @@
+import { wrapAction } from '../action-wrapper'
+import { NUM_TO_PRIORITY, NUM_TO_STATUS, PRIORITY_TO_NUM, STATUS_TO_NUM } from './const'
+
+export const createTicket = wrapAction(
+ { actionName: 'createTicket', errorMessage: 'Failed to create Freshdesk ticket' },
+ async ({ freshdeskClient, ctx }, input) => {
+ const ticket = await freshdeskClient.createTicket({
+ subject: input.subject,
+ description: input.description,
+ email: input.email,
+ priority: PRIORITY_TO_NUM[input.priority ?? 'medium'],
+ status: STATUS_TO_NUM[input.status ?? 'open'],
+ tags: input.tags,
+ custom_fields: input.custom_fields,
+ })
+
+ return {
+ id: ticket.id,
+ subject: ticket.subject,
+ status: NUM_TO_STATUS[ticket.status as keyof typeof NUM_TO_STATUS],
+ priority: NUM_TO_PRIORITY[ticket.priority as keyof typeof NUM_TO_PRIORITY],
+ createdAt: ticket.created_at,
+ url: `https://${ctx.configuration.domain}.freshdesk.com/helpdesk/tickets/${ticket.id}`,
+ }
+ }
+)
diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/deleteTicket.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/deleteTicket.ts
new file mode 100644
index 00000000000..80e0e2a961d
--- /dev/null
+++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/deleteTicket.ts
@@ -0,0 +1,9 @@
+import { wrapAction } from '../action-wrapper'
+
+export const deleteTicket = wrapAction(
+ { actionName: 'deleteTicket', errorMessage: 'Failed to delete Freshdesk ticket' },
+ async ({ freshdeskClient }, input) => {
+ await freshdeskClient.deleteTicket({ id: input.ticketId })
+ return {}
+ }
+)
diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/getContact.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/getContact.ts
new file mode 100644
index 00000000000..60c43d42927
--- /dev/null
+++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/getContact.ts
@@ -0,0 +1,18 @@
+import { wrapAction } from '../action-wrapper'
+
+export const getContact = wrapAction(
+ { actionName: 'getContact', errorMessage: 'Failed to get Freshdesk contact' },
+ async ({ freshdeskClient }, input) => {
+ const contact = await freshdeskClient.getContact(input.contactId)
+ return {
+ id: contact.id,
+ name: contact.name,
+ email: contact.email ?? null,
+ phone: contact.phone ?? null,
+ mobile: contact.mobile ?? null,
+ companyId: contact.company_id ?? null,
+ tags: contact.tags ?? null,
+ createdAt: contact.created_at,
+ }
+ }
+)
diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/getTicket.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/getTicket.ts
new file mode 100644
index 00000000000..2c171e78c83
--- /dev/null
+++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/getTicket.ts
@@ -0,0 +1,23 @@
+import { wrapAction } from '../action-wrapper'
+import { NUM_TO_PRIORITY, NUM_TO_STATUS } from './const'
+
+export const getTicket = wrapAction(
+ { actionName: 'getTicket', errorMessage: 'Failed to get Freshdesk ticket' },
+ async ({ freshdeskClient }, input) => {
+ const ticket = await freshdeskClient.getTicket({ id: input.ticketId })
+ return {
+ id: ticket.id,
+ subject: ticket.subject,
+ description: ticket.description ?? null,
+ status: NUM_TO_STATUS[ticket.status as keyof typeof NUM_TO_STATUS],
+ priority: NUM_TO_PRIORITY[ticket.priority as keyof typeof NUM_TO_PRIORITY],
+ requesterId: ticket.requester_id ?? null,
+ responderId: ticket.responder_id ?? null,
+ groupId: ticket.group_id ?? null,
+ createdAt: ticket.created_at,
+ updatedAt: ticket.updated_at,
+ tags: ticket.tags ?? null,
+ customFields: ticket.custom_fields ?? null,
+ }
+ }
+)
diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/index.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/index.ts
new file mode 100644
index 00000000000..8193aefe9b7
--- /dev/null
+++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/index.ts
@@ -0,0 +1,22 @@
+import { addNote } from './addNote'
+import { createTicket } from './createTicket'
+import { deleteTicket } from './deleteTicket'
+import { getContact } from './getContact'
+import { getTicket } from './getTicket'
+import { listTickets } from './listTickets'
+import { searchContacts } from './searchContacts'
+import { searchTickets } from './searchTickets'
+import { updateTicket } from './updateTicket'
+import * as bp from '.botpress'
+
+export default {
+ addNote,
+ createTicket,
+ deleteTicket,
+ getContact,
+ getTicket,
+ listTickets,
+ searchContacts,
+ searchTickets,
+ updateTicket,
+} as const satisfies bp.IntegrationProps['actions']
diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/listTickets.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/listTickets.ts
new file mode 100644
index 00000000000..0db91997d48
--- /dev/null
+++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/listTickets.ts
@@ -0,0 +1,24 @@
+import { wrapAction } from '../action-wrapper'
+import { NUM_TO_PRIORITY, NUM_TO_STATUS } from './const'
+
+export const listTickets = wrapAction(
+ { actionName: 'listTickets', errorMessage: 'Failed to list Freshdesk tickets' },
+ async ({ freshdeskClient }, input) => {
+ const page = input.nextToken ? Math.max(1, parseInt(input.nextToken, 10) || 1) : 1
+ const perPage = input.per_page && input.per_page > 0 ? input.per_page : 30
+ const raw = await freshdeskClient.listTickets({
+ filter: input.filter,
+ order_by: input.order_by,
+ order_type: input.order_type,
+ page,
+ per_page: perPage,
+ })
+ const tickets = raw.map((t) => ({
+ ...t,
+ status: NUM_TO_STATUS[t.status as keyof typeof NUM_TO_STATUS],
+ priority: NUM_TO_PRIORITY[t.priority as keyof typeof NUM_TO_PRIORITY],
+ }))
+ const nextToken = raw.length === perPage ? String(page + 1) : undefined
+ return { tickets, nextToken }
+ }
+)
diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/searchContacts.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/searchContacts.ts
new file mode 100644
index 00000000000..8bee71824f1
--- /dev/null
+++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/searchContacts.ts
@@ -0,0 +1,50 @@
+import { wrapAction } from '../action-wrapper'
+
+// Name search (autocomplete) returns results without email/phone;
+// each needs a separate getContact call to fill in those missing fields.
+// Cap at 5 to avoid unbounded API requests.
+const MAX_NAME_ENRICHMENT = 5
+
+export const searchContacts = wrapAction(
+ { actionName: 'searchContacts', errorMessage: 'Failed to search Freshdesk contacts' },
+ async ({ freshdeskClient }, input) => {
+ const page = input.nextToken ? Math.max(1, parseInt(input.nextToken, 10) || 1) : 1
+
+ if (input.email) {
+ const contacts = await freshdeskClient.searchContactsByEmail(input.email, page)
+ // Freshdesk returns up to 30 contacts per page
+ const nextToken = contacts.length === 30 ? String(page + 1) : undefined
+ return {
+ contacts: contacts.map((c) => ({
+ id: c.id,
+ name: c.name,
+ email: c.email ?? null,
+ phone: c.phone ?? null,
+ companyId: c.company_id ?? null,
+ })),
+ nextToken,
+ }
+ }
+
+ if (input.name) {
+ // Autocomplete endpoint has no pagination
+ const results = await freshdeskClient.searchContactsByName(input.name)
+ const settled = await Promise.allSettled(
+ results.slice(0, MAX_NAME_ENRICHMENT).map(async (r) => {
+ const contact = await freshdeskClient.getContact(r.id)
+ return {
+ id: contact.id,
+ name: contact.name,
+ email: contact.email ?? null,
+ phone: contact.phone ?? null,
+ companyId: contact.company_id ?? null,
+ }
+ })
+ )
+ const contacts = settled.filter((r) => r.status === 'fulfilled').map((r) => r.value)
+ return { contacts }
+ }
+
+ return { contacts: [] }
+ }
+)
diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/searchTickets.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/searchTickets.ts
new file mode 100644
index 00000000000..95d84c08c5e
--- /dev/null
+++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/searchTickets.ts
@@ -0,0 +1,44 @@
+import { wrapAction } from '../action-wrapper'
+import { NUM_TO_PRIORITY, NUM_TO_STATUS, PRIORITY_TO_NUM, STATUS_TO_NUM } from './const'
+
+export const searchTickets = wrapAction(
+ { actionName: 'searchTickets', errorMessage: 'Failed to search Freshdesk tickets' },
+ async ({ freshdeskClient }, input) => {
+ const page = input.nextToken ? Math.max(1, parseInt(input.nextToken, 10) || 1) : 1
+
+ const queryParts: string[] = []
+ if (input.agent_id) queryParts.push(`agent_id:${input.agent_id}`)
+ if (input.tag) queryParts.push(`tag:'${input.tag.replace(/'/g, "\\'")}'`)
+ if (input.status) queryParts.push(`status:${STATUS_TO_NUM[input.status]}`)
+ if (input.priority) queryParts.push(`priority:${PRIORITY_TO_NUM[input.priority]}`)
+
+ const toTicket = (t: {
+ id: number
+ subject: string
+ status: number
+ priority: number
+ created_at: string
+ email?: string
+ }) => ({
+ id: t.id,
+ subject: t.subject,
+ status: NUM_TO_STATUS[t.status as keyof typeof NUM_TO_STATUS],
+ priority: NUM_TO_PRIORITY[t.priority as keyof typeof NUM_TO_PRIORITY],
+ createdAt: t.created_at,
+ requesterEmail: t.email ?? null,
+ })
+
+ if (queryParts.length > 0) {
+ const query = queryParts.join(' AND ')
+ const { results } = await freshdeskClient.searchTickets({ query, page })
+ // Freshdesk search pages are always 30 results max
+ const nextToken = results.length === 30 ? String(page + 1) : undefined
+ return { tickets: results.map(toTicket), nextToken }
+ }
+
+ const limit = Math.min(input.limit ?? 20, 100)
+ const raw = await freshdeskClient.listTickets({ per_page: limit, page })
+ const nextToken = raw.length === limit ? String(page + 1) : undefined
+ return { tickets: raw.map(toTicket), nextToken }
+ }
+)
diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/updateTicket.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/updateTicket.ts
new file mode 100644
index 00000000000..080299da634
--- /dev/null
+++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/updateTicket.ts
@@ -0,0 +1,22 @@
+import { wrapAction } from '../action-wrapper'
+import { NUM_TO_PRIORITY, NUM_TO_STATUS, PRIORITY_TO_NUM, STATUS_TO_NUM } from './const'
+
+export const updateTicket = wrapAction(
+ { actionName: 'updateTicket', errorMessage: 'Failed to update Freshdesk ticket' },
+ async ({ freshdeskClient }, input) => {
+ const ticket = await freshdeskClient.updateTicket({
+ id: input.ticketId,
+ ...(input.status != null && { status: STATUS_TO_NUM[input.status] }),
+ ...(input.priority != null && { priority: PRIORITY_TO_NUM[input.priority] }),
+ ...(input.responderId != null && input.responderId > 0 && { responder_id: input.responderId }),
+ ...(input.groupId != null && input.groupId > 0 && { group_id: input.groupId }),
+ ...(input.custom_fields != null && { custom_fields: input.custom_fields }),
+ })
+ return {
+ id: ticket.id,
+ status: NUM_TO_STATUS[ticket.status as keyof typeof NUM_TO_STATUS],
+ priority: NUM_TO_PRIORITY[ticket.priority as keyof typeof NUM_TO_PRIORITY],
+ updatedAt: ticket.updated_at,
+ }
+ }
+)
diff --git a/integrations/freshdesk/src/freshdesk-client/types.ts b/integrations/freshdesk/src/freshdesk-client/types.ts
new file mode 100644
index 00000000000..9def55b9ac1
--- /dev/null
+++ b/integrations/freshdesk/src/freshdesk-client/types.ts
@@ -0,0 +1,113 @@
+export type FreshdeskTicket = {
+ id: number
+ subject: string
+ description?: string
+ description_text?: string
+ status: number
+ priority: number
+ source?: number
+ email?: string
+ name?: string
+ requester_id?: number
+ responder_id?: number
+ group_id?: number
+ type?: string
+ tags?: string[]
+ cc_emails?: string[]
+ due_by?: string
+ created_at: string
+ updated_at: string
+ custom_fields?: Record
+}
+
+export type CreateTicketInput = {
+ subject: string
+ description?: string
+ email?: string
+ phone?: string
+ twitter_id?: string
+ facebook_id?: string
+ unique_external_id?: string
+ requester_id?: number
+ priority?: number
+ status?: number
+ type?: string
+ tags?: string[]
+ group_id?: number
+ responder_id?: number
+ cc_emails?: string[]
+ custom_fields?: Record
+}
+
+export type GetTicketInput = {
+ id: number
+ include?: string
+}
+
+export type ListTicketsInput = {
+ filter?: string
+ order_by?: string
+ order_type?: 'asc' | 'desc'
+ page?: number
+ per_page?: number
+}
+
+export type UpdateTicketInput = {
+ id: number
+ subject?: string
+ description?: string
+ email?: string
+ phone?: string
+ twitter_id?: string
+ facebook_id?: string
+ unique_external_id?: string
+ requester_id?: number
+ priority?: number
+ status?: number
+ type?: string
+ tags?: string[]
+ group_id?: number
+ responder_id?: number
+ cc_emails?: string[]
+ custom_fields?: Record
+}
+
+export type DeleteTicketInput = {
+ id: number
+}
+
+export type SearchTicketsInput = {
+ query: string
+ page?: number
+}
+
+export type SearchTicketsOutput = {
+ results: FreshdeskTicket[]
+ total?: number
+}
+
+export type FreshdeskConversation = {
+ id: number
+ body: string
+ body_text?: string
+ created_at: string
+ updated_at: string
+ ticket_id: number
+ user_id: number
+}
+
+export type AddNoteInput = {
+ body: string
+ private?: boolean
+}
+
+export type FreshdeskContact = {
+ id: number
+ name: string
+ email?: string
+ phone?: string
+ mobile?: string
+ company_id?: number
+ tags?: string[]
+ created_at: string
+}
diff --git a/integrations/freshdesk/src/handler.ts b/integrations/freshdesk/src/handler.ts
new file mode 100644
index 00000000000..b28256619a4
--- /dev/null
+++ b/integrations/freshdesk/src/handler.ts
@@ -0,0 +1,66 @@
+import { timingSafeEqual } from 'crypto'
+import { executeTicketCreated } from './events/ticketCreated'
+import { executeTicketReplied } from './events/ticketReplied'
+import { executeTicketUpdated } from './events/ticketUpdated'
+import * as bp from '.botpress'
+
+export const handler: bp.IntegrationProps['handler'] = async (props) => {
+ const { req, logger, ctx } = props
+ const log = logger.forBot()
+
+ log.info(`Webhook received: ${req.method} ${req.path}`)
+ log.debug(`Webhook body: ${req.body ?? '(empty)'}`)
+
+ const { webhookSecret } = ctx.configuration
+ if (webhookSecret) {
+ const providedSecret = req.headers?.['x-webhook-secret']
+ const providedBuf = typeof providedSecret === 'string' ? Buffer.from(providedSecret, 'utf8') : null
+ const secretBuf = Buffer.from(webhookSecret, 'utf8')
+ const secretsMatch =
+ providedBuf !== null && providedBuf.byteLength === secretBuf.byteLength && timingSafeEqual(providedBuf, secretBuf)
+ if (!secretsMatch) {
+ log.warn('Webhook received with invalid or missing secret, rejecting')
+ return
+ }
+ }
+
+ try {
+ if (!req.body) {
+ log.warn('Webhook received with empty body, ignoring')
+ return
+ }
+
+ let body: Record
+ try {
+ body = JSON.parse(req.body) as Record
+ } catch (e: unknown) {
+ log.error(`Webhook body is not valid JSON: ${e instanceof Error ? e.message : String(e)}`)
+ return
+ }
+
+ if (req.path === '/ticket-created') {
+ log.debug(`Firing ticketCreated, ticket=${JSON.stringify(body['ticket'])}`)
+ const result = await executeTicketCreated({ ...props, body })
+ log.info('ticketCreated event fired successfully')
+ return result
+ }
+
+ if (req.path === '/ticket-updated') {
+ log.debug(`Firing ticketUpdated, ticket=${JSON.stringify(body['ticket'])}`)
+ const result = await executeTicketUpdated({ ...props, body })
+ log.info('ticketUpdated event fired successfully')
+ return result
+ }
+
+ if (req.path === '/ticket-replied') {
+ log.debug(`Firing ticketReplied, ticket=${JSON.stringify(body['ticket'])}`)
+ const result = await executeTicketReplied({ ...props, body })
+ log.info('ticketReplied event fired successfully')
+ return result
+ }
+
+ log.warn(`Unhandled webhook path: ${req.path}`)
+ } catch (e: unknown) {
+ log.error(`Unhandled error in webhook handler: ${e instanceof Error ? e.message : String(e)}`)
+ }
+}
diff --git a/integrations/freshdesk/src/index.ts b/integrations/freshdesk/src/index.ts
new file mode 100644
index 00000000000..4d7c9072e7f
--- /dev/null
+++ b/integrations/freshdesk/src/index.ts
@@ -0,0 +1,28 @@
+import * as sdk from '@botpress/sdk'
+import { createFreshdeskRuntimeError } from './freshdesk-client/actions/errors'
+import actions from './freshdesk-client/actions/implementations'
+import { FreshdeskClient } from './freshdesk-client/FreshdeskClient'
+import { handler } from './handler'
+import * as bp from '.botpress'
+
+export default new bp.Integration({
+ register: async (props) => {
+ const { domain, apiKey } = props.ctx.configuration
+ const logger = props.logger.forBot()
+
+ logger.info(`Validating Freshdesk configuration for domain=${domain}`)
+
+ try {
+ await new FreshdeskClient(domain, apiKey).validateCredentials()
+ logger.info('Freshdesk configuration validated successfully')
+ } catch (thrown) {
+ const error = createFreshdeskRuntimeError(thrown)
+ logger.warn('Freshdesk configuration validation failed', { error: error.message })
+ throw new sdk.RuntimeError(`Invalid Freshdesk configuration: ${error.message}`)
+ }
+ },
+ unregister: async () => {},
+ actions,
+ channels: {},
+ handler,
+})
diff --git a/integrations/freshdesk/tsconfig.json b/integrations/freshdesk/tsconfig.json
new file mode 100644
index 00000000000..d46abc5b88f
--- /dev/null
+++ b/integrations/freshdesk/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "paths": { "*": ["./*"] },
+ "outDir": "dist"
+ },
+ "include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5f9852230bb..963d8dabe72 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -990,6 +990,25 @@ importers:
specifier: ^4.14.191
version: 4.14.195
+ integrations/freshdesk:
+ dependencies:
+ '@botpress/client':
+ specifier: workspace:*
+ version: link:../../packages/client
+ '@botpress/common':
+ specifier: workspace:*
+ version: link:../../packages/common
+ '@botpress/sdk':
+ specifier: workspace:*
+ version: link:../../packages/sdk
+ '@botpress/sdk-addons':
+ specifier: workspace:*
+ version: link:../../packages/sdk-addons
+ devDependencies:
+ '@botpress/cli':
+ specifier: workspace:*
+ version: link:../../packages/cli
+
integrations/github:
dependencies:
'@botpress/common':
@@ -19600,7 +19619,7 @@ snapshots:
extend: 3.0.2
gaxios: 5.1.0
google-auth-library: 8.8.0
- qs: 6.13.0
+ qs: 6.15.0
url-template: 2.0.8
uuid: 9.0.1
transitivePeerDependencies:
@@ -19612,7 +19631,7 @@ snapshots:
extend: 3.0.2
gaxios: 6.1.1
google-auth-library: 9.0.0
- qs: 6.13.0
+ qs: 6.15.0
url-template: 2.0.8
uuid: 9.0.1
transitivePeerDependencies:
@@ -23089,7 +23108,7 @@ snapshots:
formidable: 1.2.6
methods: 1.1.2
mime: 1.6.0
- qs: 6.13.0
+ qs: 6.15.0
readable-stream: 2.3.8
transitivePeerDependencies:
- supports-color
From fa4bf6d958b9f27a226b758a67653e6192dd3772 Mon Sep 17 00:00:00 2001
From: vini <117116432+vinishamanek@users.noreply.github.com>
Date: Tue, 19 May 2026 16:34:11 -0400
Subject: [PATCH 2/2] feat(integrations): add n8n integration (#15188)
---
integrations/n8n/hub.md | 35 ++++
integrations/n8n/icon.svg | 1 +
integrations/n8n/integration.definition.ts | 129 ++++++++++++++
integrations/n8n/package.json | 18 ++
integrations/n8n/src/action-wrapper.ts | 15 ++
integrations/n8n/src/actions/get-workflow.ts | 6 +
integrations/n8n/src/actions/index.ts | 9 +
.../n8n/src/actions/list-workflows.ts | 5 +
.../n8n/src/actions/trigger-workflow.ts | 11 ++
integrations/n8n/src/client.ts | 160 ++++++++++++++++++
integrations/n8n/src/errors.ts | 41 +++++
integrations/n8n/src/index.ts | 84 +++++++++
integrations/n8n/src/types.ts | 33 ++++
integrations/n8n/tsconfig.json | 8 +
pnpm-lock.yaml | 16 ++
15 files changed, 571 insertions(+)
create mode 100644 integrations/n8n/hub.md
create mode 100644 integrations/n8n/icon.svg
create mode 100644 integrations/n8n/integration.definition.ts
create mode 100644 integrations/n8n/package.json
create mode 100644 integrations/n8n/src/action-wrapper.ts
create mode 100644 integrations/n8n/src/actions/get-workflow.ts
create mode 100644 integrations/n8n/src/actions/index.ts
create mode 100644 integrations/n8n/src/actions/list-workflows.ts
create mode 100644 integrations/n8n/src/actions/trigger-workflow.ts
create mode 100644 integrations/n8n/src/client.ts
create mode 100644 integrations/n8n/src/errors.ts
create mode 100644 integrations/n8n/src/index.ts
create mode 100644 integrations/n8n/src/types.ts
create mode 100644 integrations/n8n/tsconfig.json
diff --git a/integrations/n8n/hub.md b/integrations/n8n/hub.md
new file mode 100644
index 00000000000..d0897eb7de0
--- /dev/null
+++ b/integrations/n8n/hub.md
@@ -0,0 +1,35 @@
+# n8n
+
+This integration connects your Botpress bot to n8n, letting you trigger workflows from your bot and receive results back as events.
+
+## Prerequisites
+
+- An n8n instance
+- An n8n API key — found under **Settings → n8n API**. Generating an API key requires a paid n8n plan and is not available on the free trial.
+
+## Receiving data back from n8n
+
+To send data back to Botpress after an n8n workflow finishes, add an **HTTP Request** node at the end of your workflow and configure it to POST to your integration's webhook URL.
+
+You can find the webhook URL in your Botpress dashboard under **Integrations → n8n → Webhook URL**.
+
+**Trigger Workflow** automatically sends `conversationId` to n8n, so `{{ $json.body.conversationId }}` is available in your workflow without any extra configuration.
+Set the action's **Conversation ID** input to `{{ event.conversationId }}`.
+
+Set the request body to:
+
+```json
+{
+ "conversationId": "{{ $json.body.conversationId }}",
+ "workflowId": "{{ $workflow.id }}",
+ "workflowName": "{{ $workflow.name }}",
+ "data": {
+ "anyKey": "anyValue"
+ }
+}
+```
+
+## Limitations
+
+- **Trigger Workflow** only works on workflows that have a **Webhook** node (`n8n-nodes-base.webhook`) with a path configured.
+- **List Workflows** returns a paginated response. Use the `limit` and `cursor` inputs to page through results for many workflows.
diff --git a/integrations/n8n/icon.svg b/integrations/n8n/icon.svg
new file mode 100644
index 00000000000..82f0a6da2e2
--- /dev/null
+++ b/integrations/n8n/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/integrations/n8n/integration.definition.ts b/integrations/n8n/integration.definition.ts
new file mode 100644
index 00000000000..1b55e4cefb2
--- /dev/null
+++ b/integrations/n8n/integration.definition.ts
@@ -0,0 +1,129 @@
+import { z, IntegrationDefinition } from '@botpress/sdk'
+
+export default new IntegrationDefinition({
+ name: 'n8n',
+ title: 'n8n',
+ description: 'This integration allows you to interact with n8n workflows.',
+ version: '0.1.0',
+ readme: 'hub.md',
+ icon: 'icon.svg',
+ configuration: {
+ schema: z.object({
+ baseUrl: z
+ .string()
+ .url()
+ .title('Base URL')
+ .describe('The base URL of your n8n instance, for example https://example.app.n8n.cloud'),
+ accessKey: z
+ .string()
+ .min(1)
+ .title('Access Key')
+ .describe('Your n8n API key. Found in n8n under Settings → n8n API.'),
+ }),
+ },
+ actions: {
+ listWorkflows: {
+ title: 'List Workflows',
+ description: 'Retrieves workflows from n8n.',
+ input: {
+ schema: z.object({
+ active: z.boolean().optional().title('Active').describe('Filter by workflow active state'),
+ name: z.string().optional().title('Name').describe('Filter by workflow name'),
+ tags: z.string().optional().title('Tags').describe('Comma-separated tag filter'),
+ projectId: z.string().optional().title('Project ID').describe('Filter by project ID'),
+ excludePinnedData: z
+ .boolean()
+ .optional()
+ .default(true)
+ .title('Exclude Pinned Data')
+ .describe('Exclude pinned data from the response'),
+ limit: z
+ .number()
+ .int()
+ .max(250)
+ .optional()
+ .title('Limit')
+ .describe('Maximum number of workflows to return (1-250)'),
+ cursor: z.string().optional().title('Cursor').describe('Pagination cursor from a previous request'),
+ }),
+ },
+ output: {
+ schema: z.object({
+ data: z.array(z.any()).title('Workflows').describe('List of workflows returned by n8n'),
+ nextCursor: z
+ .string()
+ .optional()
+ .title('Next Cursor')
+ .describe('Cursor for fetching the next page of results'),
+ }),
+ },
+ },
+ getWorkflow: {
+ title: 'Get Workflow',
+ description: 'Retrieves a single workflow from n8n by ID.',
+ input: {
+ schema: z.object({
+ workflowId: z.string().min(1).title('Workflow ID').describe('The workflow ID'),
+ excludePinnedData: z
+ .boolean()
+ .optional()
+ .default(true)
+ .title('Exclude Pinned Data')
+ .describe('Exclude pinned data from the response'),
+ }),
+ },
+ output: {
+ schema: z.object({
+ workflow: z.any().title('Workflow').describe('The full workflow object returned by n8n'),
+ }),
+ },
+ },
+ triggerWorkflow: {
+ title: 'Trigger Workflow',
+ description: 'Resolves an n8n workflow webhook and posts data to it.',
+ input: {
+ schema: z.object({
+ workflowIdOrName: z.string().min(1).title('Workflow ID or Name').describe('The workflow ID or name'),
+ conversationId: z
+ .string()
+ .min(1)
+ .placeholder('{{ event.conversationId }}')
+ .title('Conversation ID')
+ .describe('The current Botpress conversation ID — use {{event.conversationId}}'),
+ payload: z
+ .record(z.string(), z.any())
+ .optional()
+ .default({})
+ .title('Payload')
+ .describe('The JSON payload to send to the workflow'),
+ }),
+ },
+ output: {
+ schema: z.object({
+ workflowId: z.string().optional().title('Workflow ID').describe('The ID of the triggered workflow'),
+ workflowName: z.string().optional().title('Workflow Name').describe('The name of the triggered workflow'),
+ response: z.any().optional().title('Response').describe('The response body returned by the n8n webhook'),
+ }),
+ },
+ },
+ },
+ events: {
+ n8nEvent: {
+ title: 'n8n event',
+ description: 'Triggered when n8n posts data back to Botpress.',
+ schema: z.object({
+ workflowId: z
+ .string()
+ .optional()
+ .title('Workflow ID')
+ .describe('The ID of the workflow that posted this event'),
+ workflowName: z
+ .string()
+ .optional()
+ .title('Workflow Name')
+ .describe('The name of the workflow that posted this event'),
+ data: z.record(z.string(), z.any()).title('Data').describe('Arbitrary data payload sent by the n8n workflow'),
+ }),
+ },
+ },
+})
diff --git a/integrations/n8n/package.json b/integrations/n8n/package.json
new file mode 100644
index 00000000000..8e4fe6b0961
--- /dev/null
+++ b/integrations/n8n/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@botpresshub/n8n",
+ "scripts": {
+ "check:type": "tsc --noEmit",
+ "build": "bp add -y && bp build",
+ "check:bplint": "bp lint"
+ },
+ "private": true,
+ "dependencies": {
+ "@botpress/common": "workspace:*",
+ "@botpress/sdk": "workspace:*",
+ "axios": "^1.7.9"
+ },
+ "devDependencies": {
+ "@botpress/cli": "workspace:*",
+ "@botpress/sdk": "workspace:*"
+ }
+}
diff --git a/integrations/n8n/src/action-wrapper.ts b/integrations/n8n/src/action-wrapper.ts
new file mode 100644
index 00000000000..cf95724c6d1
--- /dev/null
+++ b/integrations/n8n/src/action-wrapper.ts
@@ -0,0 +1,15 @@
+import { createActionWrapper } from '@botpress/common'
+import { N8nClient } from './client'
+import * as bp from '.botpress'
+
+const _wrapAction = createActionWrapper()({
+ toolFactories: {
+ n8nClient: ({ ctx }) => new N8nClient(ctx.configuration),
+ },
+})
+
+export const wrapAction: typeof _wrapAction = (meta, actionImpl) =>
+ _wrapAction(meta, (props) => {
+ props.logger.forBot().debug(`Running action "${meta.actionName}"`, { input: props.input })
+ return actionImpl(props as Parameters[0], props.input)
+ })
diff --git a/integrations/n8n/src/actions/get-workflow.ts b/integrations/n8n/src/actions/get-workflow.ts
new file mode 100644
index 00000000000..95ff04d893d
--- /dev/null
+++ b/integrations/n8n/src/actions/get-workflow.ts
@@ -0,0 +1,6 @@
+import { wrapAction } from '../action-wrapper'
+
+export const getWorkflow = wrapAction({ actionName: 'getWorkflow' }, async ({ n8nClient }, input) => {
+ const workflow = await n8nClient.getWorkflow(input.workflowId, input.excludePinnedData ?? true)
+ return { workflow }
+})
diff --git a/integrations/n8n/src/actions/index.ts b/integrations/n8n/src/actions/index.ts
new file mode 100644
index 00000000000..07d59215fdd
--- /dev/null
+++ b/integrations/n8n/src/actions/index.ts
@@ -0,0 +1,9 @@
+import { getWorkflow } from './get-workflow'
+import { listWorkflows } from './list-workflows'
+import { triggerWorkflow } from './trigger-workflow'
+
+export default {
+ listWorkflows,
+ getWorkflow,
+ triggerWorkflow,
+}
diff --git a/integrations/n8n/src/actions/list-workflows.ts b/integrations/n8n/src/actions/list-workflows.ts
new file mode 100644
index 00000000000..e494f7511bb
--- /dev/null
+++ b/integrations/n8n/src/actions/list-workflows.ts
@@ -0,0 +1,5 @@
+import { wrapAction } from '../action-wrapper'
+
+export const listWorkflows = wrapAction({ actionName: 'listWorkflows' }, async ({ n8nClient }, input) =>
+ n8nClient.listWorkflows(input)
+)
diff --git a/integrations/n8n/src/actions/trigger-workflow.ts b/integrations/n8n/src/actions/trigger-workflow.ts
new file mode 100644
index 00000000000..545bfe13079
--- /dev/null
+++ b/integrations/n8n/src/actions/trigger-workflow.ts
@@ -0,0 +1,11 @@
+import { wrapAction } from '../action-wrapper'
+
+export const triggerWorkflow = wrapAction({ actionName: 'triggerWorkflow' }, async ({ n8nClient }, input) =>
+ n8nClient.triggerWorkflowWebhook({
+ workflowIdOrName: input.workflowIdOrName,
+ body: {
+ conversationId: input.conversationId,
+ data: input.payload ?? {},
+ },
+ })
+)
diff --git a/integrations/n8n/src/client.ts b/integrations/n8n/src/client.ts
new file mode 100644
index 00000000000..6afcb85c7be
--- /dev/null
+++ b/integrations/n8n/src/client.ts
@@ -0,0 +1,160 @@
+import * as sdk from '@botpress/sdk'
+import axios, { type AxiosInstance } from 'axios'
+import { isNotFoundResponse, wrapN8nError, wrapRegistrationError } from './errors'
+import type { N8nConfiguration, N8nWorkflow, ListWorkflowsInput, TriggerWorkflowWebhookInput } from './types'
+
+const WEBHOOK_NODE_TYPE = 'n8n-nodes-base.webhook'
+
+export class N8nClient {
+ private readonly _baseUrl: string
+ private readonly _client: AxiosInstance
+
+ public constructor(configuration: N8nConfiguration) {
+ this._baseUrl = configuration.baseUrl
+ this._client = axios.create({
+ baseURL: this._buildApiUrl('/'),
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-N8N-API-KEY': configuration.accessKey,
+ },
+ })
+ }
+
+ public async validateConnection(): Promise {
+ try {
+ await this._client.get('/workflows', {
+ params: {
+ limit: 1,
+ excludePinnedData: true,
+ },
+ })
+ } catch (error) {
+ return wrapRegistrationError(error, this._baseUrl)
+ }
+ }
+
+ public async listWorkflows(input: ListWorkflowsInput) {
+ try {
+ const { data } = await this._client.get('/workflows', {
+ params: {
+ active: input.active,
+ name: input.name,
+ tags: input.tags,
+ projectId: input.projectId,
+ excludePinnedData: input.excludePinnedData,
+ limit: input.limit,
+ cursor: input.cursor,
+ },
+ })
+
+ return {
+ data: data?.data ?? [],
+ nextCursor: data?.nextCursor ?? undefined,
+ }
+ } catch (error) {
+ return wrapN8nError(error)
+ }
+ }
+
+ public async getWorkflow(workflowId: string, excludePinnedData = true): Promise {
+ try {
+ return await this._getWorkflowById(workflowId, excludePinnedData)
+ } catch (error) {
+ if (isNotFoundResponse(error)) {
+ throw new sdk.RuntimeError(`n8n workflow "${workflowId}" not found`)
+ }
+ return wrapN8nError(error)
+ }
+ }
+
+ public async triggerWorkflowWebhook(input: TriggerWorkflowWebhookInput) {
+ const workflow = await this._resolveWorkflowByIdOrName(input.workflowIdOrName)
+ const webhookPath = this._getWebhookPath(workflow)
+
+ if (!webhookPath) {
+ throw new sdk.RuntimeError('Unable to find an n8n webhook node with a path parameter in the selected workflow')
+ }
+
+ const webhookUrl = this._buildPublicUrl(`/webhook/${webhookPath.replace(/^\/+/, '')}`)
+
+ try {
+ // Webhook URLs are public endpoints, no need to use the API key.
+ const response = await axios.post(webhookUrl, {
+ ...input.body,
+ workflowId: workflow.id,
+ workflowName: workflow.name,
+ })
+
+ return {
+ workflowId: workflow.id,
+ workflowName: workflow.name,
+ response: response.data,
+ }
+ } catch (error) {
+ return wrapN8nError(error, 'n8n webhook trigger')
+ }
+ }
+
+ private async _resolveWorkflowByIdOrName(workflowIdOrName: string): Promise {
+ try {
+ return await this._getWorkflowById(workflowIdOrName)
+ } catch (error) {
+ if (!isNotFoundResponse(error)) {
+ return wrapN8nError(error)
+ }
+ }
+
+ try {
+ const { data } = await this._client.get<{ data?: N8nWorkflow[] }>('/workflows', {
+ params: {
+ name: workflowIdOrName,
+ excludePinnedData: true,
+ },
+ })
+
+ const exactMatch = (data?.data ?? []).find(
+ (workflow) => workflow?.name === workflowIdOrName || workflow?.id === workflowIdOrName
+ )
+
+ if (exactMatch?.id) {
+ return await this._getWorkflowById(exactMatch.id)
+ }
+ } catch (error) {
+ return wrapN8nError(error)
+ }
+
+ throw new sdk.RuntimeError(`Unable to resolve n8n workflow "${workflowIdOrName}"`)
+ }
+
+ private async _getWorkflowById(workflowId: string, excludePinnedData = true): Promise {
+ const { data } = await this._client.get(`/workflows/${encodeURIComponent(workflowId)}`, {
+ params: {
+ excludePinnedData,
+ },
+ })
+ return data
+ }
+
+ private _getWebhookPath(workflow: N8nWorkflow): string | undefined {
+ const nodes = workflow?.nodes ?? workflow?.activeVersion?.nodes ?? []
+ const webhookNode = nodes.find((node) => node?.type === WEBHOOK_NODE_TYPE)
+ const path = webhookNode?.parameters?.path
+
+ return typeof path === 'string' ? path : undefined
+ }
+
+ private _buildApiUrl(path: string): string {
+ return this._buildUrl(path, true)
+ }
+
+ private _buildPublicUrl(path: string): string {
+ return this._buildUrl(path, false)
+ }
+
+ private _buildUrl(path: string, api: boolean): string {
+ const base = this._baseUrl.replace(/\/+$/, '')
+ const prefix = api ? '/api/v1' : ''
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`
+ return `${base}${prefix}${normalizedPath}`
+ }
+}
diff --git a/integrations/n8n/src/errors.ts b/integrations/n8n/src/errors.ts
new file mode 100644
index 00000000000..e8353de78f2
--- /dev/null
+++ b/integrations/n8n/src/errors.ts
@@ -0,0 +1,41 @@
+import * as sdk from '@botpress/sdk'
+import axios from 'axios'
+
+export const isNotFoundResponse = (error: unknown): boolean =>
+ axios.isAxiosError(error) && error.response?.status === 404
+
+export const wrapN8nError = (error: unknown, context = 'n8n request'): never => {
+ if (axios.isAxiosError(error)) {
+ const status = error.response?.status
+ const detail = status ? `HTTP ${status}` : (error.code ?? error.message ?? 'network error')
+ throw new sdk.RuntimeError(`${context} failed: ${detail}`)
+ }
+ throw new sdk.RuntimeError(error instanceof Error ? error.message : String(error))
+}
+
+export const wrapRegistrationError = (error: unknown, baseUrl: string): never => {
+ if (axios.isAxiosError(error)) {
+ const status = error.response?.status
+ const code = error.code
+ const message = status ? `n8n responded with HTTP ${status}` : (code ?? error.message ?? 'network error')
+
+ if (status === 401 || status === 403) {
+ throw new sdk.RuntimeError(
+ `Registration failed: authentication rejected (${message}). Check your Access Key and permissions.`
+ )
+ }
+
+ if (status === 404) {
+ throw new sdk.RuntimeError(
+ `Registration failed: the n8n API was not found at ${baseUrl}/api/v1 (HTTP 404). Ensure your Base URL points to the root of your n8n instance (for example https://example.app.n8n.cloud).`
+ )
+ }
+
+ throw new sdk.RuntimeError(
+ `Registration failed: unable to reach n8n (${message}). Verify the Base URL is correct and reachable from this host.`
+ )
+ }
+
+ const message = error instanceof Error ? error.message : String(error)
+ throw new sdk.RuntimeError(`Registration failed: ${message}`)
+}
diff --git a/integrations/n8n/src/index.ts b/integrations/n8n/src/index.ts
new file mode 100644
index 00000000000..9dda7456ff9
--- /dev/null
+++ b/integrations/n8n/src/index.ts
@@ -0,0 +1,84 @@
+import { z } from '@botpress/sdk'
+import actions from './actions'
+import { N8nClient } from './client'
+import * as bp from '.botpress'
+
+const webhookPayloadSchema = z.object({
+ conversationId: z.string().optional(),
+ workflowId: z.string().optional(),
+ workflowName: z.string().optional(),
+ data: z.union([z.record(z.string(), z.any()), z.string()]).optional(),
+})
+
+const parseJsonBody = (body: unknown): unknown | null => {
+ if (typeof body !== 'string') {
+ return body
+ }
+ try {
+ return JSON.parse(body)
+ } catch {
+ return null
+ }
+}
+
+export default new bp.Integration({
+ register: async ({ ctx }) => {
+ await new N8nClient(ctx.configuration).validateConnection()
+ },
+ unregister: async () => undefined,
+ actions,
+ channels: {},
+ handler: async ({ req, client, logger }) => {
+ const log = logger.forBot()
+
+ const body = parseJsonBody(req.body)
+ if (!body || typeof body !== 'object') {
+ log.error('n8n webhook request must contain a valid JSON object body')
+ return
+ }
+
+ const result = webhookPayloadSchema.safeParse(body)
+ if (!result.success) {
+ log.error('Invalid n8n webhook payload structure')
+ return
+ }
+
+ const payload = result.data
+
+ if (!payload.conversationId) {
+ log.error('Missing conversationId in n8n webhook payload — ensure your n8n workflow echoes it back')
+ return
+ }
+
+ let data: Record
+ if (typeof payload.data === 'string') {
+ try {
+ data = JSON.parse(payload.data)
+ } catch {
+ log.error('n8n webhook payload.data contained invalid JSON')
+ return
+ }
+ } else if (typeof payload.data === 'object' && payload.data !== null) {
+ data = payload.data
+ } else {
+ data = {}
+ }
+
+ try {
+ await client.createEvent({
+ type: 'n8nEvent',
+ conversationId: payload.conversationId,
+ payload: {
+ workflowId: payload.workflowId,
+ workflowName: payload.workflowName,
+ data,
+ },
+ })
+ } catch (err) {
+ log.error(`Failed to create n8n event: ${err instanceof Error ? err.message : String(err)}`)
+ return
+ }
+
+ return { status: 200, body: JSON.stringify({ ok: true }) }
+ },
+})
diff --git a/integrations/n8n/src/types.ts b/integrations/n8n/src/types.ts
new file mode 100644
index 00000000000..98d26ce3b3c
--- /dev/null
+++ b/integrations/n8n/src/types.ts
@@ -0,0 +1,33 @@
+export type N8nNode = {
+ type: string
+ parameters?: Record
+}
+
+export type N8nWorkflow = {
+ id?: string
+ name?: string
+ nodes?: N8nNode[]
+ activeVersion?: {
+ nodes?: N8nNode[]
+ }
+}
+
+export type N8nConfiguration = {
+ baseUrl: string
+ accessKey: string
+}
+
+export type ListWorkflowsInput = {
+ active?: boolean
+ name?: string
+ tags?: string
+ projectId?: string
+ excludePinnedData?: boolean
+ limit?: number
+ cursor?: string
+}
+
+export type TriggerWorkflowWebhookInput = {
+ workflowIdOrName: string
+ body: Record
+}
diff --git a/integrations/n8n/tsconfig.json b/integrations/n8n/tsconfig.json
new file mode 100644
index 00000000000..d46abc5b88f
--- /dev/null
+++ b/integrations/n8n/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "paths": { "*": ["./*"] },
+ "outDir": "dist"
+ },
+ "include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 963d8dabe72..c8bca488b62 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1642,6 +1642,22 @@ importers:
specifier: workspace:*
version: link:../../packages/cli
+ integrations/n8n:
+ dependencies:
+ '@botpress/common':
+ specifier: workspace:*
+ version: link:../../packages/common
+ '@botpress/sdk':
+ specifier: workspace:*
+ version: link:../../packages/sdk
+ axios:
+ specifier: ^1.7.9
+ version: 1.13.6
+ devDependencies:
+ '@botpress/cli':
+ specifier: workspace:*
+ version: link:../../packages/cli
+
integrations/notion:
dependencies:
'@botpress/client':