From 7f809e4af696fe1f76c68603a1b846e548216cab Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Fri, 14 Aug 2026 15:01:11 -0700 Subject: [PATCH 1/5] fix(webhooks): read every Ashby error shape when webhook registration fails The provider read errorInfo.message and a top-level message, but not the `errors` array. Ashby uses three shapes in practice, confirmed live: objects `[{ message, parameter }]`, plain strings `['webhook_not_found']`, and `errorInfo`. A missing apiKeysWrite permission arrives in the array form, so the user saw 'Unknown Ashby API error' instead of the cause. The duplicate-webhook branch made it worse: it only fires when the message was extracted, so an unparsed error also cost the user the one actionable instruction for fixing it - delete the duplicate under Settings > API/Webhooks. Uses the shared ashbyErrorMessage extractor rather than a second partial copy, matching how other providers already import from @/tools. The delete path now reports why it failed instead of only the HTTP status. --- apps/sim/lib/webhooks/providers/ashby.test.ts | 62 ++++++++++++++++++- apps/sim/lib/webhooks/providers/ashby.ts | 19 ++++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/ashby.test.ts b/apps/sim/lib/webhooks/providers/ashby.test.ts index f51eb1f8d9a..29bfb6d648e 100644 --- a/apps/sim/lib/webhooks/providers/ashby.test.ts +++ b/apps/sim/lib/webhooks/providers/ashby.test.ts @@ -3,7 +3,7 @@ */ import crypto from 'crypto' import { createMockRequest } from '@sim/testing' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { ashbyHandler } from '@/lib/webhooks/providers/ashby' describe('ashbyHandler', () => { @@ -136,6 +136,66 @@ describe('ashbyHandler', () => { }) }) + describe('createSubscription error reporting', () => { + const realFetch = globalThis.fetch + afterEach(() => { + globalThis.fetch = realFetch + }) + + const ctx = { + requestId: 'req-1', + webhook: { + id: 'wh-1', + path: '/api/webhooks/trigger/abc', + providerConfig: { apiKey: 'k', triggerId: 'ashby_job_create' }, + }, + } as never + + const respondWith = (body: unknown, status = 200) => { + globalThis.fetch = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) + ) as never + } + + it('surfaces the object-shaped errors array Ashby documents', async () => { + // Reading only errorInfo.message misses this form, which is what a + // missing-permission failure arrives in - the user would see + // 'Unknown Ashby API error' instead of the actual cause. + respondWith({ success: false, errors: [{ message: 'missing_endpoint_permission' }] }) + await expect(ashbyHandler.createSubscription?.(ctx)).rejects.toThrow( + /missing_endpoint_permission/ + ) + }) + + it('surfaces the plain-string errors array Ashby also returns', async () => { + respondWith({ success: false, errors: ['webhook_not_found'] }) + await expect(ashbyHandler.createSubscription?.(ctx)).rejects.toThrow(/webhook_not_found/) + }) + + it('still prefers errorInfo.message when Ashby sends both shapes at once', async () => { + respondWith({ + success: false, + errors: ['webhook_not_found'], + errorInfo: { code: 'webhook_not_found', message: 'Webhook not found' }, + }) + await expect(ashbyHandler.createSubscription?.(ctx)).rejects.toThrow(/Webhook not found/) + }) + + it('keeps the actionable duplicate-webhook guidance reachable', async () => { + // The duplicate branch only fires when the message was extracted, so an + // unparsed error costs the user the instructions for fixing it. + respondWith({ success: false, errors: [{ message: 'duplicate webhook for this url' }] }) + await expect(ashbyHandler.createSubscription?.(ctx)).rejects.toThrow( + /Ashby Settings > API\/Webhooks/ + ) + }) + }) + describe('extractIdempotencyId', () => { it('derives a stable key from application id + updatedAt', () => { const body = { diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index 781fef57c66..9a9335894fd 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -16,6 +16,7 @@ import type { WebhookProviderHandler, } from '@/lib/webhooks/providers/types' import { buildFallbackDeliveryFingerprint } from '@/lib/webhooks/providers/utils' +import { ashbyErrorMessage } from '@/tools/ashby/utils' const logger = createLogger('WebhookProvider:Ashby') @@ -212,9 +213,15 @@ export const ashbyHandler: WebhookProviderHandler = { const responseBody = (await ashbyResponse.json().catch(() => ({}))) as Record if (!ashbyResponse.ok || !responseBody.success) { - const errorInfo = responseBody.errorInfo as Record | undefined - const errorMessage = - errorInfo?.message || (responseBody.message as string) || 'Unknown Ashby API error' + // Ashby documents two error shapes and uses both. Reading only + // `errorInfo.message` misses the `errors: [{ message, parameter }]` form, + // which is what a missing-permission failure arrives in - and the + // duplicate-webhook branch below only fires when the message was + // extracted, so losing it costs the user the actionable guidance. + const errorMessage = ashbyErrorMessage( + responseBody, + (responseBody.message as string) || 'Unknown Ashby API error' + ) let userFriendlyMessage = 'Failed to create webhook subscription in Ashby' if (ashbyResponse.status === 401) { @@ -305,7 +312,11 @@ export const ashbyHandler: WebhookProviderHandler = { `[${ctx.requestId}] Failed to delete Ashby webhook (non-fatal): ${ashbyResponse.status}`, { response: responseBody } ) - if (ctx.strict) throw new Error(`Failed to delete Ashby webhook: ${ashbyResponse.status}`) + if (ctx.strict) { + throw new Error( + `Failed to delete Ashby webhook: ${ashbyErrorMessage(responseBody, `HTTP ${ashbyResponse.status}`)}` + ) + } } } catch (error) { logger.warn(`[${ctx.requestId}] Error deleting Ashby webhook (non-fatal)`, error) From 41c5bd831803cf37b96d03e651aac1471ea65bbf Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Fri, 14 Aug 2026 15:44:50 -0700 Subject: [PATCH 2/5] style(webhooks): format the Ashby provider test to biome's apps/sim config CI runs `biome check .` from apps/sim; I had run biome ad hoc from the repo root, which resolves a different config and left this hunk unformatted. --- apps/sim/lib/webhooks/providers/ashby.test.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/ashby.test.ts b/apps/sim/lib/webhooks/providers/ashby.test.ts index 29bfb6d648e..0b0770bd0fe 100644 --- a/apps/sim/lib/webhooks/providers/ashby.test.ts +++ b/apps/sim/lib/webhooks/providers/ashby.test.ts @@ -152,14 +152,12 @@ describe('ashbyHandler', () => { } as never const respondWith = (body: unknown, status = 200) => { - globalThis.fetch = vi - .fn() - .mockResolvedValue( - new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }) - ) as never + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) + ) as never } it('surfaces the object-shaped errors array Ashby documents', async () => { From a37dc4df063bb49e19bf7a2cf3cbdf73678bb0de Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Fri, 14 Aug 2026 16:02:22 -0700 Subject: [PATCH 3/5] fix(webhooks): keep the Ashby error extractor local to the provider Importing the shared extractor from @/tools/ashby/utils failed check:tool-registry-boundary. Two separate reasons, both real: An import edge from lib/webhooks/providers into @/tools/** grows the workspace page graphs that reach the providers, because @/tools/types statically reaches @/lib/oauth, the rate limiter and the executor. And carving the helper into its own file did not help either: the knowledge page graph already sits exactly at the +42 ceiling the audit allows, so one more module anywhere it can reach is one too many. So the logic is duplicated across the subsystem boundary rather than shared across it, with a comment on both sides saying why. Both copies derive from the same three documented Ashby error shapes and are covered independently. --- apps/sim/lib/webhooks/providers/ashby.ts | 46 +++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index 9a9335894fd..f50080a0b98 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -16,7 +16,51 @@ import type { WebhookProviderHandler, } from '@/lib/webhooks/providers/types' import { buildFallbackDeliveryFingerprint } from '@/lib/webhooks/providers/utils' -import { ashbyErrorMessage } from '@/tools/ashby/utils' + +/** + * Kept local rather than imported from `@/tools/ashby/utils`, which has the same + * logic. The webhook providers are reachable from workspace page graphs, and the + * knowledge page graph currently sits exactly at the ceiling + * `check:tool-registry-boundary` allows - so neither an import edge into + * `@/tools/**` nor an extra module in this directory fits. Both copies derive + * from the same three documented Ashby error shapes and are covered + * independently by `tools/ashby/utils.test.ts` and `ashby.test.ts` here. + */ +/** + * Extract a human-readable error message from an Ashby error response. Ashby + * documents two shapes and uses three in practice: + * + * - `errorInfo: { code, message, requestId }` + * - `errors: ['webhook_not_found']` - plain strings + * - `errors: [{ message, parameter }]` - objects, which is the form a 403 for a + * missing module permission arrives in, and which stringifies to + * `[object Object]` unless the message is read explicitly + * + * A single response can carry more than one of these at once. + */ +function ashbyErrorMessage(data: unknown, fallback: string): string { + if (!data || typeof data !== 'object') return fallback + const d = data as Record + const info = d.errorInfo as Record | undefined + if (info && typeof info.message === 'string' && info.message) return info.message + if (Array.isArray(d.errors) && d.errors.length > 0) { + const messages = d.errors + .map((e) => { + if (typeof e === 'string') return e + if (e && typeof e === 'object') { + const entry = e as Record + const message = typeof entry.message === 'string' ? entry.message : '' + const parameter = typeof entry.parameter === 'string' ? entry.parameter : '' + if (message && parameter) return `${message} (${parameter})` + if (message) return message + } + return '' + }) + .filter(Boolean) + if (messages.length > 0) return messages.join('; ') + } + return fallback +} const logger = createLogger('WebhookProvider:Ashby') From aa5ed04502e1f24dae9da71eaf31a02e8d41c808 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 16:55:58 -0700 Subject: [PATCH 4/5] fix(webhooks): fail an Ashby webhook delete that returns success:false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ashby returns what would be a 4XX elsewhere as HTTP 200 with `success: false` — its own docs state this explicitly. `deleteSubscription` branched on `ashbyResponse.ok`, so every rejected delete logged "Successfully deleted Ashby webhook subscription " and never threw in strict mode. Sim then dropped its own row while the subscription stayed live in Ashby, and since there is no `webhook.list` endpoint the orphan cannot be enumerated afterwards. Check `success` the way `createSubscription` already does, and treat `webhook_not_found` as already-removed rather than an error — that is the shape an unknown id comes back in, not a 404. An absent `success` field stays a success here, unlike on create: teardown runs on the undeploy path, and failing closed on an undocumented response shape would wedge cleanup. Also corrects two trigger-surface details against the API reference: the setup text said the webhook is created when you save the trigger (it is created on deploy), and the jobCreate `employmentType` description omitted the documented `Temporary` value. --- apps/sim/lib/webhooks/providers/ashby.test.ts | 50 +++++++++++++++++++ apps/sim/lib/webhooks/providers/ashby.ts | 33 ++++++++---- apps/sim/triggers/ashby/utils.ts | 8 +-- 3 files changed, 77 insertions(+), 14 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/ashby.test.ts b/apps/sim/lib/webhooks/providers/ashby.test.ts index 0b0770bd0fe..1743eabb002 100644 --- a/apps/sim/lib/webhooks/providers/ashby.test.ts +++ b/apps/sim/lib/webhooks/providers/ashby.test.ts @@ -194,6 +194,56 @@ describe('ashbyHandler', () => { }) }) + describe('deleteSubscription', () => { + const realFetch = globalThis.fetch + afterEach(() => { + globalThis.fetch = realFetch + }) + + const ctx = (strict: boolean) => + ({ + requestId: 'req-1', + strict, + webhook: { + id: 'wh-1', + providerConfig: { apiKey: 'k', externalId: 'ext-1' }, + }, + }) as never + + const respondWith = (body: unknown, status = 200) => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) + ) as never + } + + it('treats a 200 carrying success:false as a failed delete', async () => { + // Ashby returns what would be a 4XX as HTTP 200. Branching on + // response.ok alone reported the leak as a successful cleanup. + respondWith({ success: false, errors: [{ message: 'missing_endpoint_permission' }] }) + await expect(ashbyHandler.deleteSubscription?.(ctx(true))).rejects.toThrow( + /missing_endpoint_permission/ + ) + }) + + it('stays non-fatal for a failed delete when not strict', async () => { + respondWith({ success: false, errors: [{ message: 'missing_endpoint_permission' }] }) + await expect(ashbyHandler.deleteSubscription?.(ctx(false))).resolves.toBeUndefined() + }) + + it('treats an already-removed webhook as done even in strict mode', async () => { + respondWith({ success: false, errors: ['webhook_not_found'] }) + await expect(ashbyHandler.deleteSubscription?.(ctx(true))).resolves.toBeUndefined() + }) + + it('accepts a successful delete', async () => { + respondWith({ success: true, results: { webhookId: 'ext-1' } }) + await expect(ashbyHandler.deleteSubscription?.(ctx(true))).resolves.toBeUndefined() + }) + }) + describe('extractIdempotencyId', () => { it('derives a stable key from application id + updatedAt', () => { const body = { diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index f50080a0b98..119ddfc0ce7 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -340,26 +340,39 @@ export const ashbyHandler: WebhookProviderHandler = { body: JSON.stringify({ webhookId: externalId }), }) - if (ashbyResponse.ok) { - await ashbyResponse.body?.cancel() + const responseBody = (await ashbyResponse.json().catch(() => ({}))) as Record + + /** + * Ashby returns what would be a 4XX elsewhere as HTTP 200 with + * `success: false`, so the status alone cannot separate a completed + * delete from a rejected one. Branching on `ashbyResponse.ok` reported + * every rejection as a successful cleanup while Sim dropped its own row + * — and with no `webhook.list` endpoint, an orphan left behind that way + * cannot be enumerated afterwards. + * + * Unlike `createSubscription`, an absent `success` field is treated as + * success rather than failure: teardown runs on the undeploy path, and + * failing closed on an unparseable body would wedge cleanup on a + * response shape Ashby does not document. + */ + const rejected = !ashbyResponse.ok || responseBody.success === false + const errorMessage = ashbyErrorMessage(responseBody, `HTTP ${ashbyResponse.status}`) + + if (!rejected) { logger.info( `[${ctx.requestId}] Successfully deleted Ashby webhook subscription ${externalId}` ) - } else if (ashbyResponse.status === 404) { - await ashbyResponse.body?.cancel() + } else if (ashbyResponse.status === 404 || /webhook_not_found/i.test(errorMessage)) { logger.info( `[${ctx.requestId}] Ashby webhook ${externalId} not found during deletion (already removed)` ) } else { - const responseBody = await ashbyResponse.json().catch(() => ({})) logger.warn( - `[${ctx.requestId}] Failed to delete Ashby webhook (non-fatal): ${ashbyResponse.status}`, - { response: responseBody } + `[${ctx.requestId}] Failed to delete Ashby webhook (non-fatal): ${errorMessage}`, + { status: ashbyResponse.status, response: responseBody } ) if (ctx.strict) { - throw new Error( - `Failed to delete Ashby webhook: ${ashbyErrorMessage(responseBody, `HTTP ${ashbyResponse.status}`)}` - ) + throw new Error(`Failed to delete Ashby webhook: ${errorMessage}`) } } } catch (error) { diff --git a/apps/sim/triggers/ashby/utils.ts b/apps/sim/triggers/ashby/utils.ts index f503f940164..ac50d4bf201 100644 --- a/apps/sim/triggers/ashby/utils.ts +++ b/apps/sim/triggers/ashby/utils.ts @@ -43,9 +43,9 @@ export function isAshbyEventMatch(triggerId: string, action: string): boolean { */ export function ashbySetupInstructions(eventType: string): string { const instructions = [ - 'Enter your Ashby API Key above. You can find your API key in Ashby at Settings > API Keys.', - `The webhook for ${eventType} events will be automatically created in Ashby when you save the trigger.`, - 'The webhook will be automatically deleted if you remove this trigger.', + 'Enter your Ashby API Key above. You can find your API key in Ashby at Settings > API Keys. It needs the apiKeysWrite permission.', + `The webhook for ${eventType} events is created in Ashby when you deploy the workflow, not when you save the trigger.`, + 'The webhook is deleted from Ashby when you remove this trigger and redeploy.', ] return instructions @@ -275,7 +275,7 @@ export function buildJobCreateOutputs(): Record { status: { type: 'string', description: 'Job status (Open, Closed, Draft, Archived)' }, employmentType: { type: 'string', - description: 'Employment type (FullTime, PartTime, Intern, Contract)', + description: 'Employment type (FullTime, PartTime, Intern, Contract, Temporary)', }, }, } as Record From f1a72f4c8d1fa04a68bbe87fdd30d1f2814036ec Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 17:02:36 -0700 Subject: [PATCH 5/5] fix(webhooks): match Ashby's real not-found envelope on repeat delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The already-removed branch tested `/webhook_not_found/` against the extracted message, but `ashbyErrorMessage` returns `errorInfo.message` first and that reads "Webhook not found" — Ashby carries the machine code on `errorInfo.code` and in the deprecated `errors` array, both of which lose to the message. So the one envelope this branch exists for, a repeat delete of an id Ashby has already dropped, fell through to the failure path: a spurious warn today and a strict-mode throw on the undeploy cleanup path. Read the codes directly and keep a prose fallback for the message-only form. Caught by Cursor Bugbot. --- apps/sim/lib/webhooks/providers/ashby.test.ts | 21 ++++++++++++ apps/sim/lib/webhooks/providers/ashby.ts | 34 ++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/webhooks/providers/ashby.test.ts b/apps/sim/lib/webhooks/providers/ashby.test.ts index 1743eabb002..9c6a81aadf0 100644 --- a/apps/sim/lib/webhooks/providers/ashby.test.ts +++ b/apps/sim/lib/webhooks/providers/ashby.test.ts @@ -238,6 +238,27 @@ describe('ashbyHandler', () => { await expect(ashbyHandler.deleteSubscription?.(ctx(true))).resolves.toBeUndefined() }) + it('recognizes the not-found envelope Ashby actually sends for a repeat delete', async () => { + // errorInfo.message wins over the code array in the extractor, so it reads + // 'Webhook not found' - matching that against `webhook_not_found` would + // turn idempotent cleanup into a strict-mode throw. + respondWith({ + success: false, + errors: ['webhook_not_found'], + errorInfo: { + code: 'webhook_not_found', + message: 'Webhook not found', + requestId: '01JSJ8FEK5ZN4XQBZP7DBKK7ZC', + }, + }) + await expect(ashbyHandler.deleteSubscription?.(ctx(true))).resolves.toBeUndefined() + }) + + it('recognizes a not-found reported only as prose', async () => { + respondWith({ success: false, errorInfo: { message: 'Webhook not found' } }) + await expect(ashbyHandler.deleteSubscription?.(ctx(true))).resolves.toBeUndefined() + }) + it('accepts a successful delete', async () => { respondWith({ success: true, results: { webhookId: 'ext-1' } }) await expect(ashbyHandler.deleteSubscription?.(ctx(true))).resolves.toBeUndefined() diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index 119ddfc0ce7..93925a7d6b9 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -62,6 +62,35 @@ function ashbyErrorMessage(data: unknown, fallback: string): string { return fallback } +/** + * Whether an Ashby error response means the webhook id no longer exists. + * + * Ashby signals this as the machine code `webhook_not_found`, carried on + * `errorInfo.code` and/or as an `errors` entry — but the same envelope's + * `errorInfo.message` reads `Webhook not found`, and that is what + * `ashbyErrorMessage` returns, since message wins over the deprecated code + * array. Matching the extracted message against the code therefore misses the + * envelope Ashby actually sends for a repeat delete, and idempotent cleanup + * would be reported as a real failure. Read the codes directly, and keep a + * prose fallback for the message-only form. + */ +function isAshbyWebhookNotFound(data: Record, message: string): boolean { + const info = data.errorInfo as Record | undefined + if (typeof info?.code === 'string' && /webhook_not_found/i.test(info.code)) return true + + if (Array.isArray(data.errors)) { + for (const entry of data.errors) { + if (typeof entry === 'string' && /webhook_not_found/i.test(entry)) return true + if (entry && typeof entry === 'object') { + const entryMessage = (entry as Record).message + if (typeof entryMessage === 'string' && /webhook_not_found/i.test(entryMessage)) return true + } + } + } + + return /webhook[\s_]not[\s_]found/i.test(message) +} + const logger = createLogger('WebhookProvider:Ashby') function validateAshbySignature(secretToken: string, signature: string, body: string): boolean { @@ -362,7 +391,10 @@ export const ashbyHandler: WebhookProviderHandler = { logger.info( `[${ctx.requestId}] Successfully deleted Ashby webhook subscription ${externalId}` ) - } else if (ashbyResponse.status === 404 || /webhook_not_found/i.test(errorMessage)) { + } else if ( + ashbyResponse.status === 404 || + isAshbyWebhookNotFound(responseBody, errorMessage) + ) { logger.info( `[${ctx.requestId}] Ashby webhook ${externalId} not found during deletion (already removed)` )