From e821f2966cfdfdd015a87f7b0ae95f257fb3b9b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C5=ABnas=20R?= Date: Thu, 12 Nov 2026 13:47:38 +0000 Subject: [PATCH 1/2] feat(workflows): let WEBHOOK steps save their response for later steps Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/services/WorkflowExecutionService.ts | 20 ++++- .../WorkflowExecutionService.webhook.test.ts | 77 +++++++++++++++++++ .../workflow-steps/WebhookStepDialog.tsx | 24 +++++- apps/wiki/content/docs/concepts/workflows.mdx | 2 +- apps/wiki/content/docs/guides/webhooks.mdx | 16 ++++ packages/shared/src/schemas/index.ts | 12 +++ 6 files changed, 148 insertions(+), 3 deletions(-) diff --git a/apps/api/src/services/WorkflowExecutionService.ts b/apps/api/src/services/WorkflowExecutionService.ts index c43e36d42..a181cc3ec 100644 --- a/apps/api/src/services/WorkflowExecutionService.ts +++ b/apps/api/src/services/WorkflowExecutionService.ts @@ -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(); +/** 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; type WorkflowExecutionWithRelations = WorkflowExecution & {contact: Contact; workflow: Workflow}; @@ -1101,7 +1104,7 @@ export class WorkflowExecutionService { _stepExecution: WorkflowStepExecution, config: StepConfig, ): Promise { - 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; @@ -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} : {}), }; } diff --git a/apps/api/src/services/__tests__/WorkflowExecutionService.webhook.test.ts b/apps/api/src/services/__tests__/WorkflowExecutionService.webhook.test.ts index 611677e24..be1a869ac 100644 --- a/apps/api/src/services/__tests__/WorkflowExecutionService.webhook.test.ts +++ b/apps/api/src/services/__tests__/WorkflowExecutionService.webhook.test.ts @@ -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).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(); + }); + }); }); diff --git a/apps/web/src/components/workflow-steps/WebhookStepDialog.tsx b/apps/web/src/components/workflow-steps/WebhookStepDialog.tsx index 468e11355..f1db40c22 100644 --- a/apps/web/src/components/workflow-steps/WebhookStepDialog.tsx +++ b/apps/web/src/components/workflow-steps/WebhookStepDialog.tsx @@ -25,6 +25,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(initialHeaders); + const [saveResponseAs, setSaveResponseAs] = useState(String(config.saveResponseAs ?? '')); const [showWebhookInfo, setShowWebhookInfo] = useState(false); const {update, isSubmitting} = useStepUpdate(workflowId, step.id); @@ -44,9 +45,15 @@ export function WebhookStepDialog({step, workflowId, open, onOpenChange, onSucce } }); + const responseKey = saveResponseAs.trim(); + if (responseKey && !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(responseKey)) { + toast.error('Response name may only contain letters, digits and underscores'); + return; + } + const ok = await update({ name, - config: {url: webhookUrl, method: webhookMethod, headers}, + config: {url: webhookUrl, method: webhookMethod, headers, ...(responseKey ? {saveResponseAs: responseKey} : {})}, }); if (ok) { @@ -169,6 +176,21 @@ export function WebhookStepDialog({step, workflowId, open, onOpenChange, onSucce ))} + +
+ + setSaveResponseAs(e.target.value)} + placeholder="e.g., coupon" + /> +

+ Stores the JSON response of a successful request so later steps can use it, e.g.{' '} + {'{{event.coupon.code}}'}. +

+
); diff --git a/apps/wiki/content/docs/concepts/workflows.mdx b/apps/wiki/content/docs/concepts/workflows.mdx index 2774eb9a6..4b9de2a43 100644 --- a/apps/wiki/content/docs/concepts/workflows.mdx +++ b/apps/wiki/content/docs/concepts/workflows.mdx @@ -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` | diff --git a/apps/wiki/content/docs/guides/webhooks.mdx b/apps/wiki/content/docs/guides/webhooks.mdx index 761e8c981..f69a7e183 100644 --- a/apps/wiki/content/docs/guides/webhooks.mdx +++ b/apps/wiki/content/docs/guides/webhooks.mdx @@ -135,6 +135,22 @@ https://api.example.com/users/{{id}}/events +### 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). `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. diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index d81bb47b9..7024b3ecd 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -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.}}` (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({ From 79f232edfb3fa5243c47f63117a856f5e130d00e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C5=ABnas=20R?= Date: Thu, 12 Nov 2026 14:14:45 +0000 Subject: [PATCH 2/2] fix(workflows): validate saveResponseAs in the dialog with the shared schema Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/workflow-steps/WebhookStepDialog.tsx | 10 +++++++--- apps/wiki/content/docs/guides/webhooks.mdx | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/workflow-steps/WebhookStepDialog.tsx b/apps/web/src/components/workflow-steps/WebhookStepDialog.tsx index f1db40c22..f50a86f08 100644 --- a/apps/web/src/components/workflow-steps/WebhookStepDialog.tsx +++ b/apps/web/src/components/workflow-steps/WebhookStepDialog.tsx @@ -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'; @@ -46,9 +47,12 @@ export function WebhookStepDialog({step, workflowId, open, onOpenChange, onSucce }); const responseKey = saveResponseAs.trim(); - if (responseKey && !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(responseKey)) { - toast.error('Response name may only contain letters, digits and underscores'); - return; + 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({ diff --git a/apps/wiki/content/docs/guides/webhooks.mdx b/apps/wiki/content/docs/guides/webhooks.mdx index f69a7e183..8a5106bf8 100644 --- a/apps/wiki/content/docs/guides/webhooks.mdx +++ b/apps/wiki/content/docs/guides/webhooks.mdx @@ -147,7 +147,7 @@ For example, a webhook step that asks your shop for a one-time coupon, with `sav The next email can then use `{{event.coupon.code}}` and `{{event.coupon.validUntil}}`. -- The name may contain letters, digits and underscores (max 64 characters). `id`, `email`, `data`, `event`, `unsubscribeUrl`, `subscribeUrl` and `manageUrl` are reserved. +- 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.