Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions apps/sim/app/api/webhooks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,56 @@ export async function POST(request: NextRequest) {
}
// --- End RSS specific logic ---

if (savedWebhook && provider === 'grain') {
logger.info(`[${requestId}] Grain provider detected. Creating Grain webhook subscription.`)
try {
const grainHookId = await createGrainWebhookSubscription(
request,
{
id: savedWebhook.id,
path: savedWebhook.path,
providerConfig: savedWebhook.providerConfig,
},
requestId
)

if (grainHookId) {
// Update the webhook record with the external Grain hook ID
const updatedConfig = {
...(savedWebhook.providerConfig as Record<string, any>),
externalId: grainHookId,
}
await db
.update(webhook)
.set({
providerConfig: updatedConfig,
updatedAt: new Date(),
})
.where(eq(webhook.id, savedWebhook.id))

savedWebhook.providerConfig = updatedConfig
logger.info(`[${requestId}] Successfully created Grain webhook`, {
grainHookId,
webhookId: savedWebhook.id,
})
}
} catch (err) {
logger.error(
`[${requestId}] Error creating Grain webhook subscription, rolling back webhook`,
err
)
await db.delete(webhook).where(eq(webhook.id, savedWebhook.id))
return NextResponse.json(
{
error: 'Failed to create webhook in Grain',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
// --- End Grain specific logic ---

const status = targetWebhookId ? 200 : 201
return NextResponse.json({ webhook: savedWebhook }, { status })
} catch (error: any) {
Expand Down Expand Up @@ -947,3 +997,103 @@ async function createWebflowWebhookSubscription(
throw error
}
}

// Helper function to create the webhook subscription in Grain
async function createGrainWebhookSubscription(
request: NextRequest,
webhookData: any,
requestId: string
): Promise<string | undefined> {
try {
const { path, providerConfig } = webhookData
const { apiKey, includeHighlights, includeParticipants, includeAiSummary } =
providerConfig || {}

if (!apiKey) {
logger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error(
'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.'
)
}

const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`

const grainApiUrl = 'https://api.grain.com/_/public-api/v2/hooks/create'

const requestBody: Record<string, any> = {
hook_url: notificationUrl,
}

// Build include object based on configuration
const include: Record<string, boolean> = {}
if (includeHighlights) {
include.highlights = true
}
if (includeParticipants) {
include.participants = true
}
if (includeAiSummary) {
include.ai_summary = true
}
if (Object.keys(include).length > 0) {
requestBody.include = include
}

const grainResponse = await fetch(grainApiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Public-Api-Version': '2025-10-31',
},
body: JSON.stringify(requestBody),
})

const responseBody = await grainResponse.json()

if (!grainResponse.ok || responseBody.error) {
const errorMessage =
responseBody.error?.message ||
responseBody.error ||
responseBody.message ||
'Unknown Grain API error'
logger.error(
`[${requestId}] Failed to create webhook in Grain for webhook ${webhookData.id}. Status: ${grainResponse.status}`,
{ message: errorMessage, response: responseBody }
)

let userFriendlyMessage = 'Failed to create webhook subscription in Grain'
if (grainResponse.status === 401) {
userFriendlyMessage =
'Invalid Grain API Key. Please verify your Personal Access Token is correct.'
} else if (grainResponse.status === 403) {
userFriendlyMessage =
'Access denied. Please ensure your Grain API Key has appropriate permissions.'
} else if (errorMessage && errorMessage !== 'Unknown Grain API error') {
userFriendlyMessage = `Grain error: ${errorMessage}`
}

throw new Error(userFriendlyMessage)
}

logger.info(
`[${requestId}] Successfully created webhook in Grain for webhook ${webhookData.id}.`,
{
grainWebhookId: responseBody.id,
}
)

return responseBody.id
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Grain webhook creation for webhook ${webhookData.id}.`,
{
message: error.message,
stack: error.stack,
}
)
throw error
}
}
54 changes: 53 additions & 1 deletion apps/sim/lib/webhooks/provider-subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const telegramLogger = createLogger('TelegramWebhook')
const airtableLogger = createLogger('AirtableWebhook')
const typeformLogger = createLogger('TypeformWebhook')
const calendlyLogger = createLogger('CalendlyWebhook')
const grainLogger = createLogger('GrainWebhook')

function getProviderConfig(webhook: any): Record<string, any> {
return (webhook.providerConfig as Record<string, any>) || {}
Expand Down Expand Up @@ -661,9 +662,58 @@ export async function deleteCalendlyWebhook(webhook: any, requestId: string): Pr
}
}

/**
* Delete a Grain webhook
* Don't fail webhook deletion if cleanup fails
*/
export async function deleteGrainWebhook(webhook: any, requestId: string): Promise<void> {
try {
const config = getProviderConfig(webhook)
const apiKey = config.apiKey as string | undefined
const externalId = config.externalId as string | undefined

if (!apiKey) {
grainLogger.warn(
`[${requestId}] Missing apiKey for Grain webhook deletion ${webhook.id}, skipping cleanup`
)
return
}

if (!externalId) {
grainLogger.warn(
`[${requestId}] Missing externalId for Grain webhook deletion ${webhook.id}, skipping cleanup`
)
return
}

const grainApiUrl = `https://api.grain.com/_/public-api/v2/hooks/${externalId}`

const grainResponse = await fetch(grainApiUrl, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Public-Api-Version': '2025-10-31',
},
})

if (!grainResponse.ok && grainResponse.status !== 404) {
const responseBody = await grainResponse.json().catch(() => ({}))
grainLogger.warn(
`[${requestId}] Failed to delete Grain webhook (non-fatal): ${grainResponse.status}`,
{ response: responseBody }
)
} else {
grainLogger.info(`[${requestId}] Successfully deleted Grain webhook ${externalId}`)
}
} catch (error) {
grainLogger.warn(`[${requestId}] Error deleting Grain webhook (non-fatal)`, error)
}
}

