Skip to content

Commit d5502d6

Browse files
feat(webhooks): dedup and custom ack configuration (#3525)
* feat(webhooks): dedup and custom ack configuration * address review comments * reject object typed idempotency key
1 parent 37d524b commit d5502d6

File tree

7 files changed

+91
-8
lines changed

7 files changed

+91
-8
lines changed

apps/sim/blocks/blocks/generic_webhook.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const GenericWebhookBlock: BlockConfig = {
1818
bestPractices: `
1919
- You can test the webhook by sending a request to the webhook URL. E.g. depending on authorization: curl -X POST http://localhost:3000/api/webhooks/trigger/d8abcf0d-1ee5-4b77-bb07-b1e8142ea4e9 -H "Content-Type: application/json" -H "X-Sim-Secret: 1234" -d '{"message": "Test webhook trigger", "data": {"key": "v"}}'
2020
- Continuing example above, the body can be accessed in downstream block using dot notation. E.g. <webhook1.message> and <webhook1.data.key>
21+
- To deduplicate incoming events, set the Deduplication Field to a dot-notation path of a unique field in the payload (e.g. "event.id"). Duplicate values within 7 days will be skipped.
2122
- Only use when there's no existing integration for the service with triggerAllowed flag set to true.
2223
`,
2324
subBlocks: [...getTrigger('generic_webhook').subBlocks],

apps/sim/executor/handlers/trigger/trigger-handler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export class TriggerBlockHandler implements BlockHandler {
2222
}
2323

2424
const existingState = ctx.blockStates.get(block.id)
25-
if (existingState?.output && Object.keys(existingState.output).length > 0) {
25+
if (existingState?.output) {
2626
return existingState.output
2727
}
2828

apps/sim/lib/core/idempotency/service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ export class IdempotencyService {
413413
: undefined
414414

415415
const webhookIdHeader =
416+
normalizedHeaders?.['x-sim-idempotency-key'] ||
416417
normalizedHeaders?.['webhook-id'] ||
417418
normalizedHeaders?.['x-webhook-id'] ||
418419
normalizedHeaders?.['x-shopify-webhook-id'] ||

apps/sim/lib/webhooks/processor.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,7 +1049,7 @@ export async function queueWebhookExecution(
10491049
}
10501050
}
10511051

1052-
const headers = Object.fromEntries(request.headers.entries())
1052+
const { 'x-sim-idempotency-key': _, ...headers } = Object.fromEntries(request.headers.entries())
10531053

10541054
// For Microsoft Teams Graph notifications, extract unique identifiers for idempotency
10551055
if (
@@ -1067,9 +1067,20 @@ export async function queueWebhookExecution(
10671067
}
10681068
}
10691069

1070-
// Extract credentialId from webhook config
1071-
// Note: Each webhook now has its own credentialId (credential sets are fanned out at save time)
10721070
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
1071+
1072+
if (foundWebhook.provider === 'generic') {
1073+
const idempotencyField = providerConfig.idempotencyField as string | undefined
1074+
if (idempotencyField && body) {
1075+
const value = idempotencyField
1076+
.split('.')
1077+
.reduce((acc: any, key: string) => acc?.[key], body)
1078+
if (value !== undefined && value !== null && typeof value !== 'object') {
1079+
headers['x-sim-idempotency-key'] = String(value)
1080+
}
1081+
}
1082+
}
1083+
10731084
const credentialId = providerConfig.credentialId as string | undefined
10741085

10751086
// credentialSetId is a direct field on webhook table, not in providerConfig
@@ -1193,6 +1204,26 @@ export async function queueWebhookExecution(
11931204
})
11941205
}
11951206

1207+
if (foundWebhook.provider === 'generic' && providerConfig.responseMode === 'custom') {
1208+
const rawCode = Number(providerConfig.responseStatusCode) || 200
1209+
const statusCode = rawCode >= 100 && rawCode <= 599 ? rawCode : 200
1210+
const responseBody = (providerConfig.responseBody as string | undefined)?.trim()
1211+
1212+
if (!responseBody) {
1213+
return new NextResponse(null, { status: statusCode })
1214+
}
1215+
1216+
try {
1217+
const parsed = JSON.parse(responseBody)
1218+
return NextResponse.json(parsed, { status: statusCode })
1219+
} catch {
1220+
return new NextResponse(responseBody, {
1221+
status: statusCode,
1222+
headers: { 'Content-Type': 'text/plain' },
1223+
})
1224+
}
1225+
}
1226+
11961227
return NextResponse.json({ message: 'Webhook processed' })
11971228
} catch (error: any) {
11981229
logger.error(`[${options.requestId}] Failed to queue webhook execution:`, error)

apps/sim/lib/workflows/comparison/compare.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ describe('hasWorkflowChanged', () => {
433433
expect(hasWorkflowChanged(state1, state2)).toBe(true)
434434
})
435435

436-
it.concurrent('should detect subBlock type changes', () => {
436+
it.concurrent('should ignore subBlock type changes', () => {
437437
const state1 = createWorkflowState({
438438
blocks: {
439439
block1: createBlock('block1', {
@@ -448,7 +448,7 @@ describe('hasWorkflowChanged', () => {
448448
}),
449449
},
450450
})
451-
expect(hasWorkflowChanged(state1, state2)).toBe(true)
451+
expect(hasWorkflowChanged(state1, state2)).toBe(false)
452452
})
453453

454454
it.concurrent('should handle null/undefined subBlock values consistently', () => {

apps/sim/lib/workflows/comparison/normalize.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,14 @@ export function normalizeSubBlockValue(subBlockId: string, value: unknown): unkn
496496
* @returns SubBlock fields excluding value and is_diff
497497
*/
498498
export function extractSubBlockRest(subBlock: Record<string, unknown>): Record<string, unknown> {
499-
const { value: _v, is_diff: _sd, ...rest } = subBlock as SubBlockWithDiffMarker
499+
const {
500+
value: _v,
501+
is_diff: _sd,
502+
type: _type,
503+
...rest
504+
} = subBlock as SubBlockWithDiffMarker & {
505+
type?: unknown
506+
}
500507
return rest
501508
}
502509

apps/sim/triggers/generic/webhook.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,49 @@ export const genericWebhookTrigger: TriggerConfig = {
4949
required: false,
5050
mode: 'trigger',
5151
},
52+
{
53+
id: 'idempotencyField',
54+
title: 'Deduplication Field (Optional)',
55+
type: 'short-input',
56+
placeholder: 'e.g. event.id',
57+
description:
58+
'Dot-notation path to a unique field in the payload for deduplication. If the same value is seen within 7 days, the duplicate webhook will be skipped.',
59+
required: false,
60+
mode: 'trigger',
61+
},
62+
{
63+
id: 'responseMode',
64+
title: 'Acknowledgement',
65+
type: 'dropdown',
66+
options: [
67+
{ label: 'Default', id: 'default' },
68+
{ label: 'Custom', id: 'custom' },
69+
],
70+
defaultValue: 'default',
71+
mode: 'trigger',
72+
},
73+
{
74+
id: 'responseStatusCode',
75+
title: 'Response Status Code',
76+
type: 'short-input',
77+
placeholder: '200 (default)',
78+
description:
79+
'HTTP status code (100–599) to return to the webhook caller. Defaults to 200 if empty or invalid.',
80+
required: false,
81+
mode: 'trigger',
82+
condition: { field: 'responseMode', value: 'custom' },
83+
},
84+
{
85+
id: 'responseBody',
86+
title: 'Response Body',
87+
type: 'code',
88+
language: 'json',
89+
placeholder: '{"ok": true}',
90+
description: 'JSON body to return to the webhook caller. Leave empty for no body.',
91+
required: false,
92+
mode: 'trigger',
93+
condition: { field: 'responseMode', value: 'custom' },
94+
},
5295
{
5396
id: 'inputFormat',
5497
title: 'Input Format',
@@ -76,7 +119,7 @@ export const genericWebhookTrigger: TriggerConfig = {
76119
'The webhook will receive any HTTP method (GET, POST, PUT, DELETE, etc.).',
77120
'All request data (headers, body, query parameters) will be available in your workflow.',
78121
'If authentication is enabled, include the token in requests using either the custom header or "Authorization: Bearer TOKEN".',
79-
'Common fields like "event", "id", and "data" will be automatically extracted from the payload when available.',
122+
'To deduplicate incoming events, set the Deduplication Field to the dot-notation path of a unique identifier in the payload (e.g. "event.id"). Duplicate values within 7 days will be skipped.',
80123
]
81124
.map(
82125
(instruction, index) =>

0 commit comments

Comments
 (0)