-
-
- {data.icon && (
-
- {data.icon}
-
- )}
-
- {data.variant === 'error' && (
-
- )}
- {data.message}
-
-
- {showCountdown && (
-
- )}
-
-
-
- {data.action && (
-
+
+
{t.message}
+ {t.description && (
+
{t.description}
)}
+ {t.action && (
+
+ )}
+
)
-})
+}
/**
* Toast container that renders toasts via portal.
- * Mount once where you want toasts to appear. Renders stacked cards in the bottom-right.
+ * Mount once in your root layout.
*
- * Visual design matches the workflow notification component: 240px cards, stacked with
- * offset, countdown ring on auto-dismissing items, enter/exit animations.
+ * @example
+ * ```tsx
+ *
+ * ```
*/
export function ToastProvider({ children }: { children?: ReactNode }) {
const [toasts, setToasts] = useState
([])
const [mounted, setMounted] = useState(false)
- const [isPaused, setIsPaused] = useState(false)
- const [exitingIds, setExitingIds] = useState>(new Set())
- const timersRef = useRef(new Map>())
useEffect(() => {
setMounted(true)
@@ -250,87 +175,17 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
const data: ToastData = {
id,
message: input.message,
+ description: input.description,
variant: input.variant ?? 'default',
- icon: input.icon,
action: input.action,
duration: input.duration ?? AUTO_DISMISS_MS,
- createdAt: Date.now(),
}
- setToasts((prev) => [data, ...prev].slice(0, MAX_VISIBLE))
+ setToasts((prev) => [...prev, data].slice(-MAX_VISIBLE))
return id
}, [])
const dismissToast = useCallback((id: string) => {
- setExitingIds((prev) => new Set(prev).add(id))
- setTimeout(() => {
- setToasts((prev) => prev.filter((t) => t.id !== id))
- setExitingIds((prev) => {
- const next = new Set(prev)
- next.delete(id)
- return next
- })
- }, EXIT_ANIMATION_MS)
- }, [])
-
- const dismissAll = useCallback(() => {
- setToasts([])
- setExitingIds(new Set())
- for (const timer of timersRef.current.values()) clearTimeout(timer)
- timersRef.current.clear()
- }, [])
-
- const pauseAll = useCallback(() => {
- setIsPaused(true)
- setExitingIds(new Set())
- for (const timer of timersRef.current.values()) clearTimeout(timer)
- timersRef.current.clear()
- }, [])
-
- const handleAction = useCallback(
- (id: string) => {
- const t = toasts.find((toast) => toast.id === id)
- if (t?.action) {
- t.action.onClick()
- dismissToast(id)
- }
- },
- [toasts, dismissToast]
- )
-
- useEffect(() => {
- if (toasts.length === 0) {
- if (isPaused) setIsPaused(false)
- return
- }
- if (isPaused) return
-
- const timers = timersRef.current
-
- for (const t of toasts) {
- if (t.duration <= 0 || timers.has(t.id)) continue
-
- timers.set(
- t.id,
- setTimeout(() => {
- timers.delete(t.id)
- dismissToast(t.id)
- }, t.duration)
- )
- }
-
- for (const [id, timer] of timers) {
- if (!toasts.some((t) => t.id === id)) {
- clearTimeout(timer)
- timers.delete(id)
- }
- }
- }, [toasts, isPaused, dismissToast])
-
- useEffect(() => {
- const timers = timersRef.current
- return () => {
- for (const timer of timers.values()) clearTimeout(timer)
- }
+ setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const toastFn = useRef(createToastFn(addToast))
@@ -344,44 +199,24 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
}, [addToast])
const ctx = useMemo(
- () => ({ toast: toastFn.current, dismiss: dismissToast, dismissAll }),
- [dismissToast, dismissAll]
+ () => ({ toast: toastFn.current, dismiss: dismissToast }),
+ [dismissToast]
)
- const visibleToasts = toasts.slice(0, MAX_VISIBLE)
-
return (
{children}
{mounted &&
- visibleToasts.length > 0 &&
createPortal(
- <>
-
-
- {[...visibleToasts].reverse().map((t, index, stacked) => {
- const depth = stacked.length - index - 1
- const showCountdown = !isPaused && t.duration > 0
-
- return (
-
- )
- })}
-
- >,
+
+ {toasts.map((t) => (
+
+ ))}
+
,
document.body
)}
diff --git a/apps/sim/lib/webhooks/pending-verification.ts b/apps/sim/lib/webhooks/pending-verification.ts
index 5f851e4104..4d77d35bd2 100644
--- a/apps/sim/lib/webhooks/pending-verification.ts
+++ b/apps/sim/lib/webhooks/pending-verification.ts
@@ -44,6 +44,7 @@ const pendingWebhookVerificationRegistrationMatchers: Record<
string,
PendingWebhookVerificationRegistrationMatcher
> = {
+ ashby: () => true,
grain: () => true,
generic: (registration) => registration.metadata?.verifyTestEvents === true,
}
@@ -52,6 +53,7 @@ const pendingWebhookVerificationProbeMatchers: Record<
string,
PendingWebhookVerificationProbeMatcher
> = {
+ ashby: ({ method, body }) => method === 'POST' && body?.action === 'ping',
grain: ({ method, body }) =>
method === 'GET' ||
method === 'HEAD' ||
diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts
index 0aeaef6e3d..4860402669 100644
--- a/apps/sim/lib/webhooks/processor.ts
+++ b/apps/sim/lib/webhooks/processor.ts
@@ -20,6 +20,7 @@ import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils'
import {
handleSlackChallenge,
handleWhatsAppVerification,
+ validateAshbySignature,
validateAttioSignature,
validateCalcomSignature,
validateCirclebackSignature,
@@ -555,6 +556,29 @@ export async function verifyProviderAuth(
}
}
+ // Ashby webhook signature verification (HMAC-SHA256 via Ashby-Signature header)
+ if (foundWebhook.provider === 'ashby') {
+ const secretToken = providerConfig.secretToken as string | undefined
+
+ if (secretToken) {
+ const signature = request.headers.get('ashby-signature')
+
+ if (!signature) {
+ logger.warn(`[${requestId}] Ashby webhook missing Ashby-Signature header`)
+ return new NextResponse('Unauthorized - Missing Ashby signature', {
+ status: 401,
+ })
+ }
+
+ if (!validateAshbySignature(secretToken, signature, rawBody)) {
+ logger.warn(`[${requestId}] Ashby webhook signature verification failed`)
+ return new NextResponse('Unauthorized - Invalid Ashby signature', {
+ status: 401,
+ })
+ }
+ }
+ }
+
// Provider-specific verification (utils may return a response for some providers)
const providerVerification = verifyProviderWebhook(foundWebhook, request, requestId)
if (providerVerification) {
diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts
index 4efbeb35e3..e34f453874 100644
--- a/apps/sim/lib/webhooks/provider-subscriptions.ts
+++ b/apps/sim/lib/webhooks/provider-subscriptions.ts
@@ -2060,7 +2060,11 @@ export async function createExternalWebhookSubscription(
if (provider === 'ashby') {
const result = await createAshbyWebhookSubscription(webhookData, requestId)
if (result) {
- updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id }
+ updatedProviderConfig = {
+ ...updatedProviderConfig,
+ externalId: result.id,
+ secretToken: result.secretToken,
+ }
externalSubscriptionCreated = true
}
} else if (provider === 'airtable') {
@@ -2175,7 +2179,7 @@ export async function cleanupExternalWebhook(
export async function createAshbyWebhookSubscription(
webhookData: any,
requestId: string
-): Promise<{ id: string } | undefined> {
+): Promise<{ id: string; secretToken: string } | undefined> {
try {
const { path, providerConfig } = webhookData
const { apiKey, triggerId } = providerConfig || {}
@@ -2213,9 +2217,12 @@ export async function createAshbyWebhookSubscription(
webhookId: webhookData.id,
})
+ const secretToken = crypto.randomUUID()
+
const requestBody: Record = {
requestUrl: notificationUrl,
webhookType,
+ secretToken,
}
const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.create', {
@@ -2255,7 +2262,7 @@ export async function createAshbyWebhookSubscription(
ashbyLogger.info(
`[${requestId}] Successfully created Ashby webhook subscription ${externalId} for webhook ${webhookData.id}`
)
- return { id: externalId }
+ return { id: externalId, secretToken }
} catch (error: any) {
ashbyLogger.error(
`[${requestId}] Exception during Ashby webhook creation for webhook ${webhookData.id}.`,
diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts
index d202a1b026..9f81f923c0 100644
--- a/apps/sim/lib/webhooks/utils.server.ts
+++ b/apps/sim/lib/webhooks/utils.server.ts
@@ -1614,6 +1614,38 @@ export function validateFirefliesSignature(
}
}
+/**
+ * Validates an Ashby webhook signature using HMAC-SHA256.
+ * Ashby signs payloads with the secretToken and sends the digest in the Ashby-Signature header.
+ * @param secretToken - The secret token configured when creating the webhook
+ * @param signature - Ashby-Signature header value (format: 'sha256=')
+ * @param body - Raw request body string
+ * @returns Whether the signature is valid
+ */
+export function validateAshbySignature(
+ secretToken: string,
+ signature: string,
+ body: string
+): boolean {
+ try {
+ if (!secretToken || !signature || !body) {
+ return false
+ }
+
+ if (!signature.startsWith('sha256=')) {
+ return false
+ }
+
+ const providedSignature = signature.substring(7)
+ const computedHash = crypto.createHmac('sha256', secretToken).update(body, 'utf8').digest('hex')
+
+ return safeCompare(computedHash, providedSignature)
+ } catch (error) {
+ logger.error('Error validating Ashby signature:', error)
+ return false
+ }
+}
+
/**
* Validates a GitHub webhook request signature using HMAC SHA-256 or SHA-1
* @param secret - GitHub webhook secret (plain text)
diff --git a/apps/sim/triggers/ashby/application_submit.ts b/apps/sim/triggers/ashby/application_submit.ts
index 1c5500cbd3..e000536a88 100644
--- a/apps/sim/triggers/ashby/application_submit.ts
+++ b/apps/sim/triggers/ashby/application_submit.ts
@@ -1,11 +1,5 @@
import { AshbyIcon } from '@/components/icons'
-import { buildTriggerSubBlocks } from '@/triggers'
-import {
- ashbySetupInstructions,
- ashbyTriggerOptions,
- buildApplicationSubmitOutputs,
- buildAshbyExtraFields,
-} from '@/triggers/ashby/utils'
+import { buildApplicationSubmitOutputs, buildAshbySubBlocks } from '@/triggers/ashby/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
@@ -22,12 +16,10 @@ export const ashbyApplicationSubmitTrigger: TriggerConfig = {
version: '1.0.0',
icon: AshbyIcon,
- subBlocks: buildTriggerSubBlocks({
+ subBlocks: buildAshbySubBlocks({
triggerId: 'ashby_application_submit',
- triggerOptions: ashbyTriggerOptions,
+ eventType: 'Application Submitted',
includeDropdown: true,
- setupInstructions: ashbySetupInstructions('Application Submitted'),
- extraFields: buildAshbyExtraFields('ashby_application_submit'),
}),
outputs: buildApplicationSubmitOutputs(),
diff --git a/apps/sim/triggers/ashby/candidate_delete.ts b/apps/sim/triggers/ashby/candidate_delete.ts
index e70d26971b..39d33966fe 100644
--- a/apps/sim/triggers/ashby/candidate_delete.ts
+++ b/apps/sim/triggers/ashby/candidate_delete.ts
@@ -1,11 +1,5 @@
import { AshbyIcon } from '@/components/icons'
-import { buildTriggerSubBlocks } from '@/triggers'
-import {
- ashbySetupInstructions,
- ashbyTriggerOptions,
- buildAshbyExtraFields,
- buildCandidateDeleteOutputs,
-} from '@/triggers/ashby/utils'
+import { buildAshbySubBlocks, buildCandidateDeleteOutputs } from '@/triggers/ashby/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
@@ -21,11 +15,9 @@ export const ashbyCandidateDeleteTrigger: TriggerConfig = {
version: '1.0.0',
icon: AshbyIcon,
- subBlocks: buildTriggerSubBlocks({
+ subBlocks: buildAshbySubBlocks({
triggerId: 'ashby_candidate_delete',
- triggerOptions: ashbyTriggerOptions,
- setupInstructions: ashbySetupInstructions('Candidate Deleted'),
- extraFields: buildAshbyExtraFields('ashby_candidate_delete'),
+ eventType: 'Candidate Deleted',
}),
outputs: buildCandidateDeleteOutputs(),
diff --git a/apps/sim/triggers/ashby/candidate_hire.ts b/apps/sim/triggers/ashby/candidate_hire.ts
index 529b15e7f2..3b6a2becc0 100644
--- a/apps/sim/triggers/ashby/candidate_hire.ts
+++ b/apps/sim/triggers/ashby/candidate_hire.ts
@@ -1,11 +1,5 @@
import { AshbyIcon } from '@/components/icons'
-import { buildTriggerSubBlocks } from '@/triggers'
-import {
- ashbySetupInstructions,
- ashbyTriggerOptions,
- buildAshbyExtraFields,
- buildCandidateHireOutputs,
-} from '@/triggers/ashby/utils'
+import { buildAshbySubBlocks, buildCandidateHireOutputs } from '@/triggers/ashby/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
@@ -22,11 +16,9 @@ export const ashbyCandidateHireTrigger: TriggerConfig = {
version: '1.0.0',
icon: AshbyIcon,
- subBlocks: buildTriggerSubBlocks({
+ subBlocks: buildAshbySubBlocks({
triggerId: 'ashby_candidate_hire',
- triggerOptions: ashbyTriggerOptions,
- setupInstructions: ashbySetupInstructions('Candidate Hired'),
- extraFields: buildAshbyExtraFields('ashby_candidate_hire'),
+ eventType: 'Candidate Hired',
}),
outputs: buildCandidateHireOutputs(),
diff --git a/apps/sim/triggers/ashby/candidate_stage_change.ts b/apps/sim/triggers/ashby/candidate_stage_change.ts
index a1a43a6302..38375b2946 100644
--- a/apps/sim/triggers/ashby/candidate_stage_change.ts
+++ b/apps/sim/triggers/ashby/candidate_stage_change.ts
@@ -1,11 +1,5 @@
import { AshbyIcon } from '@/components/icons'
-import { buildTriggerSubBlocks } from '@/triggers'
-import {
- ashbySetupInstructions,
- ashbyTriggerOptions,
- buildAshbyExtraFields,
- buildCandidateStageChangeOutputs,
-} from '@/triggers/ashby/utils'
+import { buildAshbySubBlocks, buildCandidateStageChangeOutputs } from '@/triggers/ashby/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
@@ -22,11 +16,9 @@ export const ashbyCandidateStageChangeTrigger: TriggerConfig = {
version: '1.0.0',
icon: AshbyIcon,
- subBlocks: buildTriggerSubBlocks({
+ subBlocks: buildAshbySubBlocks({
triggerId: 'ashby_candidate_stage_change',
- triggerOptions: ashbyTriggerOptions,
- setupInstructions: ashbySetupInstructions('Candidate Stage Change'),
- extraFields: buildAshbyExtraFields('ashby_candidate_stage_change'),
+ eventType: 'Candidate Stage Change',
}),
outputs: buildCandidateStageChangeOutputs(),
diff --git a/apps/sim/triggers/ashby/job_create.ts b/apps/sim/triggers/ashby/job_create.ts
index 88d60e13c2..05fcf1d1fd 100644
--- a/apps/sim/triggers/ashby/job_create.ts
+++ b/apps/sim/triggers/ashby/job_create.ts
@@ -1,11 +1,5 @@
import { AshbyIcon } from '@/components/icons'
-import { buildTriggerSubBlocks } from '@/triggers'
-import {
- ashbySetupInstructions,
- ashbyTriggerOptions,
- buildAshbyExtraFields,
- buildJobCreateOutputs,
-} from '@/triggers/ashby/utils'
+import { buildAshbySubBlocks, buildJobCreateOutputs } from '@/triggers/ashby/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
@@ -21,11 +15,9 @@ export const ashbyJobCreateTrigger: TriggerConfig = {
version: '1.0.0',
icon: AshbyIcon,
- subBlocks: buildTriggerSubBlocks({
+ subBlocks: buildAshbySubBlocks({
triggerId: 'ashby_job_create',
- triggerOptions: ashbyTriggerOptions,
- setupInstructions: ashbySetupInstructions('Job Created'),
- extraFields: buildAshbyExtraFields('ashby_job_create'),
+ eventType: 'Job Created',
}),
outputs: buildJobCreateOutputs(),
diff --git a/apps/sim/triggers/ashby/offer_create.ts b/apps/sim/triggers/ashby/offer_create.ts
index 3b952b65b7..eef678d41a 100644
--- a/apps/sim/triggers/ashby/offer_create.ts
+++ b/apps/sim/triggers/ashby/offer_create.ts
@@ -1,11 +1,5 @@
import { AshbyIcon } from '@/components/icons'
-import { buildTriggerSubBlocks } from '@/triggers'
-import {
- ashbySetupInstructions,
- ashbyTriggerOptions,
- buildAshbyExtraFields,
- buildOfferCreateOutputs,
-} from '@/triggers/ashby/utils'
+import { buildAshbySubBlocks, buildOfferCreateOutputs } from '@/triggers/ashby/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
@@ -21,11 +15,9 @@ export const ashbyOfferCreateTrigger: TriggerConfig = {
version: '1.0.0',
icon: AshbyIcon,
- subBlocks: buildTriggerSubBlocks({
+ subBlocks: buildAshbySubBlocks({
triggerId: 'ashby_offer_create',
- triggerOptions: ashbyTriggerOptions,
- setupInstructions: ashbySetupInstructions('Offer Created'),
- extraFields: buildAshbyExtraFields('ashby_offer_create'),
+ eventType: 'Offer Created',
}),
outputs: buildOfferCreateOutputs(),
diff --git a/apps/sim/triggers/ashby/utils.ts b/apps/sim/triggers/ashby/utils.ts
index ff25fcd5f8..d30fa597df 100644
--- a/apps/sim/triggers/ashby/utils.ts
+++ b/apps/sim/triggers/ashby/utils.ts
@@ -19,10 +19,9 @@ export const ashbyTriggerOptions = [
*/
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 key must have the apiKeysWrite permission.',
- `Click "Save Configuration" to automatically create the webhook in Ashby for ${eventType} events.`,
- 'The webhook will be automatically deleted when you remove this trigger.',
+ '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.',
]
return instructions
@@ -34,24 +33,54 @@ export function ashbySetupInstructions(eventType: string): string {
}
/**
- * Ashby-specific extra fields for triggers.
- * Includes API key (required for automatic webhook creation).
+ * Builds the complete subBlocks array for an Ashby trigger.
+ * Ashby webhooks are managed via API, so no webhook URL is displayed.
+ *
+ * Structure: [dropdown?] -> apiKey -> instructions
*/
-export function buildAshbyExtraFields(triggerId: string): SubBlockConfig[] {
- return [
- {
- id: 'apiKey',
- title: 'API Key',
- type: 'short-input',
- placeholder: 'Enter your Ashby API key',
- description: 'Required to create the webhook in Ashby. Must have apiKeysWrite permission.',
- password: true,
- required: true,
- paramVisibility: 'user-only',
+export function buildAshbySubBlocks(options: {
+ triggerId: string
+ eventType: string
+ includeDropdown?: boolean
+}): SubBlockConfig[] {
+ const { triggerId, eventType, includeDropdown = false } = options
+ const blocks: SubBlockConfig[] = []
+
+ if (includeDropdown) {
+ blocks.push({
+ id: 'selectedTriggerId',
+ title: 'Trigger Type',
+ type: 'dropdown',
mode: 'trigger',
- condition: { field: 'selectedTriggerId', value: triggerId },
- },
- ]
+ options: ashbyTriggerOptions,
+ value: () => triggerId,
+ required: true,
+ })
+ }
+
+ blocks.push({
+ id: 'apiKey',
+ title: 'API Key',
+ type: 'short-input',
+ placeholder: 'Enter your Ashby API key',
+ password: true,
+ required: true,
+ paramVisibility: 'user-only',
+ mode: 'trigger',
+ condition: { field: 'selectedTriggerId', value: triggerId },
+ })
+
+ blocks.push({
+ id: 'triggerInstructions',
+ title: 'Setup Instructions',
+ hideFromPreview: true,
+ type: 'text',
+ defaultValue: ashbySetupInstructions(eventType),
+ mode: 'trigger',
+ condition: { field: 'selectedTriggerId', value: triggerId },
+ })
+
+ return blocks
}
/**