Skip to content
Closed
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
61 changes: 58 additions & 3 deletions .cursor/rules/sim-integrations.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,9 @@ export function {Service}Icon(props: SVGProps<SVGSVGElement>) {
```
triggers/{service}/
├── index.ts # Export all triggers
├── webhook.ts # Webhook handler
├── utils.ts # Shared utilities
└── {event}.ts # Specific event handlers
├── utils.ts # Trigger options, setup instructions, event matching, output builders
├── webhook.ts # Generic webhook (catch-all)
└── {event}.ts # Event-specific triggers (e.g., employee_hired.ts)
```

**Register in `triggers/registry.ts`:**
Expand All @@ -223,6 +223,59 @@ import { {service}WebhookTrigger } from '@/triggers/{service}'
{service}_webhook: {service}WebhookTrigger,
```

### External Subscription Lifecycle

If the service supports programmatic webhook/subscription creation, implement auto-registration in `lib/webhooks/provider-subscriptions.ts`:

```typescript
// Create subscription on deploy
export async function create{Service}Subscription(
webhookData: Record<string, unknown>,
requestId: string
): Promise<{ id: string } | undefined> {
const { path, providerConfig } = webhookData
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
// Call external API to register webhook URL...
return { id: externalSubscriptionId }
}

// Delete/disable subscription on undeploy
export async function delete{Service}Subscription(
webhook: Record<string, unknown>,
requestId: string
): Promise<void> {
const { externalId } = webhook.providerConfig
// Call external API to delete/disable subscription...
}
```

Then add branches in the dispatcher functions:

```typescript
// In createExternalWebhookSubscription():
} else if (provider === '{service}') {
const result = await create{Service}Subscription(webhookData, requestId)
if (result) {
updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id }
externalSubscriptionCreated = true
}
}

// In cleanupExternalWebhook():
} else if (webhook.provider === '{service}') {
await delete{Service}Subscription(webhook, requestId)
}
```

Add credential sub-blocks (API key, tenant URL, etc.) to trigger sub-blocks so the deploy flow can call the external API. Credentials are stored in `providerConfig` on the webhook DB row.

### SOAP/XML Webhooks

If the service sends XML/SOAP notifications, the webhook processor handles this automatically via `text/xml`, `application/xml`, `application/soap+xml` content types using `fast-xml-parser`. Use helpers from `lib/webhooks/soap-utils.ts`:

- `extractSoapBody()` — unwraps SOAP envelope
- `stripNamespacePrefixes()` — strips `wd:`, `env:` etc. from keys

## File Handling

When integrations handle file uploads/downloads, use `UserFile` objects consistently.
Expand Down Expand Up @@ -281,5 +334,7 @@ const file = await processor.processFileData({
- [ ] Register block in `blocks/registry.ts`
- [ ] (Optional) Create triggers in `triggers/{service}/`
- [ ] (Optional) Register triggers in `triggers/registry.ts`
- [ ] (If triggers with external subscriptions) Add create/delete functions in `lib/webhooks/provider-subscriptions.ts`
- [ ] (If SOAP/XML webhooks) Use `extractSoapBody()` and `stripNamespacePrefixes()` from `lib/webhooks/soap-utils.ts`
- [ ] (If file uploads) Create internal API route with `downloadFileFromStorage`
- [ ] (If file uploads) Use `normalizeFileInput` in block config
16 changes: 14 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,12 +364,23 @@ export function ServiceIcon(props: SVGProps<SVGSVGElement>) {
```
triggers/{service}/
├── index.ts # Barrel export
├── webhook.ts # Webhook handler
└── {event}.ts # Event-specific handlers
├── utils.ts # Trigger options, setup instructions, event matching
├── webhook.ts # Generic webhook (catch-all)
└── {event}.ts # Event-specific triggers
```

Register in `triggers/registry.ts`.

**External Subscription Lifecycle:** If the service supports programmatic webhook registration (e.g., Ashby `webhook.create`, Workday `Put_Subscription`, Attio webhooks API), implement auto-registration so users don't need to manually configure webhooks in the external service:

1. Add `create{Service}Subscription()` and `delete{Service}Subscription()` in `lib/webhooks/provider-subscriptions.ts`
2. Add branches for your provider in `createExternalWebhookSubscription()` and `cleanupExternalWebhook()`
3. Add credential fields (API key, OAuth token, etc.) to trigger sub-blocks so the deploy flow can call the external API
4. Store the external subscription ID in `providerConfig.externalId` for cleanup on undeploy
5. Subscriptions are created during deploy (`saveTriggerWebhooksForDeploy`) and cleaned up during undeploy (`cleanupWebhooksForWorkflow`)

**SOAP/XML Webhooks:** If the service sends XML/SOAP notifications instead of JSON, the webhook processor (`lib/webhooks/processor.ts`) automatically handles `text/xml`, `application/xml`, and `application/soap+xml` content types via `fast-xml-parser`. Use `extractSoapBody()` and `stripNamespacePrefixes()` from `lib/webhooks/soap-utils.ts` to clean the parsed payload for event matching.

### Integration Checklist

- [ ] Look up API docs
Expand All @@ -379,5 +390,6 @@ Register in `triggers/registry.ts`.
- [ ] Create block in `blocks/blocks/{service}.ts`
- [ ] Register block in `blocks/registry.ts`
- [ ] (Optional) Create and register triggers
- [ ] (If triggers with external subscriptions) Add create/delete functions in `lib/webhooks/provider-subscriptions.ts`
- [ ] (If file uploads) Create internal API route with `downloadFileFromStorage`
- [ ] (If file uploads) Use `normalizeFileInput` in block config
14 changes: 14 additions & 0 deletions apps/sim/blocks/blocks/workday.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { WorkdayIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { getTrigger } from '@/triggers'

export const WorkdayBlock: BlockConfig = {
type: 'workday',
Expand Down Expand Up @@ -341,7 +342,20 @@ Output: {"businessTitle": "Senior Engineer"}`,
condition: { field: 'operation', value: 'terminate_worker' },
mode: 'advanced',
},
...getTrigger('workday_employee_hired').subBlocks,
...getTrigger('workday_employee_terminated').subBlocks,
...getTrigger('workday_job_changed').subBlocks,
...getTrigger('workday_webhook').subBlocks,
],
triggers: {
enabled: true,
available: [
'workday_employee_hired',
'workday_employee_terminated',
'workday_job_changed',
'workday_webhook',
],
},
tools: {
access: [
'workday_get_worker',
Expand Down
30 changes: 30 additions & 0 deletions apps/sim/lib/webhooks/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
validateTypeformSignature,
verifyProviderWebhook,
} from '@/lib/webhooks/utils.server'
import { parseXmlToJson } from '@/lib/webhooks/xml-parser'
import { executeWebhookJob } from '@/background/webhook-execution'
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
import { isConfluencePayloadMatch } from '@/triggers/confluence/utils'
Expand Down Expand Up @@ -147,6 +148,12 @@ export async function parseWebhookBody(
} else {
body = Object.fromEntries(formData.entries())
}
} else if (
contentType.includes('text/xml') ||
contentType.includes('application/xml') ||
contentType.includes('application/soap+xml')
) {
body = parseXmlToJson(rawBody)
} else {
body = JSON.parse(rawBody)
}
Expand Down Expand Up @@ -1151,6 +1158,29 @@ export async function queueWebhookExecution(
}
}

