Skip to content

Commit 07c026a

Browse files
authored
fix(ashby): repair broken idempotency dedup and clarify duplicate-webhook errors (#5518)
* fix(ashby): repair broken idempotency dedup and clarify duplicate-webhook errors 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. * fix(ashby): correct offer status enum values in trigger output descriptions 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. * 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. * fix(ashby): fall back to a content fingerprint instead of skipping dedup 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). * chore(ashby): remove inline comments, let names carry the intent * 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. * fix(ashby): rename output field 'type' to 'stageType' to fix build 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<string, TriggerOutput> 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'. * 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. * fix(ashby): fold offer id into the idempotency key when present 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.
1 parent 1236474 commit 07c026a

5 files changed

Lines changed: 322 additions & 29 deletions

File tree

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import crypto from 'crypto'
5+
import { createMockRequest } from '@sim/testing'
6+
import { describe, expect, it } from 'vitest'
7+
import { ashbyHandler } from '@/lib/webhooks/providers/ashby'
8+
9+
describe('ashbyHandler', () => {
10+
describe('verifyAuth', () => {
11+
const secret = 'test-secret-token'
12+
const rawBody = JSON.stringify({ action: 'ping', data: { webhookActionType: 'ping' } })
13+
const signature = `sha256=${crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')}`
14+
15+
it('returns 401 when secretToken is missing', () => {
16+
const request = createMockRequest('POST', JSON.parse(rawBody), {
17+
'ashby-signature': signature,
18+
})
19+
const res = ashbyHandler.verifyAuth!({
20+
request: request as any,
21+
rawBody,
22+
requestId: 'r1',
23+
providerConfig: {},
24+
webhook: {},
25+
workflow: {},
26+
})
27+
expect(res?.status).toBe(401)
28+
})
29+
30+
it('returns 401 when signature header is missing', () => {
31+
const request = createMockRequest('POST', JSON.parse(rawBody), {})
32+
const res = ashbyHandler.verifyAuth!({
33+
request: request as any,
34+
rawBody,
35+
requestId: 'r1',
36+
providerConfig: { secretToken: secret },
37+
webhook: {},
38+
workflow: {},
39+
})
40+
expect(res?.status).toBe(401)
41+
})
42+
43+
it('returns 401 when signature is invalid', () => {
44+
const request = createMockRequest('POST', JSON.parse(rawBody), {
45+
'ashby-signature': 'sha256=deadbeef',
46+
})
47+
const res = ashbyHandler.verifyAuth!({
48+
request: request as any,
49+
rawBody,
50+
requestId: 'r1',
51+
providerConfig: { secretToken: secret },
52+
webhook: {},
53+
workflow: {},
54+
})
55+
expect(res?.status).toBe(401)
56+
})
57+
58+
it('returns null when signature is valid', () => {
59+
const request = createMockRequest('POST', JSON.parse(rawBody), {
60+
'ashby-signature': signature,
61+
})
62+
const res = ashbyHandler.verifyAuth!({
63+
request: request as any,
64+
rawBody,
65+
requestId: 'r1',
66+
providerConfig: { secretToken: secret },
67+
webhook: {},
68+
workflow: {},
69+
})
70+
expect(res).toBeNull()
71+
})
72+
})
73+
74+
describe('matchEvent', () => {
75+
it('rejects ping events', async () => {
76+
const matched = await ashbyHandler.matchEvent!({
77+
webhook: { id: 'w1' } as any,
78+
body: { action: 'ping', data: { webhookActionType: 'ping' } },
79+
requestId: 'r1',
80+
providerConfig: { triggerId: 'ashby_application_submit' },
81+
} as any)
82+
expect(matched).toBe(false)
83+
})
84+
85+
it('matches when action equals the configured trigger event', async () => {
86+
const matched = await ashbyHandler.matchEvent!({
87+
webhook: { id: 'w1' } as any,
88+
body: { action: 'applicationSubmit', data: {} },
89+
requestId: 'r1',
90+
providerConfig: { triggerId: 'ashby_application_submit' },
91+
} as any)
92+
expect(matched).toBe(true)
93+
})
94+
95+
it('rejects when action does not match the configured trigger event', async () => {
96+
const matched = await ashbyHandler.matchEvent!({
97+
webhook: { id: 'w1' } as any,
98+
body: { action: 'jobCreate', data: {} },
99+
requestId: 'r1',
100+
providerConfig: { triggerId: 'ashby_application_submit' },
101+
} as any)
102+
expect(matched).toBe(false)
103+
})
104+
})
105+
106+
describe('formatInput', () => {
107+
it('spreads data fields to the top level alongside action', async () => {
108+
const result = await ashbyHandler.formatInput!({
109+
body: {
110+
action: 'applicationSubmit',
111+
data: { application: { id: 'app-1', status: 'Active' } },
112+
},
113+
} as any)
114+
expect(result.input).toEqual({
115+
action: 'applicationSubmit',
116+
application: { id: 'app-1', status: 'Active' },
117+
})
118+
})
119+
120+
it('renames currentInterviewStage.type to stageType, matching the trigger output schema', async () => {
121+
const result = await ashbyHandler.formatInput!({
122+
body: {
123+
action: 'candidateStageChange',
124+
data: {
125+
application: {
126+
id: 'app-1',
127+
currentInterviewStage: { id: 'stage-1', title: 'Offer', type: 'Offer' },
128+
},
129+
},
130+
},
131+
} as any)
132+
expect(result.input.application).toEqual({
133+
id: 'app-1',
134+
currentInterviewStage: { id: 'stage-1', title: 'Offer', stageType: 'Offer' },
135+
})
136+
})
137+
})
138+
139+
describe('extractIdempotencyId', () => {
140+
it('derives a stable key from application id + updatedAt', () => {
141+
const body = {
142+
action: 'candidateStageChange',
143+
data: { application: { id: 'app-1', updatedAt: '2026-01-01T00:00:00Z' } },
144+
}
145+
expect(ashbyHandler.extractIdempotencyId!(body)).toBe(
146+
'ashby:candidateStageChange:app-1:2026-01-01T00:00:00Z'
147+
)
148+
expect(ashbyHandler.extractIdempotencyId!({ ...body })).toBe(
149+
ashbyHandler.extractIdempotencyId!(body)
150+
)
151+
})
152+
153+
it('derives a key from candidate id for candidateDelete', () => {
154+
const body = { action: 'candidateDelete', data: { candidate: { id: 'cand-1' } } }
155+
expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:candidateDelete:cand-1')
156+
})
157+
158+
it('derives a key from job id for jobCreate', () => {
159+
const body = { action: 'jobCreate', data: { job: { id: 'job-1' } } }
160+
expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:jobCreate:job-1')
161+
})
162+
163+
it('derives a stable key from offer id alone, ignoring mutable decidedAt', () => {
164+
const created = { action: 'offerCreate', data: { offer: { id: 'offer-1', decidedAt: null } } }
165+
expect(ashbyHandler.extractIdempotencyId!(created)).toBe('ashby:offerCreate:offer-1')
166+
167+
const retriedAfterDecision = {
168+
action: 'offerCreate',
169+
data: { offer: { id: 'offer-1', decidedAt: '2026-01-02T00:00:00Z' } },
170+
}
171+
expect(ashbyHandler.extractIdempotencyId!(retriedAfterDecision)).toBe(
172+
ashbyHandler.extractIdempotencyId!(created)
173+
)
174+
})
175+
176+
it('falls back to a content fingerprint when updatedAt is missing, still deduping retries', () => {
177+
const body = {
178+
action: 'candidateStageChange',
179+
data: { application: { id: 'app-1', status: 'Active' } },
180+
}
181+
const key = ashbyHandler.extractIdempotencyId!(body)
182+
expect(key).not.toBeNull()
183+
expect(ashbyHandler.extractIdempotencyId!({ ...body, data: { ...body.data } })).toBe(key)
184+
185+
const different = {
186+
action: 'candidateStageChange',
187+
data: { application: { id: 'app-1', status: 'Hired' } },
188+
}
189+
expect(ashbyHandler.extractIdempotencyId!(different)).not.toBe(key)
190+
})
191+
192+
it('distinguishes candidateHire deliveries that share an application snapshot but differ in offer', () => {
193+
const application = { id: 'app-1', status: 'Hired' }
194+
const first = {
195+
action: 'candidateHire',
196+
data: { application, offer: { id: 'offer-1' } },
197+
}
198+
const second = {
199+
action: 'candidateHire',
200+
data: { application, offer: { id: 'offer-2' } },
201+
}
202+
expect(ashbyHandler.extractIdempotencyId!(first)).not.toBe(
203+
ashbyHandler.extractIdempotencyId!(second)
204+
)
205+
})
206+
207+
it('distinguishes candidateHire deliveries sharing application id + updatedAt but differing in offer', () => {
208+
const application = { id: 'app-1', status: 'Hired', updatedAt: '2026-01-01T00:00:00Z' }
209+
const first = {
210+
action: 'candidateHire',
211+
data: { application, offer: { id: 'offer-1' } },
212+
}
213+
const second = {
214+
action: 'candidateHire',
215+
data: { application, offer: { id: 'offer-2' } },
216+
}
217+
expect(ashbyHandler.extractIdempotencyId!(first)).not.toBe(
218+
ashbyHandler.extractIdempotencyId!(second)
219+
)
220+
// a genuine retry of `first` (identical offer too) still dedupes
221+
expect(ashbyHandler.extractIdempotencyId!({ ...first })).toBe(
222+
ashbyHandler.extractIdempotencyId!(first)
223+
)
224+
})
225+
226+
it('returns null when no recognizable resource is present', () => {
227+
expect(ashbyHandler.extractIdempotencyId!({ action: 'ping', data: {} })).toBeNull()
228+
expect(ashbyHandler.extractIdempotencyId!({})).toBeNull()
229+
})
230+
})
231+
})

