Merge pull request #375 from taniasanz7/patch-1-webhook-templating
feat: render template variables in WEBHOOK step url, headers and body
This commit is contained in:
@@ -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<string, unknown>)
|
||||
: {};
|
||||
const executionContext =
|
||||
execution.context && typeof execution.context === 'object' && !Array.isArray(execution.context)
|
||||
? (execution.context as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>): 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<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
result[key] = this.renderJsonTemplate(child, variables);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATE_CONTACT step - Update contact data
|
||||
*/
|
||||
|
||||
@@ -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<typeof vi.spyOn>;
|
||||
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<string, unknown>,
|
||||
contactOverrides: {data?: Record<string, unknown>} = {},
|
||||
executionContext: Record<string, unknown> = {},
|
||||
) {
|
||||
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<string, string>;
|
||||
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<string, string>;
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 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. |
|
||||
| `{{<key>}}` (top-level) | Any key from the contact's `data` JSON (e.g. `{{firstName}}`, `{{plan}}`). |
|
||||
| `{{data.<key>}}` | The same contact data, addressed via the `data` namespace. |
|
||||
| `{{event.<key>}}` | Webhook-only. Fields from the trigger event payload (e.g. `{{event.subject}}`). |
|
||||
| `{{<key>}}` (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}}"
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
Reference in New Issue
Block a user