if (foundWebhook.provider === 'workday') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const triggerId = providerConfig.triggerId as string | undefined

if (triggerId && triggerId !== 'workday_webhook') {
const { isWorkdayEventMatch } = await import('@/triggers/workday/utils')
if (!isWorkdayEventMatch(triggerId, body)) {
logger.debug(
`[${options.requestId}] Workday event mismatch for trigger ${triggerId}. Skipping execution.`,
{
webhookId: foundWebhook.id,
workflowId: foundWorkflow.id,
triggerId,
}
)
return NextResponse.json({
status: 'skipped',
reason: 'event_type_mismatch',
})
}
}
}

if (foundWebhook.provider === 'hubspot') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const triggerId = providerConfig.triggerId as string | undefined
Expand Down
129 changes: 129 additions & 0 deletions apps/sim/lib/webhooks/provider-subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2136,6 +2136,12 @@ export async function createExternalWebhookSubscription(
updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id }
externalSubscriptionCreated = true
}
} else if (provider === 'workday') {
const result = await createWorkdaySubscription(webhookData, requestId)
if (result) {
updatedProviderConfig = { ...updatedProviderConfig, externalId: result.subscriptionId }
externalSubscriptionCreated = true
}
}

return { updatedProviderConfig, externalSubscriptionCreated }
Expand Down Expand Up @@ -2173,6 +2179,8 @@ export async function cleanupExternalWebhook(
await deleteGrainWebhook(webhook, requestId)
} else if (webhook.provider === 'lemlist') {
await deleteLemlistWebhook(webhook, requestId)
} else if (webhook.provider === 'workday') {
await deleteWorkdaySubscription(webhook, requestId)
}
}

Expand Down Expand Up @@ -2335,3 +2343,124 @@ export async function deleteAshbyWebhook(webhook: any, requestId: string): Promi
ashbyLogger.warn(`[${requestId}] Error deleting Ashby webhook (non-fatal)`, error)
}
}

