From 3faf8fc197b48888ab4fc90e59efcbf011ee2dda Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 14:31:54 -0700 Subject: [PATCH 1/9] fix(ashby): repair broken idempotency dedup and clarify duplicate-webhook errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractIdempotencyId looked for a webhookActionId field that does not exist anywhere in Ashby's webhook payload schema (confirmed against the live OpenAPI spec), so every delivery — including Ashby's own retries — got a fresh random dedup key and could re-execute workflows. Derive the key from the affected resource's id plus its updatedAt/decidedAt instead. Also surface a clear, actionable message when Ashby's webhook.create rejects a request as a duplicate (seen repeatedly in production logs, where the outbox retried the same failing subscription for ~23 minutes before dead-lettering) instead of the generic API error passthrough. Also add the interview stage `type` field to trigger outputs (present in Ashby's schema, useful for a stage-change trigger) and fix the employmentType description to match Ashby's actual enum values. --- apps/sim/lib/webhooks/providers/ashby.test.ts | 156 ++++++++++++++++++ apps/sim/lib/webhooks/providers/ashby.ts | 37 ++++- apps/sim/triggers/ashby/utils.ts | 14 +- 3 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/ashby.test.ts diff --git a/apps/sim/lib/webhooks/providers/ashby.test.ts b/apps/sim/lib/webhooks/providers/ashby.test.ts new file mode 100644 index 00000000000..98191a93b5c --- /dev/null +++ b/apps/sim/lib/webhooks/providers/ashby.test.ts @@ -0,0 +1,156 @@ +/** + * @vitest-environment node + */ +import crypto from 'crypto' +import { createMockRequest } from '@sim/testing' +import { describe, expect, it } from 'vitest' +import { ashbyHandler } from '@/lib/webhooks/providers/ashby' + +describe('ashbyHandler', () => { + describe('verifyAuth', () => { + const secret = 'test-secret-token' + const rawBody = JSON.stringify({ action: 'ping', data: { webhookActionType: 'ping' } }) + const signature = `sha256=${crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')}` + + it('returns 401 when secretToken is missing', () => { + const request = createMockRequest('POST', JSON.parse(rawBody), { + 'ashby-signature': signature, + }) + const res = ashbyHandler.verifyAuth!({ + request: request as any, + rawBody, + requestId: 'r1', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('returns 401 when signature header is missing', () => { + const request = createMockRequest('POST', JSON.parse(rawBody), {}) + const res = ashbyHandler.verifyAuth!({ + request: request as any, + rawBody, + requestId: 'r1', + providerConfig: { secretToken: secret }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('returns 401 when signature is invalid', () => { + const request = createMockRequest('POST', JSON.parse(rawBody), { + 'ashby-signature': 'sha256=deadbeef', + }) + const res = ashbyHandler.verifyAuth!({ + request: request as any, + rawBody, + requestId: 'r1', + providerConfig: { secretToken: secret }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('returns null when signature is valid', () => { + const request = createMockRequest('POST', JSON.parse(rawBody), { + 'ashby-signature': signature, + }) + const res = ashbyHandler.verifyAuth!({ + request: request as any, + rawBody, + requestId: 'r1', + providerConfig: { secretToken: secret }, + webhook: {}, + workflow: {}, + }) + expect(res).toBeNull() + }) + }) + + describe('matchEvent', () => { + it('rejects ping events', async () => { + const matched = await ashbyHandler.matchEvent!({ + webhook: { id: 'w1' } as any, + body: { action: 'ping', data: { webhookActionType: 'ping' } }, + requestId: 'r1', + providerConfig: { triggerId: 'ashby_application_submit' }, + } as any) + expect(matched).toBe(false) + }) + + it('matches when action equals the configured trigger event', async () => { + const matched = await ashbyHandler.matchEvent!({ + webhook: { id: 'w1' } as any, + body: { action: 'applicationSubmit', data: {} }, + requestId: 'r1', + providerConfig: { triggerId: 'ashby_application_submit' }, + } as any) + expect(matched).toBe(true) + }) + + it('rejects when action does not match the configured trigger event', async () => { + const matched = await ashbyHandler.matchEvent!({ + webhook: { id: 'w1' } as any, + body: { action: 'jobCreate', data: {} }, + requestId: 'r1', + providerConfig: { triggerId: 'ashby_application_submit' }, + } as any) + expect(matched).toBe(false) + }) + }) + + describe('formatInput', () => { + it('spreads data fields to the top level alongside action', async () => { + const result = await ashbyHandler.formatInput!({ + body: { + action: 'applicationSubmit', + data: { application: { id: 'app-1', status: 'Active' } }, + }, + } as any) + expect(result.input).toEqual({ + action: 'applicationSubmit', + application: { id: 'app-1', status: 'Active' }, + }) + }) + }) + + describe('extractIdempotencyId', () => { + it('derives a stable key from application id + updatedAt', () => { + const body = { + action: 'candidateStageChange', + data: { application: { id: 'app-1', updatedAt: '2026-01-01T00:00:00Z' } }, + } + expect(ashbyHandler.extractIdempotencyId!(body)).toBe( + 'ashby:candidateStageChange:app-1:2026-01-01T00:00:00Z' + ) + // identical retried delivery produces the same key + expect(ashbyHandler.extractIdempotencyId!({ ...body })).toBe( + ashbyHandler.extractIdempotencyId!(body) + ) + }) + + it('derives a key from candidate id for candidateDelete', () => { + const body = { action: 'candidateDelete', data: { candidate: { id: 'cand-1' } } } + expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:candidateDelete:cand-1') + }) + + it('derives a key from job id for jobCreate', () => { + const body = { action: 'jobCreate', data: { job: { id: 'job-1' } } } + expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:jobCreate:job-1') + }) + + it('derives a key from offer id + decidedAt for offerCreate', () => { + const body = { action: 'offerCreate', data: { offer: { id: 'offer-1', decidedAt: null } } } + expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:offerCreate:offer-1:') + }) + + it('returns null when no recognizable resource is present', () => { + expect(ashbyHandler.extractIdempotencyId!({ action: 'ping', data: {} })).toBeNull() + expect(ashbyHandler.extractIdempotencyId!({})).toBeNull() + }) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index 77fabe6e024..75fe3f7e6af 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -35,11 +35,35 @@ function validateAshbySignature(secretToken: string, signature: string, body: st } export const ashbyHandler: WebhookProviderHandler = { + /** + * Ashby webhook payloads carry no delivery/event id (confirmed against the + * live OpenAPI spec — every webhookType only has `action` + `data`), so we + * derive a stable key from the affected resource's id plus its + * `updatedAt`/`createdAt` (when present) to distinguish a retried delivery + * of the same event from a genuinely new one. + */ extractIdempotencyId(body: unknown): string | null { const obj = body as Record - const webhookActionId = obj.webhookActionId - if (typeof webhookActionId === 'string' && webhookActionId) { - return `ashby:${webhookActionId}` + const action = typeof obj.action === 'string' ? obj.action : undefined + const data = obj.data as Record | undefined + if (!action || !data) return null + + const application = data.application as Record | undefined + const candidate = data.candidate as Record | undefined + const job = data.job as Record | undefined + const offer = data.offer as Record | undefined + + if (application?.id) { + return `ashby:${action}:${application.id}:${application.updatedAt ?? ''}` + } + if (offer?.id) { + return `ashby:${action}:${offer.id}:${offer.decidedAt ?? ''}` + } + if (candidate?.id) { + return `ashby:${action}:${candidate.id}` + } + if (job?.id) { + return `ashby:${action}:${job.id}` } return null }, @@ -185,6 +209,13 @@ export const ashbyHandler: WebhookProviderHandler = { } else if (ashbyResponse.status === 403) { userFriendlyMessage = 'Access denied. Please ensure your Ashby API Key has the apiKeysWrite permission.' + } else if (/duplicate webhook/i.test(errorMessage)) { + // Ashby has no webhook.list endpoint, so a lost externalId (e.g. a + // prior attempt succeeded in Ashby but we failed to persist the + // result) can't be self-healed by looking the webhook up — the + // user must remove the stale one in Ashby before retrying. + userFriendlyMessage = + 'A webhook for this URL and event already exists in Ashby. This usually happens when a previous save succeeded in Ashby but Sim failed to record it. Delete the duplicate webhook under Ashby Settings > API/Webhooks, then re-save this trigger.' } else if (errorMessage && errorMessage !== 'Unknown Ashby API error') { userFriendlyMessage = `Ashby error: ${errorMessage}` } diff --git a/apps/sim/triggers/ashby/utils.ts b/apps/sim/triggers/ashby/utils.ts index cc26a224cda..c9250ce986e 100644 --- a/apps/sim/triggers/ashby/utils.ts +++ b/apps/sim/triggers/ashby/utils.ts @@ -144,6 +144,10 @@ export function buildApplicationSubmitOutputs(): Record { currentInterviewStage: { id: { type: 'string', description: 'Current interview stage UUID' }, title: { type: 'string', description: 'Current interview stage title' }, + type: { + type: 'string', + description: 'Current interview stage type (e.g., Lead, Applied, Interview, Offer)', + }, }, job: { id: { type: 'string', description: 'Job UUID' }, @@ -181,6 +185,10 @@ export function buildCandidateStageChangeOutputs(): Record { currentInterviewStage: { id: { type: 'string', description: 'Current interview stage UUID' }, title: { type: 'string', description: 'Current interview stage title' }, + type: { + type: 'string', + description: 'Current interview stage type (e.g., Lead, Applied, Interview, Offer)', + }, }, job: { id: { type: 'string', description: 'Job UUID' }, @@ -262,7 +274,7 @@ export function buildJobCreateOutputs(): Record { status: { type: 'string', description: 'Job status (Open, Closed, Draft, Archived)' }, employmentType: { type: 'string', - description: 'Employment type (Full-time, Part-time, etc.)', + description: 'Employment type (FullTime, PartTime, Intern, Contract)', }, }, } as Record From d341860d08b4dddf054f4f7a164b853a485b896a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 14:36:16 -0700 Subject: [PATCH 2/9] fix(ashby): correct offer status enum values in trigger output descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit acceptanceStatus listed a non-existent "WaitingOnResponse" value and offerStatus was missing "WaitingOnApprovalDefinition" — both caught on a second pass re-checking every enum against Ashby's live OpenAPI spec. --- apps/sim/triggers/ashby/utils.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/sim/triggers/ashby/utils.ts b/apps/sim/triggers/ashby/utils.ts index c9250ce986e..6bf6e097430 100644 --- a/apps/sim/triggers/ashby/utils.ts +++ b/apps/sim/triggers/ashby/utils.ts @@ -293,13 +293,12 @@ export function buildOfferCreateOutputs(): Record { applicationId: { type: 'string', description: 'Associated application UUID' }, acceptanceStatus: { type: 'string', - description: - 'Offer acceptance status (Accepted, Declined, Pending, Created, Cancelled, WaitingOnResponse)', + description: 'Offer acceptance status (Accepted, Declined, Pending, Created, Cancelled)', }, offerStatus: { type: 'string', description: - 'Offer process status (WaitingOnApprovalStart, WaitingOnOfferApproval, WaitingOnCandidateResponse, CandidateAccepted, CandidateRejected, OfferCancelled)', + 'Offer process status (WaitingOnApprovalStart, WaitingOnOfferApproval, WaitingOnApprovalDefinition, WaitingOnCandidateResponse, CandidateRejected, CandidateAccepted, OfferCancelled)', }, decidedAt: { type: 'string', From 9b2ac1f2ad195845cd3f701dbb1523c4319c7d6f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 14:40:31 -0700 Subject: [PATCH 3/9] fix(ashby): harden idempotency key against Greptile-flagged collision risks - Return null (skip dedup) when application.updatedAt is absent instead of collapsing to an empty string, which could collide two distinct events sharing an application id onto the same key. - Drop decidedAt from the offerCreate key. It's populated only after the fact, so including it gave a retry of the same delivery a different key than the original attempt once the candidate responded, defeating dedup. offer.id alone is already stable and unique per created offer. --- apps/sim/lib/webhooks/providers/ashby.test.ts | 20 ++++++++++++++++--- apps/sim/lib/webhooks/providers/ashby.ts | 13 ++++++++++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/ashby.test.ts b/apps/sim/lib/webhooks/providers/ashby.test.ts index 98191a93b5c..931c41175d2 100644 --- a/apps/sim/lib/webhooks/providers/ashby.test.ts +++ b/apps/sim/lib/webhooks/providers/ashby.test.ts @@ -143,9 +143,23 @@ describe('ashbyHandler', () => { expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:jobCreate:job-1') }) - it('derives a key from offer id + decidedAt for offerCreate', () => { - const body = { action: 'offerCreate', data: { offer: { id: 'offer-1', decidedAt: null } } } - expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:offerCreate:offer-1:') + it('derives a stable key from offer id alone, ignoring mutable decidedAt', () => { + const created = { action: 'offerCreate', data: { offer: { id: 'offer-1', decidedAt: null } } } + expect(ashbyHandler.extractIdempotencyId!(created)).toBe('ashby:offerCreate:offer-1') + + // a retry delivered after decidedAt was populated must produce the same key + const retriedAfterDecision = { + action: 'offerCreate', + data: { offer: { id: 'offer-1', decidedAt: '2026-01-02T00:00:00Z' } }, + } + expect(ashbyHandler.extractIdempotencyId!(retriedAfterDecision)).toBe( + ashbyHandler.extractIdempotencyId!(created) + ) + }) + + it('returns null when application updatedAt is missing, rather than risk a false collision', () => { + const body = { action: 'candidateStageChange', data: { application: { id: 'app-1' } } } + expect(ashbyHandler.extractIdempotencyId!(body)).toBeNull() }) it('returns null when no recognizable resource is present', () => { diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index 75fe3f7e6af..b1f3ae0ef35 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -54,10 +54,19 @@ export const ashbyHandler: WebhookProviderHandler = { const offer = data.offer as Record | undefined if (application?.id) { - return `ashby:${action}:${application.id}:${application.updatedAt ?? ''}` + // updatedAt is required by Ashby's schema, but if it's ever absent we + // can't tell retries of the same event apart from a genuinely new one + // (e.g. a later stage change) — skip dedup rather than risk collapsing + // two distinct events into the same key. + if (!application.updatedAt) return null + return `ashby:${action}:${application.id}:${application.updatedAt}` } if (offer?.id) { - return `ashby:${action}:${offer.id}:${offer.decidedAt ?? ''}` + // offerCreate fires exactly once per offer, so the id alone is a + // stable key. `decidedAt` is populated only after the fact (candidate + // responds), so including it would give a retry of the same delivery + // a different key than the original attempt and defeat dedup. + return `ashby:${action}:${offer.id}` } if (candidate?.id) { return `ashby:${action}:${candidate.id}` From eab3861ac6565c258febb9a53a864f7e1e983d0e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 14:47:01 -0700 Subject: [PATCH 4/9] fix(ashby): fall back to a content fingerprint instead of skipping dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning null when application.updatedAt was missing avoided false collisions between distinct events, but also disabled retry dedup entirely for that payload shape — an Ashby retry would get a random key from the idempotency service's own fallback and re-run the workflow. Extract the fallback-fingerprint helper (sha256 of a stably-serialized payload) out of salesforce.ts into the shared providers/utils.ts, and use it in ashby.ts: identical retried bytes hash identically (dedup still works), while two genuinely different events hash differently (no false collision). --- apps/sim/lib/webhooks/providers/ashby.test.ts | 20 +++++++++++-- apps/sim/lib/webhooks/providers/ashby.ts | 15 ++++++---- apps/sim/lib/webhooks/providers/salesforce.ts | 22 +------------- apps/sim/lib/webhooks/providers/utils.ts | 30 +++++++++++++++++++ 4 files changed, 57 insertions(+), 30 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/ashby.test.ts b/apps/sim/lib/webhooks/providers/ashby.test.ts index 931c41175d2..bb60a99641f 100644 --- a/apps/sim/lib/webhooks/providers/ashby.test.ts +++ b/apps/sim/lib/webhooks/providers/ashby.test.ts @@ -157,9 +157,23 @@ describe('ashbyHandler', () => { ) }) - it('returns null when application updatedAt is missing, rather than risk a false collision', () => { - const body = { action: 'candidateStageChange', data: { application: { id: 'app-1' } } } - expect(ashbyHandler.extractIdempotencyId!(body)).toBeNull() + it('falls back to a content fingerprint when updatedAt is missing, still deduping retries', () => { + const body = { + action: 'candidateStageChange', + data: { application: { id: 'app-1', status: 'Active' } }, + } + const key = ashbyHandler.extractIdempotencyId!(body) + expect(key).not.toBeNull() + // an identical retry (same bytes) produces the same key + expect(ashbyHandler.extractIdempotencyId!({ ...body, data: { ...body.data } })).toBe(key) + + // a genuinely different event on the same application (different + // content) must NOT collide with the one above + const different = { + action: 'candidateStageChange', + data: { application: { id: 'app-1', status: 'Hired' } }, + } + expect(ashbyHandler.extractIdempotencyId!(different)).not.toBe(key) }) it('returns null when no recognizable resource is present', () => { diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index b1f3ae0ef35..61161df9df8 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -14,6 +14,7 @@ import type { SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +import { buildFallbackDeliveryFingerprint } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Ashby') @@ -54,12 +55,14 @@ export const ashbyHandler: WebhookProviderHandler = { const offer = data.offer as Record | undefined if (application?.id) { - // updatedAt is required by Ashby's schema, but if it's ever absent we - // can't tell retries of the same event apart from a genuinely new one - // (e.g. a later stage change) — skip dedup rather than risk collapsing - // two distinct events into the same key. - if (!application.updatedAt) return null - return `ashby:${action}:${application.id}:${application.updatedAt}` + // updatedAt is required by Ashby's schema and is the cheapest stable + // discriminator between a retry and a genuinely later event (e.g. a + // second stage change). If it's ever absent, fall back to a content + // hash of the full application payload — still stable across retries + // of the same delivery (identical bytes hash identically) without + // risking a false collision between two distinct events. + const discriminator = application.updatedAt ?? buildFallbackDeliveryFingerprint(application) + return `ashby:${action}:${application.id}:${discriminator}` } if (offer?.id) { // offerCreate fires exactly once per offer, so the id alone is a diff --git a/apps/sim/lib/webhooks/providers/salesforce.ts b/apps/sim/lib/webhooks/providers/salesforce.ts index 3aef2eb2d12..8ead8812df0 100644 --- a/apps/sim/lib/webhooks/providers/salesforce.ts +++ b/apps/sim/lib/webhooks/providers/salesforce.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { sha256Hex } from '@sim/security/hash' import { NextResponse } from 'next/server' import type { AuthContext, @@ -8,7 +7,7 @@ import type { FormatInputResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' +import { buildFallbackDeliveryFingerprint, verifyTokenAuth } from '@/lib/webhooks/providers/utils' export function extractSalesforceObjectTypeFromPayload( body: Record @@ -98,25 +97,6 @@ function pickTimestamp(body: Record, record: Record stableSerialize(item)).join(',')}]` - } - - if (value && typeof value === 'object') { - return `{${Object.entries(value as Record) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`) - .join(',')}}` - } - - return JSON.stringify(value) -} - -function buildFallbackDeliveryFingerprint(body: Record): string { - return sha256Hex(stableSerialize(body)) -} - function pickRecordId(body: Record, record: Record): string { const id = (typeof body.recordId === 'string' && body.recordId) || diff --git a/apps/sim/lib/webhooks/providers/utils.ts b/apps/sim/lib/webhooks/providers/utils.ts index 43ff2b45fed..1afe2ce3196 100644 --- a/apps/sim/lib/webhooks/providers/utils.ts +++ b/apps/sim/lib/webhooks/providers/utils.ts @@ -1,11 +1,41 @@ import type { Logger } from '@sim/logger' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { sha256Hex } from '@sim/security/hash' import { NextResponse } from 'next/server' import type { AuthContext, EventFilterContext } from '@/lib/webhooks/providers/types' const logger = createLogger('WebhookProviderAuth') +/** + * Deterministic JSON serialization with object keys sorted, so structurally + * identical payloads produce identical output regardless of key order. + */ +function stableSerialize(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => stableSerialize(item)).join(',')}]` + } + + if (value && typeof value === 'object') { + return `{${Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`) + .join(',')}}` + } + + return JSON.stringify(value) +} + +/** + * Fallback idempotency fingerprint for payloads with no stable delivery id + * or content timestamp to key on. A provider retry resends identical bytes, + * so this hash is stable across retries of the same delivery while still + * differentiating distinct events. + */ +export function buildFallbackDeliveryFingerprint(body: unknown): string { + return sha256Hex(stableSerialize(body)) +} + interface HmacVerifierOptions { configKey: string headerName: string From ab0dcfa30347d904bfa0960b2daf55c8b3399f95 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 14:50:45 -0700 Subject: [PATCH 5/9] chore(ashby): remove inline comments, let names carry the intent --- apps/sim/lib/webhooks/providers/ashby.test.ts | 5 ----- apps/sim/lib/webhooks/providers/ashby.ts | 21 ------------------- 2 files changed, 26 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/ashby.test.ts b/apps/sim/lib/webhooks/providers/ashby.test.ts index bb60a99641f..80920566879 100644 --- a/apps/sim/lib/webhooks/providers/ashby.test.ts +++ b/apps/sim/lib/webhooks/providers/ashby.test.ts @@ -127,7 +127,6 @@ describe('ashbyHandler', () => { expect(ashbyHandler.extractIdempotencyId!(body)).toBe( 'ashby:candidateStageChange:app-1:2026-01-01T00:00:00Z' ) - // identical retried delivery produces the same key expect(ashbyHandler.extractIdempotencyId!({ ...body })).toBe( ashbyHandler.extractIdempotencyId!(body) ) @@ -147,7 +146,6 @@ describe('ashbyHandler', () => { const created = { action: 'offerCreate', data: { offer: { id: 'offer-1', decidedAt: null } } } expect(ashbyHandler.extractIdempotencyId!(created)).toBe('ashby:offerCreate:offer-1') - // a retry delivered after decidedAt was populated must produce the same key const retriedAfterDecision = { action: 'offerCreate', data: { offer: { id: 'offer-1', decidedAt: '2026-01-02T00:00:00Z' } }, @@ -164,11 +162,8 @@ describe('ashbyHandler', () => { } const key = ashbyHandler.extractIdempotencyId!(body) expect(key).not.toBeNull() - // an identical retry (same bytes) produces the same key expect(ashbyHandler.extractIdempotencyId!({ ...body, data: { ...body.data } })).toBe(key) - // a genuinely different event on the same application (different - // content) must NOT collide with the one above const different = { action: 'candidateStageChange', data: { application: { id: 'app-1', status: 'Hired' } }, diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index 61161df9df8..200f61d89af 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -36,13 +36,6 @@ function validateAshbySignature(secretToken: string, signature: string, body: st } export const ashbyHandler: WebhookProviderHandler = { - /** - * Ashby webhook payloads carry no delivery/event id (confirmed against the - * live OpenAPI spec — every webhookType only has `action` + `data`), so we - * derive a stable key from the affected resource's id plus its - * `updatedAt`/`createdAt` (when present) to distinguish a retried delivery - * of the same event from a genuinely new one. - */ extractIdempotencyId(body: unknown): string | null { const obj = body as Record const action = typeof obj.action === 'string' ? obj.action : undefined @@ -55,20 +48,10 @@ export const ashbyHandler: WebhookProviderHandler = { const offer = data.offer as Record | undefined if (application?.id) { - // updatedAt is required by Ashby's schema and is the cheapest stable - // discriminator between a retry and a genuinely later event (e.g. a - // second stage change). If it's ever absent, fall back to a content - // hash of the full application payload — still stable across retries - // of the same delivery (identical bytes hash identically) without - // risking a false collision between two distinct events. const discriminator = application.updatedAt ?? buildFallbackDeliveryFingerprint(application) return `ashby:${action}:${application.id}:${discriminator}` } if (offer?.id) { - // offerCreate fires exactly once per offer, so the id alone is a - // stable key. `decidedAt` is populated only after the fact (candidate - // responds), so including it would give a retry of the same delivery - // a different key than the original attempt and defeat dedup. return `ashby:${action}:${offer.id}` } if (candidate?.id) { @@ -222,10 +205,6 @@ export const ashbyHandler: WebhookProviderHandler = { userFriendlyMessage = 'Access denied. Please ensure your Ashby API Key has the apiKeysWrite permission.' } else if (/duplicate webhook/i.test(errorMessage)) { - // Ashby has no webhook.list endpoint, so a lost externalId (e.g. a - // prior attempt succeeded in Ashby but we failed to persist the - // result) can't be self-healed by looking the webhook up — the - // user must remove the stale one in Ashby before retrying. userFriendlyMessage = 'A webhook for this URL and event already exists in Ashby. This usually happens when a previous save succeeded in Ashby but Sim failed to record it. Delete the duplicate webhook under Ashby Settings > API/Webhooks, then re-save this trigger.' } else if (errorMessage && errorMessage !== 'Unknown Ashby API error') { From 2a1862a0ba94a7cccc3deb647f28ec438942d246 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 14:56:11 -0700 Subject: [PATCH 6/9] fix(ashby): fingerprint the full data payload, not just application Hashing only data.application missed other fields (e.g. offer on candidateHire) when updatedAt is absent, so two deliveries sharing an application snapshot but differing elsewhere in data could collide onto the same idempotency key. --- apps/sim/lib/webhooks/providers/ashby.test.ts | 15 +++++++++++++++ apps/sim/lib/webhooks/providers/ashby.ts | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/webhooks/providers/ashby.test.ts b/apps/sim/lib/webhooks/providers/ashby.test.ts index 80920566879..a29d24b4934 100644 --- a/apps/sim/lib/webhooks/providers/ashby.test.ts +++ b/apps/sim/lib/webhooks/providers/ashby.test.ts @@ -171,6 +171,21 @@ describe('ashbyHandler', () => { expect(ashbyHandler.extractIdempotencyId!(different)).not.toBe(key) }) + it('distinguishes candidateHire deliveries that share an application snapshot but differ in offer', () => { + const application = { id: 'app-1', status: 'Hired' } + const first = { + action: 'candidateHire', + data: { application, offer: { id: 'offer-1' } }, + } + const second = { + action: 'candidateHire', + data: { application, offer: { id: 'offer-2' } }, + } + expect(ashbyHandler.extractIdempotencyId!(first)).not.toBe( + ashbyHandler.extractIdempotencyId!(second) + ) + }) + it('returns null when no recognizable resource is present', () => { expect(ashbyHandler.extractIdempotencyId!({ action: 'ping', data: {} })).toBeNull() expect(ashbyHandler.extractIdempotencyId!({})).toBeNull() diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index 200f61d89af..f2c56e68319 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -48,7 +48,7 @@ export const ashbyHandler: WebhookProviderHandler = { const offer = data.offer as Record | undefined if (application?.id) { - const discriminator = application.updatedAt ?? buildFallbackDeliveryFingerprint(application) + const discriminator = application.updatedAt ?? buildFallbackDeliveryFingerprint(data) return `ashby:${action}:${application.id}:${discriminator}` } if (offer?.id) { From d895b864d85ccd6833c0482e2ab91d73532b02d6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 15:13:35 -0700 Subject: [PATCH 7/9] fix(ashby): rename output field 'type' to 'stageType' to fix build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TriggerOutput reserves the 'type' key for the output's own JSON type (e.g. 'string'), so a nested field literally named 'type' inside currentInterviewStage collided with that meta-field and broke the Record cast — passing local type-check (which turbo was silently serving a stale cached pass for) but failing Next.js's build-time type check in CI. Renamed to stageType, matching the existing eventType-style convention used elsewhere for API fields literally called 'type'. --- apps/sim/triggers/ashby/utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sim/triggers/ashby/utils.ts b/apps/sim/triggers/ashby/utils.ts index 6bf6e097430..54367de590a 100644 --- a/apps/sim/triggers/ashby/utils.ts +++ b/apps/sim/triggers/ashby/utils.ts @@ -144,7 +144,7 @@ export function buildApplicationSubmitOutputs(): Record { currentInterviewStage: { id: { type: 'string', description: 'Current interview stage UUID' }, title: { type: 'string', description: 'Current interview stage title' }, - type: { + stageType: { type: 'string', description: 'Current interview stage type (e.g., Lead, Applied, Interview, Offer)', }, @@ -185,7 +185,7 @@ export function buildCandidateStageChangeOutputs(): Record { currentInterviewStage: { id: { type: 'string', description: 'Current interview stage UUID' }, title: { type: 'string', description: 'Current interview stage title' }, - type: { + stageType: { type: 'string', description: 'Current interview stage type (e.g., Lead, Applied, Interview, Offer)', }, From 3306dc5d6381c54c0ca173f28b8077bef856977b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 15:21:47 -0700 Subject: [PATCH 8/9] fix(ashby): rename currentInterviewStage.type to stageType in delivered data too Renaming the field in the output schema alone (to fix the TriggerOutput build collision) left a mismatch: Ashby's real payload still has currentInterviewStage.type, so a picker/expression using the schema's declared stageType would resolve to undefined at runtime. formatInput now renames the field in the actual delivered payload so it matches what the schema declares. --- apps/sim/lib/webhooks/providers/ashby.test.ts | 18 +++++++++++++++++ apps/sim/lib/webhooks/providers/ashby.ts | 20 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/webhooks/providers/ashby.test.ts b/apps/sim/lib/webhooks/providers/ashby.test.ts index a29d24b4934..1e5a2537e92 100644 --- a/apps/sim/lib/webhooks/providers/ashby.test.ts +++ b/apps/sim/lib/webhooks/providers/ashby.test.ts @@ -116,6 +116,24 @@ describe('ashbyHandler', () => { application: { id: 'app-1', status: 'Active' }, }) }) + + it('renames currentInterviewStage.type to stageType, matching the trigger output schema', async () => { + const result = await ashbyHandler.formatInput!({ + body: { + action: 'candidateStageChange', + data: { + application: { + id: 'app-1', + currentInterviewStage: { id: 'stage-1', title: 'Offer', type: 'Offer' }, + }, + }, + }, + } as any) + expect(result.input.application).toEqual({ + id: 'app-1', + currentInterviewStage: { id: 'stage-1', title: 'Offer', stageType: 'Offer' }, + }) + }) }) describe('extractIdempotencyId', () => { diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index f2c56e68319..24eec3ecd16 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' import { generateId } from '@sim/utils/id' +import { omit } from '@sim/utils/object' import { NextResponse } from 'next/server' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { @@ -65,9 +66,26 @@ export const ashbyHandler: WebhookProviderHandler = { async formatInput({ body }: FormatInputContext): Promise { const b = body as Record + const data = (b.data as Record) || {} + const application = data.application as Record | undefined + const currentInterviewStage = application?.currentInterviewStage as + | Record + | undefined + return { input: { - ...((b.data as Record) || {}), + ...data, + ...(application && currentInterviewStage + ? { + application: { + ...application, + currentInterviewStage: { + ...omit(currentInterviewStage, ['type']), + stageType: currentInterviewStage.type, + }, + }, + } + : {}), action: b.action, }, } From b8667939537d6817c2bd4f21020e08d840a2b650 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 15:28:33 -0700 Subject: [PATCH 9/9] fix(ashby): fold offer id into the idempotency key when present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When application.updatedAt is present, the key ignored sibling data fields — a candidateHire delivery carries both application and offer, so two deliveries sharing an application snapshot could collide even though they concern different offers. Append offer.id to the discriminator when present; it's the only sibling object Ashby's schema ever pairs with application. --- apps/sim/lib/webhooks/providers/ashby.test.ts | 19 +++++++++++++++++++ apps/sim/lib/webhooks/providers/ashby.ts | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/webhooks/providers/ashby.test.ts b/apps/sim/lib/webhooks/providers/ashby.test.ts index 1e5a2537e92..f51eb1f8d9a 100644 --- a/apps/sim/lib/webhooks/providers/ashby.test.ts +++ b/apps/sim/lib/webhooks/providers/ashby.test.ts @@ -204,6 +204,25 @@ describe('ashbyHandler', () => { ) }) + it('distinguishes candidateHire deliveries sharing application id + updatedAt but differing in offer', () => { + const application = { id: 'app-1', status: 'Hired', updatedAt: '2026-01-01T00:00:00Z' } + const first = { + action: 'candidateHire', + data: { application, offer: { id: 'offer-1' } }, + } + const second = { + action: 'candidateHire', + data: { application, offer: { id: 'offer-2' } }, + } + expect(ashbyHandler.extractIdempotencyId!(first)).not.toBe( + ashbyHandler.extractIdempotencyId!(second) + ) + // a genuine retry of `first` (identical offer too) still dedupes + expect(ashbyHandler.extractIdempotencyId!({ ...first })).toBe( + ashbyHandler.extractIdempotencyId!(first) + ) + }) + it('returns null when no recognizable resource is present', () => { expect(ashbyHandler.extractIdempotencyId!({ action: 'ping', data: {} })).toBeNull() expect(ashbyHandler.extractIdempotencyId!({})).toBeNull() diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index 24eec3ecd16..781fef57c66 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -50,7 +50,8 @@ export const ashbyHandler: WebhookProviderHandler = { if (application?.id) { const discriminator = application.updatedAt ?? buildFallbackDeliveryFingerprint(data) - return `ashby:${action}:${application.id}:${discriminator}` + const offerSuffix = offer?.id ? `:${offer.id}` : '' + return `ashby:${action}:${application.id}:${discriminator}${offerSuffix}` } if (offer?.id) { return `ashby:${action}:${offer.id}`