diff --git a/apps/api/src/services/WorkflowExecutionService.ts b/apps/api/src/services/WorkflowExecutionService.ts index ca34026..7f26b03 100644 --- a/apps/api/src/services/WorkflowExecutionService.ts +++ b/apps/api/src/services/WorkflowExecutionService.ts @@ -908,7 +908,14 @@ export class WorkflowExecutionService { } /** - * WEBHOOK step - Call an external webhook + * WEBHOOK step - Call an external webhook. + * + * Renders `{{vars}}` in `url`, header values, and `body`. The variable + * scope is a superset of the SEND_EMAIL scope: id, email, contact data, + * execution context, and subscribe/unsubscribe/manage URLs — plus a + * webhook-only `event` namespace exposing the trigger event payload. + * `method` is intentionally NOT rendered — it must remain a literal + * HTTP verb. */ private static async executeWebhook( _step: WorkflowStep, @@ -924,9 +931,37 @@ export class WorkflowExecutionService { contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data) ? (contact.data as Record) : {}; + const executionContext = + execution.context && typeof execution.context === 'object' && !Array.isArray(execution.context) + ? (execution.context as Record) + : {}; const context = execution.context || {}; - const payload = body || { + // Render scope: SEND_EMAIL's scope (id, email, contact data, execution + // context, subscribe/unsubscribe/manage URLs) plus a webhook-only + // `event` namespace carrying the trigger event payload. `method` is + // intentionally NOT rendered — it must remain a literal HTTP verb. + const variables = { + id: contact.id, + email: contact.email, + ...contactData, + ...executionContext, + data: contactData, + event: context, + unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`, + subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`, + manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`, + }; + + const renderedUrl = this.renderTemplate(url, variables); + const renderedHeaders = headers + ? Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key, this.renderTemplate(value, variables)]), + ) + : undefined; + const renderedBody = body ? this.renderJsonTemplate(body, variables) : undefined; + + const payload = renderedBody || { contact: { email: contact.email, subscribed: contact.subscribed, @@ -944,11 +979,11 @@ export class WorkflowExecutionService { }; // Make HTTP request - const response = await WorkflowExecutionService.safeFetch(url, { + const response = await WorkflowExecutionService.safeFetch(renderedUrl, { method, headers: { 'Content-Type': 'application/json', - ...headers, + ...renderedHeaders, }, body: method !== 'GET' ? JSON.stringify(payload) : undefined, }); @@ -962,7 +997,7 @@ export class WorkflowExecutionService { } return { - url, + url: renderedUrl, method, statusCode: response.status, success: response.ok, @@ -970,6 +1005,28 @@ export class WorkflowExecutionService { }; } + /** + * Helper: Recursively render template variables in any JSON-shaped value. + * Strings are rendered, arrays/objects are walked, and non-string scalars + * (numbers, booleans, null) are returned untouched. + */ + private static renderJsonTemplate(value: unknown, variables: Record): unknown { + if (typeof value === 'string') { + return this.renderTemplate(value, variables); + } + if (Array.isArray(value)) { + return value.map(item => this.renderJsonTemplate(item, variables)); + } + if (value !== null && typeof value === 'object') { + const result: Record = {}; + for (const [key, child] of Object.entries(value as Record)) { + result[key] = this.renderJsonTemplate(child, variables); + } + return result; + } + return value; + } + /** * UPDATE_CONTACT step - Update contact data */ diff --git a/apps/api/src/services/__tests__/WorkflowExecutionService.webhook.test.ts b/apps/api/src/services/__tests__/WorkflowExecutionService.webhook.test.ts new file mode 100644 index 0000000..611677e --- /dev/null +++ b/apps/api/src/services/__tests__/WorkflowExecutionService.webhook.test.ts @@ -0,0 +1,229 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db'; +import {WorkflowExecutionService} from '../WorkflowExecutionService'; +import {factories, getPrismaClient} from '../../../../../test/helpers'; + +/** + * Tests for WEBHOOK step config templating. + * + * `executeWebhook` is a private static method but is invokable at runtime + * through a `as any` cast. We mock `safeFetch` (also private) via the + * same mechanism so we can capture the rendered request without making a + * real network call. + */ +describe('WorkflowExecutionService.executeWebhook templating', () => { + let projectId: string; + const prisma = getPrismaClient(); + + // Capture (url, options) passed to safeFetch + let safeFetchSpy: ReturnType; + let captured: {url: string; options: RequestInit} | null = null; + + beforeEach(async () => { + const {project} = await factories.createUserWithProject(); + projectId = project.id; + + captured = null; + safeFetchSpy = vi + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(WorkflowExecutionService as any, 'safeFetch') + .mockImplementation(async (...args: unknown[]) => { + const [url, options] = args as [string, RequestInit]; + captured = {url, options}; + return new Response('{"ok":true}', { + status: 200, + headers: {'Content-Type': 'application/json'}, + }); + }); + }); + + afterEach(() => { + safeFetchSpy.mockRestore(); + }); + + /** + * Helper: build a workflow with a single WEBHOOK step using the given + * config, plus a contact and a RUNNING execution. Returns the args + * shape `executeWebhook` expects. + */ + async function setup( + webhookConfig: Record, + contactOverrides: {data?: Record} = {}, + executionContext: Record = {}, + ) { + const contact = await factories.createContact({ + projectId, + data: contactOverrides.data, + }); + + const workflow = await factories.createWorkflow({ + projectId, + enabled: true, + triggerType: WorkflowTriggerType.EVENT, + triggerConfig: {eventName: 'test.event'}, + }); + + const step = await prisma.workflowStep.create({ + data: { + workflowId: workflow.id, + type: WorkflowStepType.WEBHOOK, + name: 'Webhook', + position: {x: 0, y: 0}, + config: webhookConfig, + }, + }); + + const execution = await prisma.workflowExecution.create({ + data: { + workflowId: workflow.id, + contactId: contact.id, + status: WorkflowExecutionStatus.RUNNING, + context: executionContext, + }, + include: {contact: true, workflow: true}, + }); + + const stepExecution = await prisma.workflowStepExecution.create({ + data: { + executionId: execution.id, + stepId: step.id, + status: StepExecutionStatus.RUNNING, + startedAt: new Date(), + }, + }); + + return {step, execution, stepExecution}; + } + + async function invokeWebhook( + step: unknown, + execution: unknown, + stepExecution: unknown, + config: unknown, + ) { + // Call through `as any` because executeWebhook is private at the + // TypeScript level. JS has no actual access control. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (WorkflowExecutionService as any).executeWebhook(step, execution, stepExecution, config); + } + + it('renders {{vars}} in the URL from contact.data', async () => { + const {step, execution, stepExecution} = await setup( + { + url: 'https://example.com/api/users/{{userId}}', + method: 'GET', + }, + {data: {userId: 'abc-123'}}, + ); + + await invokeWebhook(step, execution, stepExecution, step.config); + + expect(captured).not.toBeNull(); + expect(captured!.url).toBe('https://example.com/api/users/abc-123'); + }); + + it('renders {{vars}} in header values', async () => { + const {step, execution, stepExecution} = await setup( + { + url: 'https://example.com/hook', + method: 'POST', + headers: { + Authorization: 'Bearer {{apiToken}}', + 'X-Static': 'literal', + }, + }, + {data: {apiToken: 'secret-token-xyz'}}, + ); + + await invokeWebhook(step, execution, stepExecution, step.config); + + expect(captured).not.toBeNull(); + const headers = captured!.options.headers as Record; + expect(headers.Authorization).toBe('Bearer secret-token-xyz'); + expect(headers['X-Static']).toBe('literal'); + }); + + it('renders {{vars}} in nested object body leaves and JSON-encodes', async () => { + const {step, execution, stepExecution} = await setup( + { + url: 'https://example.com/hook', + method: 'POST', + body: { + user: { + email: '{{email}}', + name: '{{firstName}}', + }, + ref: 'literal-ref', + tags: ['plan:{{plan}}', 'static'], + }, + }, + {data: {firstName: 'Ada', plan: 'gold'}}, + {campaignId: 'camp-9'}, + ); + + await invokeWebhook(step, execution, stepExecution, step.config); + + expect(captured).not.toBeNull(); + const body = JSON.parse(captured!.options.body as string); + expect(body.user.email).toBe(execution.contact.email); + expect(body.user.name).toBe('Ada'); + expect(body.ref).toBe('literal-ref'); + expect(body.tags).toEqual(['plan:gold', 'static']); + }); + + it('leaves non-string body leaves untouched', async () => { + const {step, execution, stepExecution} = await setup({ + url: 'https://example.com/hook', + method: 'POST', + body: { + score: 42, + active: true, + deleted: null, + meta: { + count: 7, + enabled: false, + }, + tags: ['{{plan ?? free}}', 100, false], + }, + }); + + await invokeWebhook(step, execution, stepExecution, step.config); + + expect(captured).not.toBeNull(); + const body = JSON.parse(captured!.options.body as string); + expect(body.score).toBe(42); + expect(body.active).toBe(true); + expect(body.deleted).toBe(null); + expect(body.meta).toEqual({count: 7, enabled: false}); + // String leaf rendered (with default), non-string leaves preserved. + expect(body.tags).toEqual(['free', 100, false]); + }); + + it('renders {{event.*}} variables from the trigger payload', async () => { + const {step, execution, stepExecution} = await setup( + { + url: 'https://example.com/hooks/{{event.referrer}}', + method: 'POST', + headers: { + 'X-Email-Id': '{{event.emailId}}', + }, + body: { + referrer: '{{event.referrer}}', + subject: '{{event.subject}}', + }, + }, + {}, + {referrer: 'newsletter-may', emailId: 'eml_abc123', subject: 'Welcome'}, + ); + + await invokeWebhook(step, execution, stepExecution, step.config); + + expect(captured).not.toBeNull(); + expect(captured!.url).toBe('https://example.com/hooks/newsletter-may'); + const headers = captured!.options.headers as Record; + expect(headers['X-Email-Id']).toBe('eml_abc123'); + const body = JSON.parse(captured!.options.body as string); + expect(body.referrer).toBe('newsletter-may'); + expect(body.subject).toBe('Welcome'); + }); +}); diff --git a/apps/wiki/content/docs/concepts/workflows.mdx b/apps/wiki/content/docs/concepts/workflows.mdx index deb8459..3dc68ff 100644 --- a/apps/wiki/content/docs/concepts/workflows.mdx +++ b/apps/wiki/content/docs/concepts/workflows.mdx @@ -37,7 +37,7 @@ A workflow always begins with a single auto-created `TRIGGER` step. You build th | `DELAY` | Pauses the execution for a fixed duration before continuing. | `amount`, `unit` (`minutes` / `hours` / `days`) | | `WAIT_FOR_EVENT` | Pauses until a specified event is tracked on the contact, with a timeout fallback. | `eventName`, `timeout` (seconds) | | `CONDITION` | Branches the execution based on contact data or event data. Each `CONDITION` step has two outgoing transitions tagged `yes` / `no`. | A filter expression (same shape as segment filters) | -| `WEBHOOK` | Calls an external HTTPS endpoint with contact + execution context as the JSON body. | `url`, optional `method`, `headers` | +| `WEBHOOK` | Calls an external HTTPS endpoint with contact + execution context as the JSON body. `url`, header values, and `body` support `{{variables}}`. | `url`, optional `method`, `headers`, `body` | | `UPDATE_CONTACT` | Patches contact data — useful for tagging contacts as they progress (`{ stage: "activated" }`). | `data` object | | `EXIT` | Terminates the execution. Optionally records an `exitReason` for analytics. | optional `reason` | diff --git a/apps/wiki/content/docs/guides/webhooks.mdx b/apps/wiki/content/docs/guides/webhooks.mdx index 8b17f36..853cf05 100644 --- a/apps/wiki/content/docs/guides/webhooks.mdx +++ b/apps/wiki/content/docs/guides/webhooks.mdx @@ -84,6 +84,53 @@ After the trigger, add a **Webhook** step and configure it: } ``` +- **Body** (optional): Custom request body. When omitted, Plunk sends the [default payload](#webhook-payload) shown below. When provided, the value replaces the default payload entirely and is JSON-encoded before being sent. + + + + + +### Use variables in the request (optional) + +The `url`, header values, and `body` all support `{{variable}}` interpolation. The available scope is the same as `SEND_EMAIL` templates, plus a webhook-only `event` namespace exposing the trigger event payload: + +| Variable | Value | +| --------------------------------------------------------- | --------------------------------------------------------------------------- | +| `{{id}}`, `{{email}}` | The contact's ID and email. | +| `{{}}` (top-level) | Any key from the contact's `data` JSON (e.g. `{{firstName}}`, `{{plan}}`). | +| `{{data.}}` | The same contact data, addressed via the `data` namespace. | +| `{{event.}}` | Webhook-only. Fields from the trigger event payload (e.g. `{{event.subject}}`). | +| `{{}}` (from execution context) | Keys passed in as `context` when starting a `MANUAL` execution. | +| `{{unsubscribeUrl}}`, `{{subscribeUrl}}`, `{{manageUrl}}` | Per-contact subscription management URLs. | + +The HTTP `method` is **not** templated — it must be a literal verb (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`). The `url` must include a static scheme (`http://` or `https://`); placeholders are supported inside the URL but cannot replace the scheme. + +Example — forward a contact event to your own API, parameterised by contact data: + +**URL** + +```text +https://api.example.com/users/{{id}}/events +``` + +**Headers** + +```json +{ + "Authorization": "Bearer your-secret-token" +} +``` + +**Body** + +```json +{ + "email": "{{email}}", + "plan": "{{plan}}", + "referrer": "{{event.referrer}}" +} +``` +