apps/sim/lib/webhooks/providers/ashby.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { safeCompare } from '@sim/security/compare'
33
import { hmacSha256Hex } from '@sim/security/hmac'
44
import { generateId } from '@sim/utils/id'
5+
import { omit } from '@sim/utils/object'
56
import { NextResponse } from 'next/server'
67
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
78
import type {
@@ -14,6 +15,7 @@ import type {
1415
SubscriptionResult,
1516
WebhookProviderHandler,
1617
} from '@/lib/webhooks/providers/types'
18+
import { buildFallbackDeliveryFingerprint } from '@/lib/webhooks/providers/utils'
1719

1820
const logger = createLogger('WebhookProvider:Ashby')
1921

@@ -37,18 +39,54 @@ function validateAshbySignature(secretToken: string, signature: string, body: st
3739
export const ashbyHandler: WebhookProviderHandler = {
3840
extractIdempotencyId(body: unknown): string | null {
3941
const obj = body as Record<string, unknown>
40-
const webhookActionId = obj.webhookActionId
41-
if (typeof webhookActionId === 'string' && webhookActionId) {
42-
return `ashby:${webhookActionId}`
42+
const action = typeof obj.action === 'string' ? obj.action : undefined
43+
const data = obj.data as Record<string, unknown> | undefined
44+
if (!action || !data) return null
45+
46+
const application = data.application as Record<string, unknown> | undefined
47+
const candidate = data.candidate as Record<string, unknown> | undefined
48+
const job = data.job as Record<string, unknown> | undefined
49+
const offer = data.offer as Record<string, unknown> | undefined
50+
51+
if (application?.id) {
52+
const discriminator = application.updatedAt ?? buildFallbackDeliveryFingerprint(data)
53+
const offerSuffix = offer?.id ? `:${offer.id}` : ''
54+
return `ashby:${action}:${application.id}:${discriminator}${offerSuffix}`
55+
}
56+
if (offer?.id) {
57+
return `ashby:${action}:${offer.id}`
58+
}
59+
if (candidate?.id) {
60+
return `ashby:${action}:${candidate.id}`
61+
}
62+
if (job?.id) {
63+
return `ashby:${action}:${job.id}`
4364
}
4465
return null
4566
},
4667

4768
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
4869
const b = body as Record<string, unknown>
70+
const data = (b.data as Record<string, unknown>) || {}
71+
const application = data.application as Record<string, unknown> | undefined
72+
const currentInterviewStage = application?.currentInterviewStage as
73+
| Record<string, unknown>
74+
| undefined
75+
4976
return {
5077
input: {
51-
...((b.data as Record<string, unknown>) || {}),
78+
...data,
79+
...(application && currentInterviewStage
80+
? {
81+
application: {
82+
...application,
83+
currentInterviewStage: {
84+
...omit(currentInterviewStage, ['type']),
85+
stageType: currentInterviewStage.type,
86+
},
87+
},
88+
}
89+
: {}),
5290
action: b.action,
5391
},
5492
}
@@ -185,6 +223,9 @@ export const ashbyHandler: WebhookProviderHandler = {
185223
} else if (ashbyResponse.status === 403) {
186224
userFriendlyMessage =
187225
'Access denied. Please ensure your Ashby API Key has the apiKeysWrite permission.'
226+
} else if (/duplicate webhook/i.test(errorMessage)) {
227+
userFriendlyMessage =
228+
'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.'
188229
} else if (errorMessage && errorMessage !== 'Unknown Ashby API error') {
189230
userFriendlyMessage = `Ashby error: ${errorMessage}`
190231
}

apps/sim/lib/webhooks/providers/salesforce.ts

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { createLogger } from '@sim/logger'
2-
import { sha256Hex } from '@sim/security/hash'
32
import { NextResponse } from 'next/server'
43
import type {
54
AuthContext,
@@ -8,7 +7,7 @@ import type {
87
FormatInputResult,
98
WebhookProviderHandler,
109
} from '@/lib/webhooks/providers/types'
11-
import { verifyTokenAuth } from '@/lib/webhooks/providers/utils'
10+
import { buildFallbackDeliveryFingerprint, verifyTokenAuth } from '@/lib/webhooks/providers/utils'
1211

1312
export function extractSalesforceObjectTypeFromPayload(
1413
body: Record<string, unknown>
@@ -98,25 +97,6 @@ function pickTimestamp(body: Record<string, unknown>, record: Record<string, unk
9897
return ''
9998
}
10099

101-
function stableSerialize(value: unknown): string {
102-
if (Array.isArray(value)) {
103-
return `[${value.map((item) => stableSerialize(item)).join(',')}]`
104-
}
105-
106-
if (value && typeof value === 'object') {
107-
return `{${Object.entries(value as Record<string, unknown>)
108-
.sort(([a], [b]) => a.localeCompare(b))
109-
.map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`)
110-
.join(',')}}`
111-
}
112-
113-
return JSON.stringify(value)
114-
}
115-
116-
function buildFallbackDeliveryFingerprint(body: Record<string, unknown>): string {
117-
return sha256Hex(stableSerialize(body))
118-
}
119-
120100
function pickRecordId(body: Record<string, unknown>, record: Record<string, unknown>): string {
121101
const id =
122102
(typeof body.recordId === 'string' && body.recordId) ||

apps/sim/lib/webhooks/providers/utils.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,41 @@
11
import type { Logger } from '@sim/logger'
22
import { createLogger } from '@sim/logger'
33
import { safeCompare } from '@sim/security/compare'
4+
import { sha256Hex } from '@sim/security/hash'
45
import { NextResponse } from 'next/server'
56
import type { AuthContext, EventFilterContext } from '@/lib/webhooks/providers/types'
67

78
const logger = createLogger('WebhookProviderAuth')
89

10+
/**
11+
* Deterministic JSON serialization with object keys sorted, so structurally
12+
* identical payloads produce identical output regardless of key order.
13+
*/
14+
function stableSerialize(value: unknown): string {
15+
if (Array.isArray(value)) {
16+
return `[${value.map((item) => stableSerialize(item)).join(',')}]`
17+
}
18+
19+
if (value && typeof value === 'object') {
20+
return `{${Object.entries(value as Record<string, unknown>)
21+
.sort(([a], [b]) => a.localeCompare(b))
22+
.map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`)
23+
.join(',')}}`
24+
}
25+
26+
return JSON.stringify(value)
27+
}
28+
29+
/**
30+
* Fallback idempotency fingerprint for payloads with no stable delivery id
31+
* or content timestamp to key on. A provider retry resends identical bytes,
32+
* so this hash is stable across retries of the same delivery while still
33+
* differentiating distinct events.
34+
*/
35+
export function buildFallbackDeliveryFingerprint(body: unknown): string {
36+
return sha256Hex(stableSerialize(body))
37+
}
38+
939
interface HmacVerifierOptions {
1040
configKey: string
1141
headerName: string

0 commit comments

Comments
 (0)