feat: render template variables in WEBHOOK step url, headers and body

This commit is contained in:
taniasanz7
2026-05-10 17:28:45 +02:00
committed by Tania Sanz
parent 1d475c382b
commit c484da88ab
4 changed files with 339 additions and 6 deletions
@@ -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>