Skip to content

Commit 4cb0f4a

Browse files
authored
feat(ashby): add webhook triggers with automatic lifecycle management (#3548)
* feat(ashby): add webhook triggers with automatic lifecycle management * fix(ashby): address PR review comments - Restore mode: 'advanced' on updateName sub-block - Move action after spread in formatWebhookInput to prevent override - Remove generic webhook trigger (Ashby requires webhookType) * fix(ashby): throw on unknown triggerId, always include webhookType * fix(ashby): address PR review feedback - paramVisibility, stageType, json catch - Add paramVisibility: 'user-only' to apiKey extra field - Remove stageType from candidateStageChange/candidateHire outputs (TriggerOutput type conflict with 'type' field) - Add .catch() fallback to .json() parse in createAshbyWebhookSubscription - Fix candidateStageChange outputs to match actual Ashby application payload structure * fix(ashby): add missing applicationSubmit outputs, fix delete log branches - Add candidate, currentInterviewStage, job to applicationSubmit outputs - Split delete webhook log into ok/404/error branches for accurate logging * fix(ashby): drain response body on delete, clarify decidedAt description - Cancel unconsumed response body in ok/404 delete branches to free connections - Update decidedAt description to note it's typically null at offer creation * fix(ashby): eliminate double-logging, fix hiringTeam JSDoc - Remove pre-throw warn/error logs; catch block is single logging point - Remove hiringTeam from candidateHire JSDoc (TriggerOutput doesn't support arrays)
1 parent fdd587d commit 4cb0f4a

File tree

16 files changed

+741
-2
lines changed

16 files changed

+741
-2
lines changed

apps/docs/content/docs/en/tools/ashby.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ With Ashby, you can:
2222
- **List and view jobs**: Browse all open, closed, and archived job postings with location and department info
2323
- **List applications**: View all applications across your organization with candidate and job details, status tracking, and pagination
2424

25+
The Ashby block also supports **webhook triggers** that automatically start workflows in response to Ashby events. Available triggers include Application Submitted, Candidate Stage Change, Candidate Hired, Candidate Deleted, Job Created, and Offer Created. Webhooks are fully managed — Sim automatically creates the webhook in Ashby when you save the trigger and deletes it when you remove it, so there's no manual webhook configuration needed. Just provide your Ashby API key (with `apiKeysWrite` permission) and select the event type.
26+
2527
In Sim, the Ashby integration enables your agents to programmatically manage your recruiting pipeline. Agents can search for candidates, create new candidate records, add notes after interviews, and monitor applications across jobs. This allows you to automate recruiting workflows like candidate intake, interview follow-ups, pipeline reporting, and cross-referencing candidates across roles.
2628
{/* MANUAL-CONTENT-END */}
2729

apps/docs/content/docs/en/tools/evernote.mdx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
1010
color="#E0E0E0"
1111
/>
1212

