Skip to content

Commit 04922fe

Browse files
fix(generic-webhooks): idempotency simplification, generic webhook vars changes (#1384)
* fix(idempotency): simplify for deterministic provider based checks * remove generic webhook outputs and allow body to be referenced via vars
1 parent 8e70a61 commit 04922fe

File tree

8 files changed

+50
-183
lines changed

8 files changed

+50
-183
lines changed

apps/sim/app/api/webhooks/cleanup/idempotency/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type NextRequest, NextResponse } from 'next/server'
22
import { verifyCronAuth } from '@/lib/auth/internal'
3-
import { cleanupExpiredIdempotencyKeys, getIdempotencyKeyStats } from '@/lib/idempotency/cleanup'
3+
import { cleanupExpiredIdempotencyKeys, getIdempotencyKeyStats } from '@/lib/idempotency'
44
import { createLogger } from '@/lib/logs/console/logger'
55
import { generateRequestId } from '@/lib/utils'
66

apps/sim/app/api/webhooks/trigger/[path]/route.ts

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -375,39 +375,40 @@ export async function POST(
375375

376376
const idempotencyKey = IdempotencyService.createWebhookIdempotencyKey(
377377
foundWebhook.id,
378-
body,
379378
Object.fromEntries(request.headers.entries())
380379
)
381380

382-
const result = await webhookIdempotency.executeWithIdempotency(
383-
foundWebhook.provider,
384-
idempotencyKey,
385-
async () => {
386-
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
381+
const runOperation = async () => {
382+
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
387383

388-
if (useTrigger) {
389-
const handle = await tasks.trigger('webhook-execution', payload)
390-
logger.info(
391-
`[${requestId}] Queued webhook execution task ${handle.id} for ${foundWebhook.provider} webhook`
392-
)
393-
return {
394-
method: 'trigger.dev',
395-
taskId: handle.id,
396-
status: 'queued',
397-
}
398-
}
399-
// Fire-and-forget direct execution to avoid blocking webhook response
400-
void executeWebhookJob(payload).catch((error) => {
401-
logger.error(`[${requestId}] Direct webhook execution failed`, error)
402-
})
384+
if (useTrigger) {
385+
const handle = await tasks.trigger('webhook-execution', payload)
403386
logger.info(
404-
`[${requestId}] Queued direct webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)`
387+
`[${requestId}] Queued webhook execution task ${handle.id} for ${foundWebhook.provider} webhook`
405388
)
406389
return {
407-
method: 'direct',
390+
method: 'trigger.dev',
391+
taskId: handle.id,
408392
status: 'queued',
409393
}
410394
}
395+
// Fire-and-forget direct execution to avoid blocking webhook response
396+
void executeWebhookJob(payload).catch((error) => {
397+
logger.error(`[${requestId}] Direct webhook execution failed`, error)
398+
})
399+
logger.info(
400+
`[${requestId}] Queued direct webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)`
401+
)
402+
return {
403+
method: 'direct',
404+
status: 'queued',
405+
}
406+
}
407+
408+
const result = await webhookIdempotency.executeWithIdempotency(
409+
foundWebhook.provider,
410+
idempotencyKey,
411+
runOperation
411412
)
412413

413414
logger.debug(`[${requestId}] Webhook execution result:`, result)

apps/sim/background/webhook-execution.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,17 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) {
4444

4545
const idempotencyKey = IdempotencyService.createWebhookIdempotencyKey(
4646
payload.webhookId,
47-
payload.body,
4847
payload.headers
4948
)
5049

50+
const runOperation = async () => {
51+
return await executeWebhookJobInternal(payload, executionId, requestId)
52+
}
53+
5154
return await webhookIdempotency.executeWithIdempotency(
5255
payload.provider,
5356
idempotencyKey,
54-
async () => {
55-
return await executeWebhookJobInternal(payload, executionId, requestId)
56-
}
57+
runOperation
5758
)
5859
}
5960

apps/sim/blocks/blocks/generic_webhook.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,7 @@ export const GenericWebhookBlock: BlockConfig = {
2727

2828
inputs: {}, // No inputs - webhook triggers receive data externally
2929

30-
outputs: {
31-
// Generic webhook outputs that can be used with any webhook payload
32-
payload: { type: 'json', description: 'Complete webhook payload' },
33-
headers: { type: 'json', description: 'Request headers' },
34-
method: { type: 'string', description: 'HTTP method' },
35-
url: { type: 'string', description: 'Request URL' },
36-
timestamp: { type: 'string', description: 'Webhook received timestamp' },
37-
// Common webhook fields that services often use
38-
event: { type: 'string', description: 'Event type from payload' },
39-
id: { type: 'string', description: 'Event ID from payload' },
40-
data: { type: 'json', description: 'Event data from payload' },
41-
},
30+
outputs: {},
4231

4332
triggers: {
4433
enabled: true,

apps/sim/lib/idempotency/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,5 @@ export * from './cleanup'
22
export * from './service'
33
export {
44
pollingIdempotency,
5-
triggerIdempotency,
65
webhookIdempotency,
76
} from './service'

apps/sim/lib/idempotency/service.ts

Lines changed: 14 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import * as crypto from 'crypto'
1+
import { randomUUID } from 'crypto'
22
import { db } from '@sim/db'
33
import { idempotencyKey } from '@sim/db/schema'
44
import { and, eq } from 'drizzle-orm'
@@ -451,110 +451,26 @@ export class IdempotencyService {
451451

452452
/**
453453
* Create an idempotency key from a webhook payload following RFC best practices
454-
* Priority order:
455-
* 1. Standard webhook headers (webhook-id, x-webhook-id, etc.)
456-
* 2. Event/message IDs from payload
457-
* 3. Deterministic hash of stable payload fields (excluding timestamps)
454+
* Standard webhook headers (webhook-id, x-webhook-id, etc.)
458455
*/
459-
static createWebhookIdempotencyKey(
460-
webhookId: string,
461-
payload: any,
462-
headers?: Record<string, string>
463-
): string {
464-
// 1. Check for standard webhook headers (RFC compliant)
456+
static createWebhookIdempotencyKey(webhookId: string, headers?: Record<string, string>): string {
457+
const normalizedHeaders = headers
458+
? Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]))
459+
: undefined
460+
465461
const webhookIdHeader =
466-
headers?.['webhook-id'] || // Standard Webhooks spec
467-
headers?.['x-webhook-id'] || // Legacy standard
468-
headers?.['x-shopify-webhook-id'] ||
469-
headers?.['x-github-delivery'] ||
470-
headers?.['x-event-id'] // Generic event ID header
462+
normalizedHeaders?.['webhook-id'] ||
463+
normalizedHeaders?.['x-webhook-id'] ||
464+
normalizedHeaders?.['x-shopify-webhook-id'] ||
465+
normalizedHeaders?.['x-github-delivery'] ||
466+
normalizedHeaders?.['x-event-id']
471467

472468
if (webhookIdHeader) {
473469
return `${webhookId}:${webhookIdHeader}`
474470
}
475471

476-
// 2. Extract event/message IDs from payload (most reliable)
477-
const payloadId =
478-
payload?.id ||
479-
payload?.event_id ||
480-
payload?.eventId ||
481-
payload?.message?.id ||
482-
payload?.data?.id ||
483-
payload?.object?.id ||
484-
payload?.event?.id
485-
486-
if (payloadId) {
487-
return `${webhookId}:${payloadId}`
488-
}
489-
490-
// 3. Create deterministic hash from stable payload fields (excluding timestamps)
491-
const stablePayload = IdempotencyService.createStablePayloadForHashing(payload)
492-
const payloadHash = crypto
493-
.createHash('sha256')
494-
.update(JSON.stringify(stablePayload))
495-
.digest('hex')
496-
.substring(0, 16)
497-
498-
return `${webhookId}:${payloadHash}`
499-
}
500-
501-
/**
502-
* Create a stable representation of the payload for hashing by removing
503-
* timestamp and other volatile fields that change between requests
504-
*/
505-
private static createStablePayloadForHashing(payload: any): any {
506-
if (!payload || typeof payload !== 'object') {
507-
return payload
508-
}
509-
510-
const volatileFields = [
511-
'timestamp',
512-
'created_at',
513-
'updated_at',
514-
'sent_at',
515-
'received_at',
516-
'processed_at',
517-
'delivered_at',
518-
'attempt',
519-
'retry_count',
520-
'request_id',
521-
'trace_id',
522-
'span_id',
523-
'delivery_id',
524-
'webhook_timestamp',
525-
]
526-
527-
const cleanPayload = { ...payload }
528-
529-
const removeVolatileFields = (obj: any): any => {
530-
if (!obj || typeof obj !== 'object') return obj
531-
532-
if (Array.isArray(obj)) {
533-
return obj.map(removeVolatileFields)
534-
}
535-
536-
const cleaned: any = {}
537-
for (const [key, value] of Object.entries(obj)) {
538-
const lowerKey = key.toLowerCase()
539-
540-
if (volatileFields.some((field) => lowerKey.includes(field))) {
541-
continue
542-
}
543-
544-
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
545-
continue
546-
}
547-
if (typeof value === 'number' && value > 1000000000 && value < 9999999999) {
548-
continue
549-
}
550-
551-
cleaned[key] = removeVolatileFields(value)
552-
}
553-
554-
return cleaned
555-
}
556-
557-
return removeVolatileFields(cleanPayload)
472+
const uniqueId = randomUUID()
473+
return `${webhookId}:${uniqueId}`
558474
}
559475
}
560476

@@ -567,8 +483,3 @@ export const pollingIdempotency = new IdempotencyService({
567483
namespace: 'polling',
568484
ttlSeconds: 60 * 60 * 24 * 3, // 3 days
569485
})
570-
571-
export const triggerIdempotency = new IdempotencyService({
572-
namespace: 'trigger',
573-
ttlSeconds: 60 * 60 * 24 * 1, // 1 day
574-
})

apps/sim/lib/webhooks/utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,10 @@ export function formatWebhookInput(
512512
}
513513
}
514514

