Skip to content
Open
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
20 changes: 19 additions & 1 deletion apps/api/src/services/WorkflowExecutionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import {QueueService} from './QueueService.js';
// had completed; the step it came from has handled it, so those pass it up untouched.
const handledFailures = new WeakSet<object>();

/** A saved webhook response is copied into every later step's context, so keep it small. */
const WEBHOOK_SAVED_RESPONSE_MAX_BYTES = 64 * 1024;

type StepConfig = Prisma.JsonValue;
type StepResult = Record<string, unknown>;
type WorkflowExecutionWithRelations = WorkflowExecution & {contact: Contact; workflow: Workflow};
Expand Down Expand Up @@ -1101,7 +1104,7 @@ export class WorkflowExecutionService {
_stepExecution: WorkflowStepExecution,
config: StepConfig,
): Promise<StepResult> {
const {url, method, headers, body} = WorkflowStepConfigSchemas.webhook.parse(config);
const {url, method, headers, body, saveResponseAs} = WorkflowStepConfigSchemas.webhook.parse(config);

// Prepare webhook payload
const contact = execution.contact;
Expand Down Expand Up @@ -1172,12 +1175,27 @@ export class WorkflowExecutionService {
parsedResponse = responseData;
}

const responseSaved =
saveResponseAs !== undefined &&
response.ok &&
Buffer.byteLength(responseData, 'utf8') <= WEBHOOK_SAVED_RESPONSE_MAX_BYTES;

if (responseSaved) {
const updatedContext = {...executionContext, [saveResponseAs]: parsedResponse};
await prisma.workflowExecution.update({
where: {id: execution.id},
data: {context: toPrismaJson(updatedContext)},
});
execution.context = updatedContext as WorkflowExecutionWithRelations['context'];
}

return {
url: renderedUrl,
method,
statusCode: response.status,
success: response.ok,
response: parsedResponse,
...(saveResponseAs !== undefined ? {savedAs: responseSaved ? saveResponseAs : null} : {}),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,81 @@ describe('WorkflowExecutionService.executeWebhook templating', () => {
expect(body.referrer).toBe('newsletter-may');
expect(body.subject).toBe('Welcome');
});

describe('saveResponseAs', () => {
it('stores the JSON response in the execution context', async () => {
safeFetchSpy.mockImplementation(
async () => new Response('{"code":"WELCOME-7Q2K","validUntil":"2026-12-01"}', {status: 200}),
);
const {step, execution, stepExecution} = await setup(
{url: 'https://example.com/coupon', method: 'POST', saveResponseAs: 'coupon'},
{},
{referrer: 'newsletter-may'},
);

const result = await invokeWebhook(step, execution, stepExecution, step.config);

expect(result.savedAs).toBe('coupon');
const stored = await prisma.workflowExecution.findUnique({where: {id: execution.id}});
expect(stored!.context).toEqual({
referrer: 'newsletter-may',
coupon: {code: 'WELCOME-7Q2K', validUntil: '2026-12-01'},
});
expect((execution.context as Record<string, unknown>).coupon).toEqual({
code: 'WELCOME-7Q2K',
validUntil: '2026-12-01',
});
});

it('makes the saved response available to a later webhook step', async () => {
safeFetchSpy.mockImplementationOnce(async () => new Response('{"code":"WELCOME-7Q2K"}', {status: 200}));
const {step, execution, stepExecution} = await setup({
url: 'https://example.com/coupon',
saveResponseAs: 'coupon',
});
await invokeWebhook(step, execution, stepExecution, step.config);

await invokeWebhook(step, execution, stepExecution, {
url: 'https://example.com/use/{{event.coupon.code}}',
method: 'GET',
});

expect(captured!.url).toBe('https://example.com/use/WELCOME-7Q2K');
});

it('does not store the response of a failed request', async () => {
safeFetchSpy.mockImplementation(async () => new Response('{"error":"nope"}', {status: 500}));
const {step, execution, stepExecution} = await setup(
{url: 'https://example.com/coupon', saveResponseAs: 'coupon'},
{},
{referrer: 'newsletter-may'},
);

const result = await invokeWebhook(step, execution, stepExecution, step.config);

expect(result.savedAs).toBeNull();
const stored = await prisma.workflowExecution.findUnique({where: {id: execution.id}});
expect(stored!.context).toEqual({referrer: 'newsletter-may'});
});

it('leaves the context untouched without saveResponseAs', async () => {
const {step, execution, stepExecution} = await setup({url: 'https://example.com/hook'}, {}, {referrer: 'x'});

const result = await invokeWebhook(step, execution, stepExecution, step.config);

expect(result).not.toHaveProperty('savedAs');
const stored = await prisma.workflowExecution.findUnique({where: {id: execution.id}});
expect(stored!.context).toEqual({referrer: 'x'});
});

it('rejects reserved and invalid names', async () => {
const {step, execution, stepExecution} = await setup({url: 'https://example.com/hook'});
await expect(
invokeWebhook(step, execution, stepExecution, {url: 'https://example.com/hook', saveResponseAs: 'email'}),
).rejects.toThrow();
await expect(
invokeWebhook(step, execution, stepExecution, {url: 'https://example.com/hook', saveResponseAs: '1abc'}),
).rejects.toThrow();
});
});
});
28 changes: 27 additions & 1 deletion apps/web/src/components/workflow-steps/WebhookStepDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {WorkflowStepConfigSchemas} from '@plunk/shared';
import {Button, Collapsible, CollapsibleContent, CollapsibleTrigger, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@plunk/ui';
import {ChevronDown, Plus, Trash2} from 'lucide-react';
import Link from 'next/link';
Expand Down Expand Up @@ -25,6 +26,7 @@ export function WebhookStepDialog({step, workflowId, open, onOpenChange, onSucce
const [webhookUrl, setWebhookUrl] = useState(String(config.url ?? ''));
const [webhookMethod, setWebhookMethod] = useState(String(config.method ?? 'POST'));
const [webhookHeaders, setWebhookHeaders] = useState<HeaderEntry[]>(initialHeaders);
const [saveResponseAs, setSaveResponseAs] = useState(String(config.saveResponseAs ?? ''));
const [showWebhookInfo, setShowWebhookInfo] = useState(false);

const {update, isSubmitting} = useStepUpdate(workflowId, step.id);
Expand All @@ -44,9 +46,18 @@ export function WebhookStepDialog({step, workflowId, open, onOpenChange, onSucce
}
});

const responseKey = saveResponseAs.trim();
if (responseKey) {
const check = WorkflowStepConfigSchemas.webhook.shape.saveResponseAs.safeParse(responseKey);
if (!check.success) {
toast.error(`Save response as: ${check.error.issues[0]?.message ?? 'invalid name'}`);
return;
}
}

const ok = await update({
name,
config: {url: webhookUrl, method: webhookMethod, headers},
config: {url: webhookUrl, method: webhookMethod, headers, ...(responseKey ? {saveResponseAs: responseKey} : {})},
});

if (ok) {
Expand Down Expand Up @@ -169,6 +180,21 @@ export function WebhookStepDialog({step, workflowId, open, onOpenChange, onSucce
))}
</div>
</div>

<div>
<Label htmlFor="editWebhookSaveResponseAs">Save response as (optional)</Label>
<Input
className="font-mono mt-1.5"
id="editWebhookSaveResponseAs"
value={saveResponseAs}
onChange={e => setSaveResponseAs(e.target.value)}
placeholder="e.g., coupon"
/>
<p className="text-xs text-neutral-500 mt-1.5">
Stores the JSON response of a successful request so later steps can use it, e.g.{' '}
<code className="font-mono">{'{{event.coupon.code}}'}</code>.
</p>
</div>
</div>
</StepDialogShell>
);
Expand Down
2 changes: 1 addition & 1 deletion apps/wiki/content/docs/concepts/workflows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,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; `0` or omitted waits with no limit) |
| `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`, header values, and `body` support `{{variables}}`. | `url`, optional `method`, `headers`, `body` |
| `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`, `saveResponseAs` |
| `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` |