/**
* Clean up external webhook subscriptions for a webhook
* Handles Airtable, Teams, Telegram, Typeform, and Calendly cleanup
* Handles Airtable, Teams, Telegram, Typeform, Calendly, and Grain cleanup
* Don't fail deletion if cleanup fails
*/
export async function cleanupExternalWebhook(
Expand All @@ -681,5 +731,7 @@ export async function cleanupExternalWebhook(
await deleteTypeformWebhook(webhook, requestId)
} else if (webhook.provider === 'calendly') {
await deleteCalendlyWebhook(webhook, requestId)
} else if (webhook.provider === 'grain') {
await deleteGrainWebhook(webhook, requestId)
}
}
48 changes: 35 additions & 13 deletions apps/sim/triggers/grain/highlight_created.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,49 @@ export const grainHighlightCreatedTrigger: TriggerConfig = {

subBlocks: [
{
id: 'webhookUrlDisplay',
title: 'Webhook URL',
id: 'apiKey',
title: 'API Key',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: 'Webhook URL will be generated',
placeholder: 'Enter your Grain API key (Personal Access Token)',
description: 'Required to create the webhook in Grain.',
password: true,
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_highlight_created',
},
},
{
id: 'webhookSecret',
title: 'Webhook Secret',
type: 'short-input',
placeholder: 'Enter a strong secret',
description: 'Validates that webhook deliveries originate from Grain.',
password: true,
required: false,
id: 'includeHighlights',
title: 'Include Highlights',
type: 'switch',
description: 'Include highlights/clips in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_highlight_created',
},
},
{
id: 'includeParticipants',
title: 'Include Participants',
type: 'switch',
description: 'Include participant list in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_highlight_created',
},
},
{
id: 'includeAiSummary',
title: 'Include AI Summary',
type: 'switch',
description: 'Include AI-generated summary in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
Expand Down
48 changes: 35 additions & 13 deletions apps/sim/triggers/grain/highlight_updated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,49 @@ export const grainHighlightUpdatedTrigger: TriggerConfig = {

subBlocks: [
{
id: 'webhookUrlDisplay',
title: 'Webhook URL',
id: 'apiKey',
title: 'API Key',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: 'Webhook URL will be generated',
placeholder: 'Enter your Grain API key (Personal Access Token)',
description: 'Required to create the webhook in Grain.',
password: true,
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_highlight_updated',
},
},
{
id: 'webhookSecret',
title: 'Webhook Secret',
type: 'short-input',
placeholder: 'Enter a strong secret',
description: 'Validates that webhook deliveries originate from Grain.',
password: true,
required: false,
id: 'includeHighlights',
title: 'Include Highlights',
type: 'switch',
description: 'Include highlights/clips in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_highlight_updated',
},
},
{
id: 'includeParticipants',
title: 'Include Participants',
type: 'switch',
description: 'Include participant list in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_highlight_updated',
},
},
{
id: 'includeAiSummary',
title: 'Include AI Summary',
type: 'switch',
description: 'Include AI-generated summary in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
Expand Down
Loading