Skip to content

Commit 3faf8fc

Browse files
committed
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.
1 parent fa82acd commit 3faf8fc

3 files changed

Lines changed: 203 additions & 4 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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+
121+
describe('extractIdempotencyId', () => {
122+
it('derives a stable key from application id + updatedAt', () => {
123+
const body = {
124+
action: 'candidateStageChange',
125+
data: { application: { id: 'app-1', updatedAt: '2026-01-01T00:00:00Z' } },
126+
}
127+
expect(ashbyHandler.extractIdempotencyId!(body)).toBe(
128+
'ashby:candidateStageChange:app-1:2026-01-01T00:00:00Z'
129+
)
130+
// identical retried delivery produces the same key
131+
expect(ashbyHandler.extractIdempotencyId!({ ...body })).toBe(
132+
ashbyHandler.extractIdempotencyId!(body)
133+
)
134+
})
135+
136+
it('derives a key from candidate id for candidateDelete', () => {
137+
const body = { action: 'candidateDelete', data: { candidate: { id: 'cand-1' } } }
138+
expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:candidateDelete:cand-1')
139+
})
140+
141+
it('derives a key from job id for jobCreate', () => {
142+
const body = { action: 'jobCreate', data: { job: { id: 'job-1' } } }
143+
expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:jobCreate:job-1')
144+
})
145+
146+
it('derives a key from offer id + decidedAt for offerCreate', () => {
147+
const body = { action: 'offerCreate', data: { offer: { id: 'offer-1', decidedAt: null } } }
148+
expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:offerCreate:offer-1:')
149+
})
150+
151+
it('returns null when no recognizable resource is present', () => {
152+
expect(ashbyHandler.extractIdempotencyId!({ action: 'ping', data: {} })).toBeNull()
153+
expect(ashbyHandler.extractIdempotencyId!({})).toBeNull()
154+
})
155+
})
156+
})

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

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,35 @@ function validateAshbySignature(secretToken: string, signature: string, body: st
3535
}
3636

3737
export const ashbyHandler: WebhookProviderHandler = {
38+
/**
39+
* Ashby webhook payloads carry no delivery/event id (confirmed against the
40+
* live OpenAPI spec — every webhookType only has `action` + `data`), so we
41+
* derive a stable key from the affected resource's id plus its
42+
* `updatedAt`/`createdAt` (when present) to distinguish a retried delivery
43+
* of the same event from a genuinely new one.
44+
*/
3845
extractIdempotencyId(body: unknown): string | null {
3946
const obj = body as Record<string, unknown>
40-
const webhookActionId = obj.webhookActionId
41-
if (typeof webhookActionId === 'string' && webhookActionId) {
42-
return `ashby:${webhookActionId}`
47+
const action = typeof obj.action === 'string' ? obj.action : undefined
48+
const data = obj.data as Record<string, unknown> | undefined
49+
if (!action || !data) return null
50+
51+
const application = data.application as Record<string, unknown> | undefined
52+
const candidate = data.candidate as Record<string, unknown> | undefined
53+
const job = data.job as Record<string, unknown> | undefined
54+
const offer = data.offer as Record<string, unknown> | undefined
55+
56+
if (application?.id) {
57+
return `ashby:${action}:${application.id}:${application.updatedAt ?? ''}`
58+
}
59+
if (offer?.id) {
60+
return `ashby:${action}:${offer.id}:${offer.decidedAt ?? ''}`
61+
}
62+
if (candidate?.id) {
63+
return `ashby:${action}:${candidate.id}`
64+
}
65+
if (job?.id) {
66+
return `ashby:${action}:${job.id}`
4367
}
4468
return null
4569
},
@@ -185,6 +209,13 @@ export const ashbyHandler: WebhookProviderHandler = {
185209
} else if (ashbyResponse.status === 403) {
186210
userFriendlyMessage =
187211
'Access denied. Please ensure your Ashby API Key has the apiKeysWrite permission.'
212+
} else if (/duplicate webhook/i.test(errorMessage)) {
213+
// Ashby has no webhook.list endpoint, so a lost externalId (e.g. a
214+
// prior attempt succeeded in Ashby but we failed to persist the
215+
// result) can't be self-healed by looking the webhook up — the
216+
// user must remove the stale one in Ashby before retrying.
217+
userFriendlyMessage =
218+
'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.'
188219
} else if (errorMessage && errorMessage !== 'Unknown Ashby API error') {
189220
userFriendlyMessage = `Ashby error: ${errorMessage}`
190221
}

apps/sim/triggers/ashby/utils.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,10 @@ export function buildApplicationSubmitOutputs(): Record<string, TriggerOutput> {
144144
currentInterviewStage: {
145145
id: { type: 'string', description: 'Current interview stage UUID' },
146146
title: { type: 'string', description: 'Current interview stage title' },
147+
type: {
148+
type: 'string',
149+
description: 'Current interview stage type (e.g., Lead, Applied, Interview, Offer)',
150+
},
147151
},
148152
job: {
149153
id: { type: 'string', description: 'Job UUID' },
@@ -181,6 +185,10 @@ export function buildCandidateStageChangeOutputs(): Record<string, TriggerOutput
181185
currentInterviewStage: {
182186
id: { type: 'string', description: 'Current interview stage UUID' },
183187
title: { type: 'string', description: 'Current interview stage title' },
188+
type: {
189+
type: 'string',
190+
description: 'Current interview stage type (e.g., Lead, Applied, Interview, Offer)',
191+
},
184192
},
185193
job: {
186194
id: { type: 'string', description: 'Job UUID' },
@@ -213,6 +221,10 @@ export function buildCandidateHireOutputs(): Record<string, TriggerOutput> {
213221
currentInterviewStage: {
214222
id: { type: 'string', description: 'Current interview stage UUID' },
215223
title: { type: 'string', description: 'Current interview stage title' },
224+
type: {
225+
type: 'string',
226+
description: 'Current interview stage type (e.g., Lead, Applied, Interview, Offer)',
227+
},
216228
},
217229
job: {
218230
id: { type: 'string', description: 'Job UUID' },
@@ -262,7 +274,7 @@ export function buildJobCreateOutputs(): Record<string, TriggerOutput> {
262274
status: { type: 'string', description: 'Job status (Open, Closed, Draft, Archived)' },
263275
employmentType: {
264276
type: 'string',
265-
description: 'Employment type (Full-time, Part-time, etc.)',
277+
description: 'Employment type (FullTime, PartTime, Intern, Contract)',
266278
},
267279
},
268280
} as Record<string, TriggerOutput>

0 commit comments

Comments
 (0)