Expand Down
16 changes: 16 additions & 0 deletions apps/wiki/content/docs/guides/webhooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,22 @@ https://api.example.com/users/{{id}}/events

<Step>

### Use the response in later steps (optional)

Set **Save response as** (`saveResponseAs` in the step config) to store the JSON response of a successful (2xx) request in the execution context. Later steps read it under that name, the same way as trigger event data: in a `SEND_EMAIL` template, a `CONDITION`, or another `WEBHOOK`.

For example, a webhook step that asks your shop for a one-time coupon, with `saveResponseAs` set to `coupon`:

```json
{ "code": "WELCOME-7Q2K", "validUntil": "2026-12-01" }
```

The next email can then use `{{event.coupon.code}}` and `{{event.coupon.validUntil}}`.

- The name may contain letters, digits and underscores (max 64 characters) and must not start with a digit. `id`, `email`, `data`, `event`, `unsubscribeUrl`, `subscribeUrl` and `manageUrl` are reserved.
- The response is saved only for a 2xx status and when it is at most 64 KB. A non-JSON response is saved as a string.
- Saving under a name that already exists in the trigger event data replaces that value for the rest of the execution.

### Enable the workflow

Once configured, enable the workflow. It will start sending webhook requests whenever the trigger event occurs.
Expand Down
12 changes: 12 additions & 0 deletions packages/shared/src/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,18 @@ export const WorkflowStepConfigSchemas = {
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).default('POST'),
headers: z.record(z.string()).optional(),
body: jsonSchema.optional(),
/**
* Store the parsed response body in the execution context under this key, so later steps
* can read it as `{{event.<key>}}` (e.g. a coupon code returned by the shop).
*/
saveResponseAs: z
.string()
.regex(/^[A-Za-z_][A-Za-z0-9_]{0,63}$/, 'Use letters, digits and underscores (max 64), not starting with a digit')
.refine(
value => !['id', 'email', 'data', 'event', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'].includes(value),
'This name is reserved',
)
.optional(),
}),
updateContact: z
.object({
Expand Down
Loading