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(
|
private static async executeWebhook(
|
||||||
_step: WorkflowStep,
|
_step: WorkflowStep,
|
||||||
@@ -924,9 +931,37 @@ export class WorkflowExecutionService {
|
|||||||
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data)
|
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data)
|
||||||
? (contact.data as Record<string, unknown>)
|
? (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 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: {
|
contact: {
|
||||||
email: contact.email,
|
email: contact.email,
|
||||||
subscribed: contact.subscribed,
|
subscribed: contact.subscribed,
|
||||||
@@ -944,11 +979,11 @@ export class WorkflowExecutionService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Make HTTP request
|
// Make HTTP request
|
||||||
const response = await WorkflowExecutionService.safeFetch(url, {
|
const response = await WorkflowExecutionService.safeFetch(renderedUrl, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...headers,
|
...renderedHeaders,
|
||||||
},
|
},
|
||||||
body: method !== 'GET' ? JSON.stringify(payload) : undefined,
|
body: method !== 'GET' ? JSON.stringify(payload) : undefined,
|
||||||
});
|
});
|
||||||
@@ -962,7 +997,7 @@ export class WorkflowExecutionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url,
|
url: renderedUrl,
|
||||||
method,
|
method,
|
||||||
statusCode: response.status,
|
statusCode: response.status,
|
||||||
success: response.ok,
|
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
|
* 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`) |
|
| `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) |
|
| `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) |
|
| `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 |
|
| `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` |
|
| `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>
|
||||||
|
|
||||||
<Step>
|
<Step>
|
||||||
|
|||||||
Reference in New Issue
Block a user