13+
{/* MANUAL-CONTENT-START:intro */}
14+
[Evernote](https://evernote.com/) is a note-taking and organization platform that helps individuals and teams capture ideas, manage projects, and store information across devices. With notebooks, tags, and powerful search, Evernote serves as a central hub for knowledge management.
15+
16+
With the Sim Evernote integration, you can:
17+
18+
- **Create and update notes**: Programmatically create new notes with content and tags, or update existing notes in any notebook.
19+
- **Search and retrieve notes**: Use Evernote's search grammar to find notes by keyword, tag, notebook, or other criteria, and retrieve full note content.
20+
- **Organize with notebooks and tags**: Create notebooks and tags, list existing ones, and move or copy notes between notebooks.
21+
- **Delete and manage notes**: Move notes to trash or copy them to different notebooks as part of automated workflows.
22+
23+
**How it works in Sim:**
24+
Add an Evernote block to your workflow and select an operation (e.g., create note, search notes, list notebooks). Provide your Evernote developer token and any required parameters. The block calls the Evernote API and returns structured data you can pass to downstream blocks — for example, searching for meeting notes and sending summaries to Slack, or creating notes from AI-generated content.
25+
{/* MANUAL-CONTENT-END */}
26+
27+
1328
## Usage Instructions
1429

1530
Integrate with Evernote to manage notes, notebooks, and tags. Create, read, update, copy, search, and delete notes. Create and list notebooks and tags.

apps/docs/content/docs/en/tools/fathom.mdx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
1010
color="#181C1E"
1111
/>
1212

13+
{/* MANUAL-CONTENT-START:intro */}
14+
[Fathom](https://fathom.video/) is an AI meeting assistant that automatically records, transcribes, and summarizes your video calls. It works across platforms like Zoom, Google Meet, and Microsoft Teams, generating highlights and action items so your team can stay focused during meetings and catch up quickly afterward.
15+
16+
With the Sim Fathom integration, you can:
17+
18+
- **List and filter meetings**: Retrieve recent meetings recorded by you or shared with your team, with optional filters by date range, recorder, or team.
19+
- **Get meeting summaries**: Pull structured, markdown-formatted summaries for any recorded meeting to quickly review key discussion points.
20+
- **Access full transcripts**: Retrieve complete transcripts with speaker attribution and timestamps for detailed review or downstream processing.
21+
- **Manage teams and members**: List teams in your Fathom organization and view team member details to coordinate meeting workflows.
22+
23+
**How it works in Sim:**
24+
Add a Fathom block to your workflow and select an operation. Provide your Fathom API key and any required parameters (such as a recording ID for summaries and transcripts). The block calls the Fathom API and returns structured data you can pass to downstream blocks — for example, sending a summary to Slack or extracting action items with an AI agent.
25+
{/* MANUAL-CONTENT-END */}
26+
27+
1328
## Usage Instructions
1429

1530
Integrate Fathom AI Notetaker into your workflow. List meetings, get transcripts and summaries, and manage team members and teams. Can also trigger workflows when new meeting content is ready.

apps/docs/content/docs/en/tools/obsidian.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,22 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
1010
color="#0F0F0F"
1111
/>
1212

13+
{/* MANUAL-CONTENT-START:intro */}
14+
[Obsidian](https://obsidian.md/) is a powerful knowledge base and note-taking application that works on top of a local folder of plain-text Markdown files. With features like bidirectional linking, graph views, and a rich plugin ecosystem, Obsidian is widely used for personal knowledge management, research, and documentation.
15+
16+
With the Sim Obsidian integration, you can:
17+
18+
- **Read and create notes**: Retrieve note content from your vault or create new notes programmatically as part of automated workflows.
19+
- **Update and patch notes**: Modify existing notes in full or patch content at specific locations within a note.
20+
- **Search your vault**: Find notes by keyword or content across your entire Obsidian vault.
21+
- **Manage periodic notes**: Access and create daily or other periodic notes for journaling and task tracking.
22+
- **Execute commands**: Trigger Obsidian commands remotely to automate vault operations.
23+
24+
**How it works in Sim:**
25+
Add an Obsidian block to your workflow and select an operation. This integration requires the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) plugin to be installed and running in your vault. Provide your API key and vault URL, along with any required parameters. The block communicates with your local Obsidian instance and returns structured data you can pass to downstream blocks — for example, searching your vault for research notes and feeding them into an AI agent for summarization.
26+
{/* MANUAL-CONTENT-END */}
27+
28+
1329
## Usage Instructions
1430

1531
Read, create, update, search, and delete notes in your Obsidian vault. Manage periodic notes, execute commands, and patch content at specific locations. Requires the Obsidian Local REST API plugin.

apps/sim/blocks/blocks/ashby.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AshbyIcon } from '@/components/icons'
22
import { AuthMode, type BlockConfig } from '@/blocks/types'
3+
import { getTrigger } from '@/triggers'
34

45
export const AshbyBlock: BlockConfig = {
56
type: 'ashby',
@@ -13,6 +14,18 @@ export const AshbyBlock: BlockConfig = {
1314
icon: AshbyIcon,
1415
authMode: AuthMode.ApiKey,
1516

17+
triggers: {
18+
enabled: true,
19+
available: [
20+
'ashby_application_submit',
21+
'ashby_candidate_stage_change',
22+
'ashby_candidate_hire',
23+
'ashby_candidate_delete',
24+
'ashby_job_create',
25+
'ashby_offer_create',
26+
],
27+
},
28+
1629
subBlocks: [
1730
{
1831
id: 'operation',
@@ -366,6 +379,14 @@ Output only the ISO 8601 timestamp string, nothing else.`,
366379
},
367380
mode: 'advanced',
368381
},
382+
383+
// Trigger subBlocks
384+
...getTrigger('ashby_application_submit').subBlocks,
385+
...getTrigger('ashby_candidate_stage_change').subBlocks,
386+
...getTrigger('ashby_candidate_hire').subBlocks,
387+
...getTrigger('ashby_candidate_delete').subBlocks,
388+
...getTrigger('ashby_job_create').subBlocks,
389+
...getTrigger('ashby_offer_create').subBlocks,
369390
],
370391

371392
tools: {

apps/sim/lib/webhooks/provider-subscriptions.ts

Lines changed: 169 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const telegramLogger = createLogger('TelegramWebhook')
1616
const airtableLogger = createLogger('AirtableWebhook')
1717
const typeformLogger = createLogger('TypeformWebhook')
1818
const calendlyLogger = createLogger('CalendlyWebhook')
19+
const ashbyLogger = createLogger('AshbyWebhook')
1920
const grainLogger = createLogger('GrainWebhook')
2021
const fathomLogger = createLogger('FathomWebhook')
2122
const lemlistLogger = createLogger('LemlistWebhook')
@@ -1974,6 +1975,7 @@ type RecreateCheckInput = {
19741975
/** Providers that create external webhook subscriptions */
19751976
const PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS = new Set([
19761977
'airtable',
1978+
'ashby',
19771979
'attio',
19781980
'calendly',
19791981
'fathom',
@@ -2046,7 +2048,13 @@ export async function createExternalWebhookSubscription(
20462048
let updatedProviderConfig = providerConfig
20472049
let externalSubscriptionCreated = false
20482050

2049-
if (provider === 'airtable') {
2051+
if (provider === 'ashby') {
2052+
const result = await createAshbyWebhookSubscription(webhookData, requestId)
2053+
if (result) {
2054+
updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id }
2055+
externalSubscriptionCreated = true
2056+
}
2057+
} else if (provider === 'airtable') {
20502058
const externalId = await createAirtableWebhookSubscription(userId, webhookData, requestId)
20512059
if (externalId) {
20522060
updatedProviderConfig = { ...updatedProviderConfig, externalId }
@@ -2126,7 +2134,9 @@ export async function cleanupExternalWebhook(
21262134
workflow: any,
21272135
requestId: string
21282136
): Promise<void> {
2129-
if (webhook.provider === 'airtable') {
2137+
if (webhook.provider === 'ashby') {
2138+
await deleteAshbyWebhook(webhook, requestId)
2139+
} else if (webhook.provider === 'airtable') {
21302140
await deleteAirtableWebhook(webhook, workflow, requestId)
21312141
} else if (webhook.provider === 'attio') {
21322142
await deleteAttioWebhook(webhook, workflow, requestId)
@@ -2148,3 +2158,160 @@ export async function cleanupExternalWebhook(
21482158
await deleteLemlistWebhook(webhook, requestId)
21492159
}
21502160
}
2161+
2162+
/**
2163+
* Creates a webhook subscription in Ashby via webhook.create API.
2164+
* Ashby uses Basic Auth and one webhook per event type (webhookType).
2165+
*/
2166+
export async function createAshbyWebhookSubscription(
2167+
webhookData: any,
2168+
requestId: string
2169+
): Promise<{ id: string } | undefined> {
2170+
try {
2171+
const { path, providerConfig } = webhookData
2172+
const { apiKey, triggerId } = providerConfig || {}
2173+
2174+
if (!apiKey) {
2175+
throw new Error(
2176+
'Ashby API Key is required. Please provide your API Key with apiKeysWrite permission in the trigger configuration.'
2177+
)
2178+
}
2179+
2180+
if (!triggerId) {
2181+
throw new Error('Trigger ID is required to create Ashby webhook.')
2182+
}
2183+
2184+
const webhookTypeMap: Record<string, string> = {
2185+
ashby_application_submit: 'applicationSubmit',
2186+
ashby_candidate_stage_change: 'candidateStageChange',
2187+
ashby_candidate_hire: 'candidateHire',
2188+
ashby_candidate_delete: 'candidateDelete',
2189+
ashby_job_create: 'jobCreate',
2190+
ashby_offer_create: 'offerCreate',
2191+
}
2192+
2193+
const webhookType = webhookTypeMap[triggerId]
2194+
if (!webhookType) {
2195+
throw new Error(`Unknown Ashby triggerId: ${triggerId}. Add it to webhookTypeMap.`)
2196+
}
2197+
2198+
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
2199+
const authString = Buffer.from(`${apiKey}:`).toString('base64')
2200+
2201+
ashbyLogger.info(`[${requestId}] Creating Ashby webhook`, {
2202+
triggerId,
2203+
webhookType,
2204+
webhookId: webhookData.id,
2205+
})
2206+
2207+
const requestBody: Record<string, unknown> = {
2208+
requestUrl: notificationUrl,
2209+
webhookType,
2210+
}
2211+
2212+
const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.create', {
2213+
method: 'POST',
2214+
headers: {
2215+
Authorization: `Basic ${authString}`,
2216+
'Content-Type': 'application/json',
2217+
},
2218+
body: JSON.stringify(requestBody),
2219+
})
2220+
2221+
const responseBody = await ashbyResponse.json().catch(() => ({}))
2222+
2223+
if (!ashbyResponse.ok || !responseBody.success) {
2224+
const errorMessage =
2225+
responseBody.errorInfo?.message || responseBody.message || 'Unknown Ashby API error'
2226+
2227+
let userFriendlyMessage = 'Failed to create webhook subscription in Ashby'
2228+
if (ashbyResponse.status === 401) {
2229+
userFriendlyMessage =
2230+
'Invalid Ashby API Key. Please verify your API Key is correct and has apiKeysWrite permission.'
2231+
} else if (ashbyResponse.status === 403) {
2232+
userFriendlyMessage =
2233+
'Access denied. Please ensure your Ashby API Key has the apiKeysWrite permission.'
2234+
} else if (errorMessage && errorMessage !== 'Unknown Ashby API error') {
2235+
userFriendlyMessage = `Ashby error: ${errorMessage}`
2236+
}
2237+
2238+
throw new Error(userFriendlyMessage)
2239+
}
2240+
2241+
const externalId = responseBody.results?.id
2242+
if (!externalId) {
2243+
throw new Error('Ashby webhook creation succeeded but no webhook ID was returned')
2244+
}
2245+
2246+
ashbyLogger.info(
2247+
`[${requestId}] Successfully created Ashby webhook subscription ${externalId} for webhook ${webhookData.id}`
2248+
)
2249+
return { id: externalId }
2250+
} catch (error: any) {
2251+
ashbyLogger.error(
2252+
`[${requestId}] Exception during Ashby webhook creation for webhook ${webhookData.id}.`,
2253+
{
2254+
message: error.message,
2255+
stack: error.stack,
2256+
}
2257+
)
2258+
throw error
2259+
}
2260+
}
2261+
2262+
/**
2263+
* Deletes an Ashby webhook subscription via webhook.delete API.
2264+
* Ashby uses POST with webhookId in the body (not DELETE method).
2265+
*/
2266+
export async function deleteAshbyWebhook(webhook: any, requestId: string): Promise<void> {
2267+
try {
2268+
const config = getProviderConfig(webhook)
2269+
const apiKey = config.apiKey as string | undefined
2270+
const externalId = config.externalId as string | undefined
2271+
2272+
if (!apiKey) {
2273+
ashbyLogger.warn(
2274+
`[${requestId}] Missing apiKey for Ashby webhook deletion ${webhook.id}, skipping cleanup`
2275+
)
2276+
return
2277+
}
2278+
2279+
if (!externalId) {
2280+
ashbyLogger.warn(
2281+
`[${requestId}] Missing externalId for Ashby webhook deletion ${webhook.id}, skipping cleanup`
2282+
)
2283+
return
2284+
}
2285+
2286+
const authString = Buffer.from(`${apiKey}:`).toString('base64')
2287+
2288+
const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.delete', {
2289+
method: 'POST',
2290+
headers: {
2291+
Authorization: `Basic ${authString}`,
2292+
'Content-Type': 'application/json',
2293+
},
2294+
body: JSON.stringify({ webhookId: externalId }),
2295+
})
2296+
2297+
if (ashbyResponse.ok) {
2298+
await ashbyResponse.body?.cancel()
2299+
ashbyLogger.info(
2300+
`[${requestId}] Successfully deleted Ashby webhook subscription ${externalId}`
2301+
)
2302+
} else if (ashbyResponse.status === 404) {
2303+
await ashbyResponse.body?.cancel()
2304+
ashbyLogger.info(
2305+
`[${requestId}] Ashby webhook ${externalId} not found during deletion (already removed)`
2306+
)
2307+
} else {
2308+
const responseBody = await ashbyResponse.json().catch(() => ({}))
2309+
ashbyLogger.warn(
2310+
`[${requestId}] Failed to delete Ashby webhook (non-fatal): ${ashbyResponse.status}`,
2311+
{ response: responseBody }
2312+
)
2313+
}
2314+
} catch (error) {
2315+
ashbyLogger.warn(`[${requestId}] Error deleting Ashby webhook (non-fatal)`, error)
2316+
}
2317+
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,6 +1244,14 @@ export async function formatWebhookInput(
12441244
return extractPageData(body)
12451245
}
12461246

1247+
if (foundWebhook.provider === 'ashby') {
1248+
return {
1249+
...(body.data || {}),
1250+
action: body.action,
1251+
data: body.data || {},
1252+
}
1253+
}
1254+
12471255
if (foundWebhook.provider === 'stripe') {
12481256
return body
12491257
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { AshbyIcon } from '@/components/icons'
2+
import { buildTriggerSubBlocks } from '@/triggers'
3+
import {
4+
ashbySetupInstructions,
5+
ashbyTriggerOptions,
6+
buildApplicationSubmitOutputs,
7+
buildAshbyExtraFields,
8+
} from '@/triggers/ashby/utils'
9+
import type { TriggerConfig } from '@/triggers/types'
10+
11+
/**
12+
* Ashby Application Submitted Trigger
13+
*
14+
* This is the PRIMARY trigger - it includes the dropdown for selecting trigger type.
15+
* Fires when a candidate submits an application or is manually added.
16+
*/
17+
export const ashbyApplicationSubmitTrigger: TriggerConfig = {
18+
id: 'ashby_application_submit',
19+
name: 'Ashby Application Submitted',
20+
provider: 'ashby',
21+
description: 'Trigger workflow when a new application is submitted',
22+
version: '1.0.0',
23+
icon: AshbyIcon,
24+
25+
subBlocks: buildTriggerSubBlocks({
26+
triggerId: 'ashby_application_submit',
27+
triggerOptions: ashbyTriggerOptions,
28+
includeDropdown: true,
29+
setupInstructions: ashbySetupInstructions('Application Submitted'),
30+
extraFields: buildAshbyExtraFields('ashby_application_submit'),
31+
}),
32+
33+
outputs: buildApplicationSubmitOutputs(),
34+
35+
webhook: {
36+
method: 'POST',
37+
headers: {
38+
'Content-Type': 'application/json',
39+
},
40+
},
41+
}

0 commit comments

Comments
 (0)