diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 4c2d2735f..62ad40db0 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -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), + 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) { @@ -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 { + 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 = { + hook_url: notificationUrl, + } + + // Build include object based on configuration + const include: Record = {} + 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 + } +} diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index 2772b9c8b..7b4566935 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -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 { return (webhook.providerConfig as Record) || {} @@ -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 { + 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( @@ -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) } } diff --git a/apps/sim/triggers/grain/highlight_created.ts b/apps/sim/triggers/grain/highlight_created.ts index b59338ac7..12ab02d26 100644 --- a/apps/sim/triggers/grain/highlight_created.ts +++ b/apps/sim/triggers/grain/highlight_created.ts @@ -12,13 +12,13 @@ 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', @@ -26,13 +26,35 @@ export const grainHighlightCreatedTrigger: TriggerConfig = { }, }, { - 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', diff --git a/apps/sim/triggers/grain/highlight_updated.ts b/apps/sim/triggers/grain/highlight_updated.ts index e3c7f7378..cf8e77158 100644 --- a/apps/sim/triggers/grain/highlight_updated.ts +++ b/apps/sim/triggers/grain/highlight_updated.ts @@ -12,13 +12,13 @@ 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', @@ -26,13 +26,35 @@ export const grainHighlightUpdatedTrigger: TriggerConfig = { }, }, { - 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', diff --git a/apps/sim/triggers/grain/recording_created.ts b/apps/sim/triggers/grain/recording_created.ts index 6fe67aa5e..4085f0c75 100644 --- a/apps/sim/triggers/grain/recording_created.ts +++ b/apps/sim/triggers/grain/recording_created.ts @@ -12,13 +12,13 @@ export const grainRecordingCreatedTrigger: 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', @@ -26,13 +26,35 @@ export const grainRecordingCreatedTrigger: TriggerConfig = { }, }, { - 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_recording_created', + }, + }, + { + id: 'includeParticipants', + title: 'Include Participants', + type: 'switch', + description: 'Include participant list in webhook payload.', + defaultValue: false, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_recording_created', + }, + }, + { + id: 'includeAiSummary', + title: 'Include AI Summary', + type: 'switch', + description: 'Include AI-generated summary in webhook payload.', + defaultValue: false, mode: 'trigger', condition: { field: 'selectedTriggerId', diff --git a/apps/sim/triggers/grain/recording_updated.ts b/apps/sim/triggers/grain/recording_updated.ts index d541fe132..799424f2e 100644 --- a/apps/sim/triggers/grain/recording_updated.ts +++ b/apps/sim/triggers/grain/recording_updated.ts @@ -12,13 +12,13 @@ export const grainRecordingUpdatedTrigger: 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', @@ -26,13 +26,35 @@ export const grainRecordingUpdatedTrigger: TriggerConfig = { }, }, { - 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_recording_updated', + }, + }, + { + id: 'includeParticipants', + title: 'Include Participants', + type: 'switch', + description: 'Include participant list in webhook payload.', + defaultValue: false, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_recording_updated', + }, + }, + { + id: 'includeAiSummary', + title: 'Include AI Summary', + type: 'switch', + description: 'Include AI-generated summary in webhook payload.', + defaultValue: false, mode: 'trigger', condition: { field: 'selectedTriggerId', diff --git a/apps/sim/triggers/grain/story_created.ts b/apps/sim/triggers/grain/story_created.ts index 8e43051be..e9b7306b8 100644 --- a/apps/sim/triggers/grain/story_created.ts +++ b/apps/sim/triggers/grain/story_created.ts @@ -12,13 +12,13 @@ export const grainStoryCreatedTrigger: 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', @@ -26,13 +26,35 @@ export const grainStoryCreatedTrigger: TriggerConfig = { }, }, { - 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_story_created', + }, + }, + { + id: 'includeParticipants', + title: 'Include Participants', + type: 'switch', + description: 'Include participant list in webhook payload.', + defaultValue: false, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_story_created', + }, + }, + { + id: 'includeAiSummary', + title: 'Include AI Summary', + type: 'switch', + description: 'Include AI-generated summary in webhook payload.', + defaultValue: false, mode: 'trigger', condition: { field: 'selectedTriggerId', diff --git a/apps/sim/triggers/grain/utils.ts b/apps/sim/triggers/grain/utils.ts index c9fa593ec..3c77dd026 100644 --- a/apps/sim/triggers/grain/utils.ts +++ b/apps/sim/triggers/grain/utils.ts @@ -17,19 +17,17 @@ export const grainTriggerOptions = [ */ export function grainSetupInstructions(eventType: string): string { const instructions = [ - 'Note: You need admin permissions in your Grain workspace to create webhooks.', - 'In Grain, navigate to Settings > Integrations > Webhooks.', - 'Click "Create webhook" or "Add webhook".', - 'Paste the Webhook URL from above into the URL field.', - 'Optionally, enter the Webhook Secret from above for signature validation.', - `Select the event types this webhook should listen to. For this trigger, select ${eventType}.`, - 'Click "Save" to activate the webhook.', + 'Enter your Grain API Key (Personal Access Token) above.', + 'You can find or create your API key in Grain at Settings > Integrations > API.', + 'Optionally configure filters to narrow which recordings trigger the webhook.', + `Click "Save Configuration" to automatically create the webhook in Grain for ${eventType} events.`, + 'The webhook will be automatically deleted when you remove this trigger.', ] return instructions .map( (instruction, index) => - `
${index === 0 ? instruction : `${index}. ${instruction}`}
` + `
${index + 1}. ${instruction}
` ) .join('') } diff --git a/apps/sim/triggers/grain/webhook.ts b/apps/sim/triggers/grain/webhook.ts index 872a2ea93..88f968682 100644 --- a/apps/sim/triggers/grain/webhook.ts +++ b/apps/sim/triggers/grain/webhook.ts @@ -21,13 +21,13 @@ export const grainWebhookTrigger: TriggerConfig = { required: true, }, { - 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', @@ -35,13 +35,35 @@ export const grainWebhookTrigger: TriggerConfig = { }, }, { - 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_webhook', + }, + }, + { + id: 'includeParticipants', + title: 'Include Participants', + type: 'switch', + description: 'Include participant list in webhook payload.', + defaultValue: false, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_webhook', + }, + }, + { + id: 'includeAiSummary', + title: 'Include AI Summary', + type: 'switch', + description: 'Include AI-generated summary in webhook payload.', + defaultValue: false, mode: 'trigger', condition: { field: 'selectedTriggerId',