Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/docs/content/docs/en/integrations/jotform.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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 |

10 changes: 6 additions & 4 deletions apps/sim/blocks/blocks/jotform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down
8 changes: 8 additions & 0 deletions apps/sim/blocks/blocks/jotform.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -669,6 +670,8 @@ export const JotformBlock: BlockConfig = {
condition: { field: 'operation', value: 'get_history' },
mode: 'advanced',
},

...getTrigger('jotform_webhook').subBlocks,
],

tools: {
Expand Down Expand Up @@ -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 = {
Expand Down
12 changes: 9 additions & 3 deletions apps/sim/lib/integrations/integrations.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"updatedAt": "2026-08-17",
"updatedAt": "2026-08-18",
"integrations": [
{
"type": "onepassword",
Expand Down Expand Up @@ -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",
Expand Down
59 changes: 58 additions & 1 deletion apps/sim/lib/webhooks/processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -136,6 +136,7 @@ import {
dispatchResolvedWebhookTarget,
findAllWebhooksForPath,
handleWebhookEventFilter,
parseWebhookBody,
processPolledWebhookEvent,
} from '@/lib/webhooks/processor'

Expand Down Expand Up @@ -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)
})
})
27 changes: 27 additions & 0 deletions apps/sim/lib/webhooks/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> {
const formData = await new Response(rawBody, {
headers: { 'content-type': contentType },
}).formData()

const fields: Record<string, unknown> = {}
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
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading