From d20590352032743ec295bd970f7e75a753092211 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 17 Aug 2026 17:36:01 -0700 Subject: [PATCH 1/3] feat(jotform): trigger a workflow on every new form submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jotform's only webhook event is a new submission, so the block gets one trigger. Deploying it registers the callback on the form through the API and undeploying removes it again. Two things about this provider needed handling: Jotform posts submissions as multipart/form-data, which the shared webhook body parser did not read — the delivery died as a 400 before any handler saw it. The parser now flattens a multipart body the same way it already flattens a urlencoded one, reducing an uploaded part to its filename so a stray file cannot inflate the execution input. The form's webhooks are identified by their position in the form's webhook map, so an id captured at registration goes stale the moment any other webhook on that form is removed. Nothing persists it; cleanup re-resolves the id by matching the callback URL. Registration checks the same way, because Jotform answers a rejected request with the unchanged list rather than an error. Answers are exposed as the parsed `rawRequest` rather than re-keyed by question label — the labels are not unique, and the payload shape is only documented as the raw q{qid}_{slug} map. The trigger's region field is named `apiRegion` so it does not collide with the block's own advanced-mode `region`. --- .../content/docs/en/integrations/jotform.mdx | 31 +++ apps/sim/blocks/blocks/jotform.test.ts | 10 +- apps/sim/blocks/blocks/jotform.ts | 8 + apps/sim/lib/integrations/integrations.json | 12 +- apps/sim/lib/webhooks/processor.test.ts | 59 +++++- apps/sim/lib/webhooks/processor.ts | 27 +++ .../lib/webhooks/providers/jotform.test.ts | 185 +++++++++++++++++ apps/sim/lib/webhooks/providers/jotform.ts | 196 ++++++++++++++++++ apps/sim/lib/webhooks/providers/registry.ts | 2 + apps/sim/triggers/jotform/index.ts | 1 + apps/sim/triggers/jotform/webhook.ts | 129 ++++++++++++ apps/sim/triggers/registry.ts | 2 + 12 files changed, 654 insertions(+), 8 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/jotform.test.ts create mode 100644 apps/sim/lib/webhooks/providers/jotform.ts create mode 100644 apps/sim/triggers/jotform/index.ts create mode 100644 apps/sim/triggers/jotform/webhook.ts diff --git a/apps/docs/content/docs/en/integrations/jotform.mdx b/apps/docs/content/docs/en/integrations/jotform.mdx index 91171bf5bb0..b710473f56d 100644 --- a/apps/docs/content/docs/en/integrations/jotform.mdx +++ b/apps/docs/content/docs/en/integrations/jotform.mdx @@ -1174,3 +1174,34 @@ Update account settings such as name, email, website, company, industry, or time | ↳ `usage` | string | URL of the monthly usage endpoint | + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### Jotform Webhook + +Trigger workflow when a Jotform form receives a new submission + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `formId` | string | Yes | The form to watch. It is the numeric segment of the form URL, and the List Forms operation returns it. | +| `apiKey` | string | Yes | Used to register the webhook on the form automatically. Create one under Account Settings > API. | +| `apiRegion` | string | No | Data residency region the API key belongs to. A key only works on the host that issued it. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `formId` | string | ID of the form that was submitted | +| `submissionId` | string | ID of the new submission | +| `formTitle` | string | Title of the form at the time of submission | +| `username` | string | Jotform account username that owns the form | +| `ip` | string | IP address the submission came from | +| `submissionType` | string | How the submission was made, e.g. WEB | +| `pretty` | string | Human-readable summary of the answers, as comma-separated "Question Label:Answer" pairs | +| `rawRequest` | json | Submitted answers keyed by question, in Jotform's q\{questionId\}_\{slugifiedLabel\} form. Values are strings, or objects for multi-part questions such as name and address. | +| `raw` | json | Complete original webhook payload from Jotform | + diff --git a/apps/sim/blocks/blocks/jotform.test.ts b/apps/sim/blocks/blocks/jotform.test.ts index 0c7ca2c680c..31144f416fc 100644 --- a/apps/sim/blocks/blocks/jotform.test.ts +++ b/apps/sim/blocks/blocks/jotform.test.ts @@ -26,17 +26,19 @@ describe('JotformBlock', () => { ) }) + /* Trigger-mode subBlocks are supplied by the trigger config and carry their own + state, so neither the input declarations nor the tool-mode id space covers them. */ + const toolSubBlocks = JotformBlock.subBlocks.filter((subBlock) => subBlock.mode !== 'trigger') + it('declares an input for every subblock', () => { const inputIds = new Set(Object.keys(JotformBlock.inputs)) - const missing = JotformBlock.subBlocks - .map((subBlock) => subBlock.id) - .filter((id) => !inputIds.has(id)) + const missing = toolSubBlocks.map((subBlock) => subBlock.id).filter((id) => !inputIds.has(id)) expect(missing).toEqual([]) }) it('gives every subblock a unique id', () => { - const ids = JotformBlock.subBlocks.map((subBlock) => subBlock.id) + const ids = toolSubBlocks.map((subBlock) => subBlock.id) expect(new Set(ids).size).toBe(ids.length) }) diff --git a/apps/sim/blocks/blocks/jotform.ts b/apps/sim/blocks/blocks/jotform.ts index 1ebaf9f26d2..d22a56dfcc7 100644 --- a/apps/sim/blocks/blocks/jotform.ts +++ b/apps/sim/blocks/blocks/jotform.ts @@ -1,5 +1,6 @@ import { JotformIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' +import { getTrigger } from '@/triggers' /** * Operations that address a form by ID. Kept as one list because seventeen of the @@ -669,6 +670,8 @@ export const JotformBlock: BlockConfig = { condition: { field: 'operation', value: 'get_history' }, mode: 'advanced', }, + + ...getTrigger('jotform_webhook').subBlocks, ], tools: { @@ -949,6 +952,11 @@ export const JotformBlock: BlockConfig = { description: 'Confirmation text returned by a delete operation', }, }, + + triggers: { + enabled: true, + available: ['jotform_webhook'], + }, } export const JotformBlockMeta = { diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 86b060fa685..b46ecfb17ec 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-08-17", + "updatedAt": "2026-08-18", "integrations": [ { "type": "onepassword", @@ -11532,8 +11532,14 @@ } ], "operationCount": 43, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "jotform_webhook", + "name": "Jotform Webhook", + "description": "Trigger workflow when a Jotform form receives a new submission" + } + ], + "triggerCount": 1, "authType": "api-key", "category": "tools", "integrationType": "productivity", diff --git a/apps/sim/lib/webhooks/processor.test.ts b/apps/sim/lib/webhooks/processor.test.ts index f454b8682d9..3904473599b 100644 --- a/apps/sim/lib/webhooks/processor.test.ts +++ b/apps/sim/lib/webhooks/processor.test.ts @@ -14,7 +14,7 @@ import { workflowsPersistenceUtilsMock, workflowsPersistenceUtilsMockFns, } from '@sim/testing' -import type { NextRequest } from 'next/server' +import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { ADMISSION_ERROR_CODE, @@ -136,6 +136,7 @@ import { dispatchResolvedWebhookTarget, findAllWebhooksForPath, handleWebhookEventFilter, + parseWebhookBody, processPolledWebhookEvent, } from '@/lib/webhooks/processor' @@ -678,3 +679,59 @@ describe('polled webhook reservation ownership', () => { ) }) }) + +describe('parseWebhookBody', () => { + const parse = async (request: NextRequest) => { + const result = await parseWebhookBody(request, 'req-1') + if (result instanceof Response) throw new Error(`unexpected ${result.status} response`) + return result + } + + it('flattens a multipart body, as Jotform posts submissions', async () => { + const form = new FormData() + form.set('formID', '231504059977966') + form.set('submissionID', '5678') + form.set('rawRequest', '{"q4_email":"bart@example.com"}') + + const result = await parse( + new NextRequest('http://localhost:3000/api/webhooks/trigger/jotform-path', { + method: 'POST', + body: form, + }) + ) + + expect(result.body).toEqual({ + formID: '231504059977966', + submissionID: '5678', + rawRequest: '{"q4_email":"bart@example.com"}', + }) + }) + + it('reduces an uploaded multipart part to its filename', async () => { + const form = new FormData() + form.set('attachment', new File(['file bytes'], 'receipt.pdf', { type: 'application/pdf' })) + + const result = await parse( + new NextRequest('http://localhost:3000/api/webhooks/trigger/jotform-path', { + method: 'POST', + body: form, + }) + ) + + expect(result.body).toEqual({ attachment: 'receipt.pdf' }) + }) + + it('still rejects a body that matches no supported content type', async () => { + const response = await parseWebhookBody( + new NextRequest('http://localhost:3000/api/webhooks/trigger/jotform-path', { + method: 'POST', + body: 'not json', + headers: { 'content-type': 'application/json' }, + }), + 'req-1' + ) + + expect(response).toBeInstanceOf(Response) + expect((response as Response).status).toBe(400) + }) +}) diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index a7a56b03f58..06933871349 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -77,6 +77,31 @@ export interface WebhookPreprocessingResult { const WEBHOOK_BODY_LABEL = 'Webhook request body' +/** + * Flattens a `multipart/form-data` body into the plain object shape provider handlers + * already receive from JSON and urlencoded bodies. Jotform posts submissions this way, + * and every field it sends is text. + * + * Parsing the decoded body rather than the original bytes is safe here because the parts + * are delimited by an ASCII boundary and an uploaded part is reduced to its filename — + * its bytes are never read, so re-encoding cannot corrupt anything we keep. Discarding + * them also stops a stray upload from inflating the execution input. + */ +async function parseMultipartBody( + rawBody: string, + contentType: string +): Promise> { + const formData = await new Response(rawBody, { + headers: { 'content-type': contentType }, + }).formData() + + const fields: Record = {} + for (const [key, value] of formData.entries()) { + fields[key] = typeof value === 'string' ? value : value.name + } + return fields +} + export async function parseWebhookBody( request: NextRequest, requestId: string @@ -121,6 +146,8 @@ export async function parseWebhookBody( } else { body = Object.fromEntries(formData.entries()) } + } else if (contentType.includes('multipart/form-data')) { + body = await parseMultipartBody(rawBody, contentType) } else { body = JSON.parse(rawBody) } diff --git a/apps/sim/lib/webhooks/providers/jotform.test.ts b/apps/sim/lib/webhooks/providers/jotform.test.ts new file mode 100644 index 00000000000..ffa0f7766e4 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/jotform.test.ts @@ -0,0 +1,185 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const WEBHOOK_ID = 'webhook-uuid-1234' +const NOTIFICATION_URL = 'https://app.example.com/api/webhooks/trigger/jotform-path' + +vi.mock('@/lib/webhooks/provider-subscription-utils', () => ({ + getProviderConfig: (webhook: { providerConfig?: Record }) => + webhook.providerConfig || {}, + getNotificationUrl: () => NOTIFICATION_URL, +})) + +import { jotformHandler } from '@/lib/webhooks/providers/jotform' + +const fetchMock = vi.fn() + +function createContext(providerConfig: Record) { + return { + webhook: { id: WEBHOOK_ID, path: 'jotform-path', providerConfig }, + workflow: {}, + userId: 'user-1', + requestId: 'req-1', + } as never +} + +/** Jotform wraps every response in an envelope and reports failures inside it. */ +function envelope(content: unknown, overrides: Record = {}) { + return new Response(JSON.stringify({ responseCode: 200, content, ...overrides }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('jotformHandler formatInput', () => { + it('maps the multipart fields and parses rawRequest', async () => { + const result = await jotformHandler.formatInput!({ + body: { + formID: '231504059977966', + submissionID: '5678', + formTitle: 'Contact Us', + username: 'acme', + ip: '198.51.100.4', + type: 'WEB', + pretty: 'Name:Bart Simpson, Email:bart@example.com', + rawRequest: '{"q3_name":{"first":"Bart","last":"Simpson"},"q4_email":"bart@example.com"}', + }, + } as never) + + expect(result.input).toEqual({ + formId: '231504059977966', + submissionId: '5678', + formTitle: 'Contact Us', + username: 'acme', + ip: '198.51.100.4', + submissionType: 'WEB', + pretty: 'Name:Bart Simpson, Email:bart@example.com', + rawRequest: { + q3_name: { first: 'Bart', last: 'Simpson' }, + q4_email: 'bart@example.com', + }, + raw: expect.objectContaining({ submissionID: '5678' }), + }) + }) + + it('keeps the submission when rawRequest is not valid JSON', async () => { + const result = await jotformHandler.formatInput!({ + body: { submissionID: '5678', rawRequest: 'not json' }, + } as never) + + expect((result.input as Record).rawRequest).toBeNull() + expect((result.input as Record).submissionId).toBe('5678') + }) +}) + +describe('jotformHandler extractIdempotencyId', () => { + it('keys on the submission id', () => { + expect(jotformHandler.extractIdempotencyId!({ submissionID: '5678' })).toBe('submission:5678') + }) + + it('returns null without a submission id', () => { + expect(jotformHandler.extractIdempotencyId!({ formID: '1' })).toBeNull() + }) +}) + +describe('jotformHandler createSubscription', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + }) + + it('registers the notification URL on the form', async () => { + fetchMock.mockResolvedValueOnce(envelope({ '0': NOTIFICATION_URL })) + + await jotformHandler.createSubscription!( + createContext({ formId: '231504059977966', apiKey: 'jf-key' }) + ) + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('https://api.jotform.com/form/231504059977966/webhooks') + expect(init.method).toBe('POST') + expect(init.headers.APIKEY).toBe('jf-key') + expect(init.body).toBe(`webhookURL=${encodeURIComponent(NOTIFICATION_URL)}`) + }) + + it('uses the host that issued the key', async () => { + fetchMock.mockResolvedValueOnce(envelope({ '0': NOTIFICATION_URL })) + + await jotformHandler.createSubscription!( + createContext({ formId: '1', apiKey: 'jf-key', apiRegion: 'eu' }) + ) + + expect(fetchMock.mock.calls[0][0]).toBe('https://eu-api.jotform.com/form/1/webhooks') + }) + + it('fails when the URL is missing from the returned webhook list', async () => { + fetchMock.mockResolvedValueOnce(envelope({ '0': 'https://elsewhere.example.com/hook' })) + + await expect( + jotformHandler.createSubscription!(createContext({ formId: '1', apiKey: 'jf-key' })) + ).rejects.toThrow(/did not register the webhook URL/) + }) + + it('fails on a 200 body that carries a non-2xx responseCode', async () => { + fetchMock.mockResolvedValueOnce( + envelope(null, { responseCode: '401', message: 'Invalid API Key' }) + ) + + await expect( + jotformHandler.createSubscription!(createContext({ formId: '1', apiKey: 'bad-key' })) + ).rejects.toThrow(/Invalid API Key/) + }) + + it('fails before calling Jotform when the API key is missing', async () => { + await expect( + jotformHandler.createSubscription!(createContext({ formId: '1' })) + ).rejects.toThrow(/API Key is required/) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe('jotformHandler deleteSubscription', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + }) + + it('resolves the webhook id by URL before deleting it', async () => { + fetchMock + .mockResolvedValueOnce( + envelope({ '0': 'https://elsewhere.example.com/hook', '1': NOTIFICATION_URL }) + ) + .mockResolvedValueOnce(envelope({ '0': 'https://elsewhere.example.com/hook' })) + + await jotformHandler.deleteSubscription!(createContext({ formId: '1', apiKey: 'jf-key' })) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1][0]).toBe('https://api.jotform.com/form/1/webhooks/1') + expect(fetchMock.mock.calls[1][1].method).toBe('DELETE') + }) + + it('does not delete anything when the form no longer carries our URL', async () => { + fetchMock.mockResolvedValueOnce(envelope({ '0': 'https://elsewhere.example.com/hook' })) + + await jotformHandler.deleteSubscription!(createContext({ formId: '1', apiKey: 'jf-key' })) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('swallows a failed cleanup unless the caller is strict', async () => { + fetchMock.mockRejectedValue(new Error('network down')) + + await expect( + jotformHandler.deleteSubscription!(createContext({ formId: '1', apiKey: 'jf-key' })) + ).resolves.toBeUndefined() + + await expect( + jotformHandler.deleteSubscription!({ + ...(createContext({ formId: '1', apiKey: 'jf-key' }) as object), + strict: true, + } as never) + ).rejects.toThrow(/network down/) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/jotform.ts b/apps/sim/lib/webhooks/providers/jotform.ts new file mode 100644 index 00000000000..4cfc433d0ef --- /dev/null +++ b/apps/sim/lib/webhooks/providers/jotform.ts @@ -0,0 +1,196 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' +import type { + DeleteSubscriptionContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { normalizeWebhooks } from '@/tools/jotform/normalize' +import { + buildJotformHeaders, + buildJotformUrl, + jotformFormHeaders, + parseJotformResponse, + toFormBody, + toStringOrNull, +} from '@/tools/jotform/utils' + +const logger = createLogger('WebhookProvider:Jotform') + +interface JotformSubscriptionCredentials { + formId: string + apiKey: string + region?: string +} + +function readCredentials( + webhook: Record +): JotformSubscriptionCredentials | { error: string } { + const config = getProviderConfig(webhook) + const formId = toStringOrNull(config.formId)?.trim() + const apiKey = toStringOrNull(config.apiKey)?.trim() + + if (!formId) return { error: 'Form ID is required to register a Jotform webhook.' } + if (!apiKey) { + return { + error: + 'API Key is required to register a Jotform webhook. Create one under Account Settings > API with Full Access.', + } + } + + return { formId, apiKey, region: toStringOrNull(config.apiRegion)?.trim() || undefined } +} + +/** + * Jotform identifies a form's webhooks by their position in the form's webhook map, so an + * id captured at registration goes stale the moment any other webhook on the form is + * removed. Nothing persists it; the delete path re-resolves it by matching the URL. + */ +function findWebhookIdByUrl(content: unknown, notificationUrl: string): string | null { + return normalizeWebhooks(content).find((entry) => entry.url === notificationUrl)?.id ?? null +} + +async function listWebhookIdForUrl( + credentials: JotformSubscriptionCredentials, + notificationUrl: string +): Promise { + const response = await fetch( + buildJotformUrl( + credentials, + `form/${encodeURIComponent(credentials.formId)}/webhooks` + ).toString(), + { method: 'GET', headers: buildJotformHeaders(credentials.apiKey) } + ) + const envelope = await parseJotformResponse(response, 'Jotform List Webhooks') + return findWebhookIdByUrl(envelope.content, notificationUrl) +} + +export const jotformHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const payload = isRecordLike(body) ? body : {} + + /* Jotform posts `rawRequest` as a JSON string inside a multipart body. A form whose + answers fail to parse is still worth executing on, so a bad string degrades to null + rather than dropping the submission. */ + let rawRequest: unknown = null + const rawRequestString = toStringOrNull(payload.rawRequest) + if (rawRequestString) { + try { + rawRequest = JSON.parse(rawRequestString) + } catch (error) { + logger.warn('Jotform rawRequest was not valid JSON', { error: getErrorMessage(error) }) + } + } + + return { + input: { + formId: toStringOrNull(payload.formID) ?? '', + submissionId: toStringOrNull(payload.submissionID) ?? '', + formTitle: toStringOrNull(payload.formTitle) ?? '', + username: toStringOrNull(payload.username) ?? '', + ip: toStringOrNull(payload.ip) ?? '', + submissionType: toStringOrNull(payload.type) ?? '', + pretty: toStringOrNull(payload.pretty) ?? '', + rawRequest, + raw: payload, + }, + } + }, + + extractIdempotencyId(body: unknown): string | null { + if (!isRecordLike(body)) return null + const submissionId = toStringOrNull(body.submissionID) + return submissionId ? `submission:${submissionId}` : null + }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const credentials = readCredentials(ctx.webhook) + if ('error' in credentials) { + logger.warn(`[${ctx.requestId}] ${credentials.error}`, { webhookId: ctx.webhook.id }) + throw new Error(credentials.error) + } + + const notificationUrl = getNotificationUrl(ctx.webhook) + + try { + const response = await fetch( + buildJotformUrl( + credentials, + `form/${encodeURIComponent(credentials.formId)}/webhooks` + ).toString(), + { + method: 'POST', + headers: jotformFormHeaders(credentials.apiKey), + body: toFormBody({ webhookURL: notificationUrl }), + } + ) + + /* Jotform answers a rejected registration with the unchanged webhook list rather + than an error, so the response is only a success if our URL is in it. */ + const envelope = await parseJotformResponse(response, 'Jotform Create Webhook') + if (!findWebhookIdByUrl(envelope.content, notificationUrl)) { + throw new Error( + 'Jotform accepted the request but did not register the webhook URL on the form. Verify the form ID and that the API key has Full Access.' + ) + } + + logger.info(`[${ctx.requestId}] Registered Jotform webhook`, { + webhookId: ctx.webhook.id, + formId: credentials.formId, + }) + + return {} + } catch (error) { + logger.error(`[${ctx.requestId}] Failed to register Jotform webhook`, { + webhookId: ctx.webhook.id, + error: getErrorMessage(error), + }) + throw new Error(getErrorMessage(error, 'Failed to register the Jotform webhook.')) + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const credentials = readCredentials(ctx.webhook) + if ('error' in credentials) { + logger.warn( + `[${ctx.requestId}] Missing Jotform credentials, skipping webhook cleanup for ${ctx.webhook.id}` + ) + if (ctx.strict) throw new Error(credentials.error) + return + } + + const notificationUrl = getNotificationUrl(ctx.webhook) + + try { + const webhookId = await listWebhookIdForUrl(credentials, notificationUrl) + + if (!webhookId) { + logger.info( + `[${ctx.requestId}] Jotform webhook was already absent from form ${credentials.formId}` + ) + return + } + + const response = await fetch( + buildJotformUrl( + credentials, + `form/${encodeURIComponent(credentials.formId)}/webhooks/${encodeURIComponent(webhookId)}` + ).toString(), + { method: 'DELETE', headers: buildJotformHeaders(credentials.apiKey) } + ) + + await parseJotformResponse(response, 'Jotform Delete Webhook') + logger.info(`[${ctx.requestId}] Deleted Jotform webhook from form ${credentials.formId}`) + } catch (error) { + logger.warn(`[${ctx.requestId}] Error deleting Jotform webhook (non-fatal)`, { + error: getErrorMessage(error), + }) + if (ctx.strict) throw error + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 085280c9d55..cf2548611b8 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -26,6 +26,7 @@ import { incidentioHandler } from '@/lib/webhooks/providers/incidentio' import { instantlyHandler } from '@/lib/webhooks/providers/instantly' import { intercomHandler } from '@/lib/webhooks/providers/intercom' import { jiraHandler } from '@/lib/webhooks/providers/jira' +import { jotformHandler } from '@/lib/webhooks/providers/jotform' import { jsmHandler } from '@/lib/webhooks/providers/jsm' import { lemlistHandler } from '@/lib/webhooks/providers/lemlist' import { linearHandler } from '@/lib/webhooks/providers/linear' @@ -90,6 +91,7 @@ const PROVIDER_HANDLERS: Record = { intercom: intercomHandler, instantly: instantlyHandler, jira: jiraHandler, + jotform: jotformHandler, jsm: jsmHandler, lemlist: lemlistHandler, linear: linearHandler, diff --git a/apps/sim/triggers/jotform/index.ts b/apps/sim/triggers/jotform/index.ts new file mode 100644 index 00000000000..898766e6f2e --- /dev/null +++ b/apps/sim/triggers/jotform/index.ts @@ -0,0 +1 @@ +export { jotformWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/jotform/webhook.ts b/apps/sim/triggers/jotform/webhook.ts new file mode 100644 index 00000000000..efbfe26869d --- /dev/null +++ b/apps/sim/triggers/jotform/webhook.ts @@ -0,0 +1,129 @@ +import { JotformIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' + +export const jotformWebhookTrigger: TriggerConfig = { + id: 'jotform_webhook', + name: 'Jotform Webhook', + provider: 'jotform', + description: 'Trigger workflow when a Jotform form receives a new submission', + version: '1.0.0', + icon: JotformIcon, + + subBlocks: [ + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + mode: 'trigger', + }, + { + id: 'formId', + title: 'Form ID', + canvasNoun: 'a form', + type: 'short-input', + placeholder: 'e.g., 231504059977966', + description: + 'The form to watch. It is the numeric segment of the form URL, and the List Forms operation returns it.', + required: true, + mode: 'trigger', + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your Jotform API key', + description: + 'Used to register the webhook on the form automatically. Create one under Account Settings > API.', + password: true, + required: true, + mode: 'trigger', + }, + { + /* Named apart from the block's own `region` field: the two live in the same block + and a shared id would collide across tool mode and trigger mode. */ + id: 'apiRegion', + title: 'Region', + type: 'dropdown', + description: + 'Data residency region the API key belongs to. A key only works on the host that issued it.', + options: [ + { label: 'US (api.jotform.com)', id: 'us' }, + { label: 'EU (eu-api.jotform.com)', id: 'eu' }, + { label: 'HIPAA (hipaa-api.jotform.com)', id: 'hipaa' }, + ], + value: () => 'us', + mode: 'trigger', + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: [ + 'Create an API key at https://www.jotform.com/myaccount/api and give it Full Access, since read-only keys cannot add webhooks', + 'Pick the region your account is on. EU and HIPAA accounts are served by different hosts, and a key is rejected by every host but its own', + 'Copy the Form ID from the form URL, e.g. https://www.jotform.com/build/231504059977966 gives 231504059977966', + 'Sim registers the webhook on that form when you deploy the workflow, and removes it again when you undeploy', + 'Note: Only submissions made through the form fire this trigger. Submissions created through the API do not.', + ] + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join(''), + mode: 'trigger', + }, + ], + + outputs: { + formId: { + type: 'string', + description: 'ID of the form that was submitted', + }, + submissionId: { + type: 'string', + description: 'ID of the new submission', + }, + formTitle: { + type: 'string', + description: 'Title of the form at the time of submission', + }, + username: { + type: 'string', + description: 'Jotform account username that owns the form', + }, + ip: { + type: 'string', + description: 'IP address the submission came from', + }, + submissionType: { + type: 'string', + description: 'How the submission was made, e.g. WEB', + }, + pretty: { + type: 'string', + description: + 'Human-readable summary of the answers, as comma-separated "Question Label:Answer" pairs', + }, + rawRequest: { + type: 'json', + description: + "Submitted answers keyed by question, in Jotform's q{questionId}_{slugifiedLabel} form. Values are strings, or objects for multi-part questions such as name and address.", + }, + raw: { + type: 'json', + description: 'Complete original webhook payload from Jotform', + }, + }, + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data', + }, + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index c9c55455f44..32f016cc03b 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -266,6 +266,7 @@ import { jiraWorklogDeletedTrigger, jiraWorklogUpdatedTrigger, } from '@/triggers/jira' +import { jotformWebhookTrigger } from '@/triggers/jotform' import { jsmRequestCommentedTrigger, jsmRequestCreatedTrigger, @@ -661,6 +662,7 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { jira_sprint_closed: jiraSprintClosedTrigger, jira_project_created: jiraProjectCreatedTrigger, jira_version_released: jiraVersionReleasedTrigger, + jotform_webhook: jotformWebhookTrigger, jsm_request_created: jsmRequestCreatedTrigger, jsm_request_updated: jsmRequestUpdatedTrigger, jsm_request_commented: jsmRequestCommentedTrigger, From b154f11cfd0f70f43b8041ba1e27f5c6ec47722e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 17 Aug 2026 17:46:20 -0700 Subject: [PATCH 2/3] fix(jotform): make webhook registration idempotent and URL matching tolerant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated the trigger against Jotform's API reference and a captured delivery (zulip's multipart fixture), which confirmed every mapped field — formID, submissionID, formTitle, username, ip, type, pretty, rawRequest — and turned up three things worth correcting. Jotform keeps a form's webhooks as a plain list and does not treat the URL as a key, so posting one it already holds leaves the form delivering every submission twice. Registration now consults the list first and only posts when the URL is absent. The documented POST sample returns the new entry as "0", renumbering the rest, which is further reason nothing persists an id. URL matching no longer lets a trailing slash decide the outcome. Jotform stores the URL verbatim in every sample seen, but an exact match failing would hard-fail deploy, and Pipedream's client normalizes the same way. The rawRequest description claimed the field holds the submitted answers. A real payload also carries slug, buildDate, submitSource and jsExecutionTracker, and a file answer appears under the bare slugified label as upload URLs rather than under a q{qid}_ key — which is also why filtering to q-prefixed keys would silently drop file answers. --- .../content/docs/en/integrations/jotform.mdx | 4 +- .../lib/webhooks/providers/jotform.test.ts | 68 +++++++++++++++++-- apps/sim/lib/webhooks/providers/jotform.ts | 25 +++++-- apps/sim/triggers/jotform/webhook.ts | 4 +- 4 files changed, 88 insertions(+), 13 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/jotform.mdx b/apps/docs/content/docs/en/integrations/jotform.mdx index b710473f56d..cc03a74c049 100644 --- a/apps/docs/content/docs/en/integrations/jotform.mdx +++ b/apps/docs/content/docs/en/integrations/jotform.mdx @@ -1201,7 +1201,7 @@ Trigger workflow when a Jotform form receives a new submission | `username` | string | Jotform account username that owns the form | | `ip` | string | IP address the submission came from | | `submissionType` | string | How the submission was made, e.g. WEB | -| `pretty` | string | Human-readable summary of the answers, as comma-separated "Question Label:Answer" pairs | -| `rawRequest` | json | Submitted answers keyed by question, in Jotform's q\{questionId\}_\{slugifiedLabel\} form. Values are strings, or objects for multi-part questions such as name and address. | +| `pretty` | string | Human-readable summary of the answers, as comma-separated "Question Label:Answer" pairs. Unanswered questions are left out. | +| `rawRequest` | json | The submitted form body. Answers are keyed q\{questionId\}_\{slugifiedLabel\} and hold a string, or an object for a multi-part question such as name or address. A file answer instead appears under the plain slugified label as an array of upload URLs, with the chosen filenames under temp_upload. The body also carries form-internal fields such as slug, buildDate, submitSource, and jsExecutionTracker. | | `raw` | json | Complete original webhook payload from Jotform | diff --git a/apps/sim/lib/webhooks/providers/jotform.test.ts b/apps/sim/lib/webhooks/providers/jotform.test.ts index ffa0f7766e4..b94a8f0372d 100644 --- a/apps/sim/lib/webhooks/providers/jotform.test.ts +++ b/apps/sim/lib/webhooks/providers/jotform.test.ts @@ -64,6 +64,41 @@ describe('jotformHandler formatInput', () => { }) }) + /** + * Field names and shapes taken from a captured Jotform delivery: answers sit under + * q{qid}_{slug}, a file answer lands under the bare slug as upload URLs, and the body + * carries form-internal keys alongside them. + */ + it('carries a real payload through unflattened', async () => { + const result = await jotformHandler.formatInput!({ + body: { + formID: '243231271343446', + submissionID: '6084250982513018472', + formTitle: 'Tutor Appointment Form', + username: 'UserNiloth', + type: 'WEB', + ip: '27.51.18.17', + pretty: "Student's Name:Niloth P, Grade:12", + rawRequest: JSON.stringify({ + slug: 'submit/243231271343446', + jsExecutionTracker: 'build-date-1732271172685=>init-started', + q3_studentsName: { first: 'Niloth', last: 'P' }, + q35_grade: '12', + temp_upload: { q6_reports: ['Report Card.pdf#jotformfs-e4f4'] }, + reports: ['https://www.jotform.com/uploads/UserNiloth/Report%20Card.pdf'], + }), + }, + } as never) + + const input = result.input as Record> + expect(input.submissionType).toBe('WEB') + expect(input.rawRequest.q3_studentsName).toEqual({ first: 'Niloth', last: 'P' }) + expect(input.rawRequest.reports).toEqual([ + 'https://www.jotform.com/uploads/UserNiloth/Report%20Card.pdf', + ]) + expect(input.rawRequest.slug).toBe('submit/243231271343446') + }) + it('keeps the submission when rawRequest is not valid JSON', async () => { const result = await jotformHandler.formatInput!({ body: { submissionID: '5678', rawRequest: 'not json' }, @@ -91,31 +126,54 @@ describe('jotformHandler createSubscription', () => { }) it('registers the notification URL on the form', async () => { - fetchMock.mockResolvedValueOnce(envelope({ '0': NOTIFICATION_URL })) + fetchMock + .mockResolvedValueOnce(envelope({})) + .mockResolvedValueOnce(envelope({ '0': NOTIFICATION_URL })) await jotformHandler.createSubscription!( createContext({ formId: '231504059977966', apiKey: 'jf-key' }) ) - const [url, init] = fetchMock.mock.calls[0] + const [url, init] = fetchMock.mock.calls[1] expect(url).toBe('https://api.jotform.com/form/231504059977966/webhooks') expect(init.method).toBe('POST') expect(init.headers.APIKEY).toBe('jf-key') expect(init.body).toBe(`webhookURL=${encodeURIComponent(NOTIFICATION_URL)}`) }) - it('uses the host that issued the key', async () => { + it('does not post again when the form already carries the URL', async () => { fetchMock.mockResolvedValueOnce(envelope({ '0': NOTIFICATION_URL })) + await jotformHandler.createSubscription!(createContext({ formId: '1', apiKey: 'jf-key' })) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0][1].method).toBe('GET') + }) + + it('treats a stored URL that differs only by a trailing slash as the same webhook', async () => { + fetchMock.mockResolvedValueOnce(envelope({ '0': `${NOTIFICATION_URL}/` })) + + await jotformHandler.createSubscription!(createContext({ formId: '1', apiKey: 'jf-key' })) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('uses the host that issued the key', async () => { + fetchMock + .mockResolvedValueOnce(envelope({})) + .mockResolvedValueOnce(envelope({ '0': NOTIFICATION_URL })) + await jotformHandler.createSubscription!( createContext({ formId: '1', apiKey: 'jf-key', apiRegion: 'eu' }) ) - expect(fetchMock.mock.calls[0][0]).toBe('https://eu-api.jotform.com/form/1/webhooks') + expect(fetchMock.mock.calls[1][0]).toBe('https://eu-api.jotform.com/form/1/webhooks') }) it('fails when the URL is missing from the returned webhook list', async () => { - fetchMock.mockResolvedValueOnce(envelope({ '0': 'https://elsewhere.example.com/hook' })) + fetchMock + .mockResolvedValueOnce(envelope({})) + .mockResolvedValueOnce(envelope({ '0': 'https://elsewhere.example.com/hook' })) await expect( jotformHandler.createSubscription!(createContext({ formId: '1', apiKey: 'jf-key' })) diff --git a/apps/sim/lib/webhooks/providers/jotform.ts b/apps/sim/lib/webhooks/providers/jotform.ts index 4cfc433d0ef..55c6ee23d6b 100644 --- a/apps/sim/lib/webhooks/providers/jotform.ts +++ b/apps/sim/lib/webhooks/providers/jotform.ts @@ -46,13 +46,19 @@ function readCredentials( return { formId, apiKey, region: toStringOrNull(config.apiRegion)?.trim() || undefined } } +/** Compares callback URLs without letting a trailing slash decide the outcome. */ +function sameUrl(a: string, b: string): boolean { + return a.replace(/\/+$/, '') === b.replace(/\/+$/, '') +} + /** - * Jotform identifies a form's webhooks by their position in the form's webhook map, so an - * id captured at registration goes stale the moment any other webhook on the form is - * removed. Nothing persists it; the delete path re-resolves it by matching the URL. + * Jotform identifies a form's webhooks by their position in the form's webhook map, and + * adding one renumbers the rest — the documented POST sample returns the new entry as + * `"0"`. An id captured at registration is therefore stale as soon as the form's webhooks + * change at all. Nothing persists it; every path re-resolves it by matching the URL. */ function findWebhookIdByUrl(content: unknown, notificationUrl: string): string | null { - return normalizeWebhooks(content).find((entry) => entry.url === notificationUrl)?.id ?? null + return normalizeWebhooks(content).find((entry) => sameUrl(entry.url, notificationUrl))?.id ?? null } async function listWebhookIdForUrl( @@ -118,6 +124,17 @@ export const jotformHandler: WebhookProviderHandler = { const notificationUrl = getNotificationUrl(ctx.webhook) try { + /* Jotform keeps a plain list and does not treat the URL as a key, so posting one it + already holds is not a no-op — it would leave the form delivering every submission + twice. Redeploys re-run this, so the existing list decides whether to post. */ + if (await listWebhookIdForUrl(credentials, notificationUrl)) { + logger.info(`[${ctx.requestId}] Jotform webhook was already registered`, { + webhookId: ctx.webhook.id, + formId: credentials.formId, + }) + return {} + } + const response = await fetch( buildJotformUrl( credentials, diff --git a/apps/sim/triggers/jotform/webhook.ts b/apps/sim/triggers/jotform/webhook.ts index efbfe26869d..433676616d1 100644 --- a/apps/sim/triggers/jotform/webhook.ts +++ b/apps/sim/triggers/jotform/webhook.ts @@ -107,12 +107,12 @@ export const jotformWebhookTrigger: TriggerConfig = { pretty: { type: 'string', description: - 'Human-readable summary of the answers, as comma-separated "Question Label:Answer" pairs', + 'Human-readable summary of the answers, as comma-separated "Question Label:Answer" pairs. Unanswered questions are left out.', }, rawRequest: { type: 'json', description: - "Submitted answers keyed by question, in Jotform's q{questionId}_{slugifiedLabel} form. Values are strings, or objects for multi-part questions such as name and address.", + 'The submitted form body. Answers are keyed q{questionId}_{slugifiedLabel} and hold a string, or an object for a multi-part question such as name or address. A file answer instead appears under the plain slugified label as an array of upload URLs, with the chosen filenames under temp_upload. The body also carries form-internal fields such as slug, buildDate, submitSource, and jsExecutionTracker.', }, raw: { type: 'json', From c50c11ea4ef3984f9494ea574d700d50569e35d1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 17 Aug 2026 17:55:04 -0700 Subject: [PATCH 3/3] fix(jotform): keep the callback when an active deployment still needs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redeploying prepares the replacement webhook row alongside the live one and a workflow keeps its path across deployments, so both rows resolve to a single callback on a single form. Registration adopts the callback already present instead of posting a duplicate, which left the retired row's cleanup deleting the one the new row had just adopted — the trigger went silent after a redeploy that changed the trigger config. Teardown now skips when another webhook row belonging to an active deployment resolves to the same form and callback URL, matching how the Telegram handler skips deleteWebhook while an active deployment still uses the same bot. A genuine undeploy has no such row and still cleans up. --- .../lib/webhooks/providers/jotform.test.ts | 47 +++++++++++++- apps/sim/lib/webhooks/providers/jotform.ts | 63 +++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/jotform.test.ts b/apps/sim/lib/webhooks/providers/jotform.test.ts index b94a8f0372d..099c68a5ea1 100644 --- a/apps/sim/lib/webhooks/providers/jotform.test.ts +++ b/apps/sim/lib/webhooks/providers/jotform.test.ts @@ -1,15 +1,19 @@ /** * @vitest-environment node */ +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + const WEBHOOK_ID = 'webhook-uuid-1234' const NOTIFICATION_URL = 'https://app.example.com/api/webhooks/trigger/jotform-path' vi.mock('@/lib/webhooks/provider-subscription-utils', () => ({ getProviderConfig: (webhook: { providerConfig?: Record }) => webhook.providerConfig || {}, - getNotificationUrl: () => NOTIFICATION_URL, + getNotificationUrl: (webhook: { path?: string | null }) => + `https://app.example.com/api/webhooks/trigger/${webhook.path ?? 'jotform-path'}`, })) import { jotformHandler } from '@/lib/webhooks/providers/jotform' @@ -18,7 +22,7 @@ const fetchMock = vi.fn() function createContext(providerConfig: Record) { return { - webhook: { id: WEBHOOK_ID, path: 'jotform-path', providerConfig }, + webhook: { id: WEBHOOK_ID, workflowId: 'wf-1', path: 'jotform-path', providerConfig }, workflow: {}, userId: 'user-1', requestId: 'req-1', @@ -201,6 +205,7 @@ describe('jotformHandler createSubscription', () => { describe('jotformHandler deleteSubscription', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('fetch', fetchMock) }) @@ -226,6 +231,44 @@ describe('jotformHandler deleteSubscription', () => { expect(fetchMock).toHaveBeenCalledTimes(1) }) + /** + * Redeploying prepares the replacement row before the retired row is cleaned up, and the + * workflow keeps its path, so both rows name one callback on one form. Without the guard + * the retired row's cleanup deletes the callback the live row is now relying on and the + * trigger goes silent. + */ + it('leaves the callback alone while an active deployment is served by it', async () => { + queueTableRows(schemaMock.webhook, [{ path: 'jotform-path', providerConfig: { formId: '1' } }]) + + await jotformHandler.deleteSubscription!(createContext({ formId: '1', apiKey: 'jf-key' })) + + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('still deletes when the active deployment points at a different form', async () => { + queueTableRows(schemaMock.webhook, [ + { path: 'jotform-path', providerConfig: { formId: '999' } }, + ]) + fetchMock + .mockResolvedValueOnce(envelope({ '0': NOTIFICATION_URL })) + .mockResolvedValueOnce(envelope({})) + + await jotformHandler.deleteSubscription!(createContext({ formId: '1', apiKey: 'jf-key' })) + + expect(fetchMock.mock.calls[1][1].method).toBe('DELETE') + }) + + it('still deletes when the active deployment is served by a different path', async () => { + queueTableRows(schemaMock.webhook, [{ path: 'other-path', providerConfig: { formId: '1' } }]) + fetchMock + .mockResolvedValueOnce(envelope({ '0': NOTIFICATION_URL })) + .mockResolvedValueOnce(envelope({})) + + await jotformHandler.deleteSubscription!(createContext({ formId: '1', apiKey: 'jf-key' })) + + expect(fetchMock.mock.calls[1][1].method).toBe('DELETE') + }) + it('swallows a failed cleanup unless the caller is strict', async () => { fetchMock.mockRejectedValue(new Error('network down')) diff --git a/apps/sim/lib/webhooks/providers/jotform.ts b/apps/sim/lib/webhooks/providers/jotform.ts index 55c6ee23d6b..bdad7809d73 100644 --- a/apps/sim/lib/webhooks/providers/jotform.ts +++ b/apps/sim/lib/webhooks/providers/jotform.ts @@ -1,6 +1,8 @@ +import { db, webhook, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' +import { and, eq, isNull, ne } from 'drizzle-orm' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, @@ -76,6 +78,53 @@ async function listWebhookIdForUrl( return findWebhookIdByUrl(envelope.content, notificationUrl) } +/** + * Reports whether another webhook row belonging to an active deployment is served by the + * same Jotform callback — the same form and the same URL. + * + * Redeploying a trigger prepares the replacement row alongside the live one, and a workflow + * keeps its webhook path across deployments, so both rows resolve to one callback on one + * form. Registration adopts the callback already there instead of posting a duplicate, which + * leaves the retired row's cleanup pointing at the callback the new row now depends on. + * Teardown is skipped in that case, exactly as the Telegram handler skips `deleteWebhook` + * while an active deployment still uses the same bot. + */ +async function activeDeploymentSharesJotformCallback( + webhookRecord: Record, + formId: string, + notificationUrl: string +): Promise { + const workflowId = webhookRecord.workflowId + const webhookId = webhookRecord.id + if (typeof workflowId !== 'string' || typeof webhookId !== 'string') return false + + const activeWebhooks = await db + .select({ path: webhook.path, providerConfig: webhook.providerConfig }) + .from(webhook) + .innerJoin( + workflowDeploymentVersion, + eq(webhook.deploymentVersionId, workflowDeploymentVersion.id) + ) + .where( + and( + eq(webhook.workflowId, workflowId), + ne(webhook.id, webhookId), + eq(webhook.provider, 'jotform'), + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.isActive, true), + isNull(webhook.archivedAt) + ) + ) + + return activeWebhooks.some((activeWebhook) => { + const config = getProviderConfig({ providerConfig: activeWebhook.providerConfig }) + return ( + toStringOrNull(config.formId)?.trim() === formId && + sameUrl(getNotificationUrl({ path: activeWebhook.path }), notificationUrl) + ) + }) +} + export const jotformHandler: WebhookProviderHandler = { async formatInput({ body }: FormatInputContext): Promise { const payload = isRecordLike(body) ? body : {} @@ -184,6 +233,20 @@ export const jotformHandler: WebhookProviderHandler = { const notificationUrl = getNotificationUrl(ctx.webhook) try { + if ( + await activeDeploymentSharesJotformCallback( + ctx.webhook, + credentials.formId, + notificationUrl + ) + ) { + logger.info( + `[${ctx.requestId}] Skipping Jotform webhook deletion because an active deployment is served by the same callback`, + { webhookId: ctx.webhook.id } + ) + return + } + const webhookId = await listWebhookIdForUrl(credentials, notificationUrl) if (!webhookId) {