const workdayLogger = createLogger('WorkdaySubscription')

const WORKDAY_TRIGGER_EVENT_TYPES: Record<string, string[]> = {
workday_employee_hired: ['Hire_Employee'],
workday_employee_terminated: ['Terminate_Employee'],
workday_job_changed: ['Change_Job'],
}

/**
* Creates a webhook subscription in Workday via the Put_Subscription SOAP operation.
* Uses the Integrations service (v29.0) to register our webhook URL as a push endpoint.
*/
export async function createWorkdaySubscription(
webhookData: Record<string, unknown>,
requestId: string
): Promise<{ subscriptionId: string } | undefined> {
try {
const { path, providerConfig } = webhookData
const config = (providerConfig as Record<string, unknown>) || {}
const { tenantUrl, tenant, username, password, integrationSystemId, triggerId } = config

if (!tenantUrl || !tenant || !username || !password || !integrationSystemId) {
workdayLogger.error(`[${requestId}] Missing Workday credentials for subscription creation`)
return undefined
}

const { createWorkdaySoapClient, wdRef, extractRefId } = await import('@/tools/workday/soap')

const client = await createWorkdaySoapClient(
tenantUrl as string,
tenant as string,
'integrations',
username as string,
password as string
)

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

const subscriptionData: Record<string, unknown> = {
Subscriber_Reference: wdRef('Integration_System_ID', integrationSystemId as string),
Endpoint_Info_Data: {
Web_Service_API_Version_Reference: wdRef('Version', 'v29.0'),
Subscriber_URL: notificationUrl,
},
}

const eventTypes = WORKDAY_TRIGGER_EVENT_TYPES[triggerId as string]
if (eventTypes) {
subscriptionData.Included_Transaction_Log_Type_Reference = eventTypes.map((et) =>
wdRef('Business_Process_Type', et)
)
} else {
subscriptionData.Subscribe_to_all_Business_Processes = true
}

const [result] = await client.Put_SubscriptionAsync({
Subscription_Data: subscriptionData,
})

const subscriptionId = extractRefId(result?.Subscription_Reference)

if (subscriptionId) {
workdayLogger.info(`[${requestId}] Workday subscription created: ${subscriptionId}`)
return { subscriptionId }
}

workdayLogger.warn(`[${requestId}] Workday subscription created but no ID returned`)
return undefined
} catch (error) {
workdayLogger.error(`[${requestId}] Failed to create Workday subscription`, { error })
return undefined
}
}

/**
* Disables a Workday webhook subscription by calling Put_Subscription
* with Disable_Endpoint set to true.
*/
export async function deleteWorkdaySubscription(
webhook: Record<string, unknown>,
requestId: string
): Promise<void> {
try {
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
const { tenantUrl, tenant, username, password, externalId, integrationSystemId } =
providerConfig

if (!tenantUrl || !tenant || !username || !password || !externalId) {
workdayLogger.warn(
`[${requestId}] Missing credentials for Workday subscription cleanup, skipping`
)
return
Comment on lines +2434 to +2438
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 integrationSystemId not guarded before use

The guard condition checks for tenantUrl, tenant, username, password, and externalId, but NOT integrationSystemId. On line 2454, wdRef('Integration_System_ID', integrationSystemId as string) is called unconditionally — if integrationSystemId is missing from the stored providerConfig, this will pass undefined (coerced to the string "undefined") to the SOAP client, producing a malformed Put_Subscription disable request.

Suggested change
if (!tenantUrl || !tenant || !username || !password || !externalId) {
workdayLogger.warn(
`[${requestId}] Missing credentials for Workday subscription cleanup, skipping`
)
return
if (!tenantUrl || !tenant || !username || !password || !externalId || !integrationSystemId) {
workdayLogger.warn(
`[${requestId}] Missing credentials for Workday subscription cleanup, skipping`
)
return
}

}

const { createWorkdaySoapClient, wdRef } = await import('@/tools/workday/soap')

const client = await createWorkdaySoapClient(
tenantUrl as string,
tenant as string,
'integrations',
username as string,
password as string
)

await client.Put_SubscriptionAsync({
Subscription_Reference: wdRef('WID', externalId as string),
Subscription_Data: {
Subscriber_Reference: wdRef('Integration_System_ID', integrationSystemId as string),
Endpoint_Info_Data: {
Web_Service_API_Version_Reference: wdRef('Version', 'v29.0'),
Disable_Endpoint: true,
},
},
})

workdayLogger.info(`[${requestId}] Workday subscription ${externalId} disabled`)
} catch (error) {
workdayLogger.warn(`[${requestId}] Error disabling Workday subscription (non-fatal)`, error)
}
}
Loading
Loading