515+
if (foundWebhook.provider === 'generic') {
516+
return body
517+
}
518+
515519
if (foundWebhook.provider === 'google_forms') {
516520
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
517521

apps/sim/triggers/generic/webhook.ts

Lines changed: 1 addition & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -34,45 +34,7 @@ export const genericWebhookTrigger: TriggerConfig = {
3434
},
3535
},
3636

37-
outputs: {
38-
payload: {
39-
type: 'json',
40-
description: 'Complete webhook payload received',
41-
},
42-
headers: {
43-
type: 'json',
44-
description: 'HTTP request headers',
45-
},
46-
method: {
47-
type: 'string',
48-
description: 'HTTP method (GET, POST, PUT, etc.)',
49-
},
50-
url: {
51-
type: 'string',
52-
description: 'Request URL path',
53-
},
54-
query: {
55-
type: 'json',
56-
description: 'URL query parameters',
57-
},
58-
timestamp: {
59-
type: 'string',
60-
description: 'Webhook received timestamp',
61-
},
62-
// Common fields that many services use
63-
event: {
64-
type: 'string',
65-
description: 'Event type (extracted from payload.event, payload.type, or payload.event_type)',
66-
},
67-
id: {
68-
type: 'string',
69-
description: 'Event ID (extracted from payload.id, payload.event_id, or payload.uuid)',
70-
},
71-
data: {
72-
type: 'json',
73-
description: 'Event data (extracted from payload.data or the full payload)',
74-
},
75-
},
37+
outputs: {},
7638

7739
instructions: [
7840
'Copy the webhook URL provided above and use it in your external service or API.',

0 commit comments

Comments
 (0)