UNPKG

workflow

Version:

Workflow SDK - Build durable, resilient, and observable workflows

681 lines (486 loc) • 27 kB
--- title: Hooks & Webhooks description: Pause workflows and resume them later with external data, user interactions, or HTTP requests. type: conceptual summary: Pause workflows and resume them with external data or HTTP requests. prerequisites: - /docs/foundations/workflows-and-steps related: - /docs/api-reference/workflow/create-hook - /docs/api-reference/workflow/create-webhook - /docs/ai/human-in-the-loop --- Hooks pause workflow execution and resume it later with external data. Workflows can wait for external events, user interactions (also known as "human in the loop"), or HTTP requests. ## Understanding hooks At their core, **Hooks** are a low-level primitive that allows you to pause a workflow and resume it later with arbitrary [serializable data](/docs/foundations/serialization). Think of them as suspension points in your workflow where you're waiting for external input. When you create a hook, it generates a unique token that external systems can use to send data back to your workflow. This makes hooks perfect for scenarios like: - Waiting for approval from a user or admin - Receiving data from an external system or service - Implementing event-driven workflows that react to multiple events over time ### Creating your first hook This workflow creates a hook and waits for external data: ```typescript lineNumbers import { createHook } from "workflow"; export async function approvalWorkflow() { "use workflow"; using hook = createHook<{ approved: boolean; comment: string }>(); console.log("Waiting for approval..."); console.log("Send approval to token:", hook.token); // Workflow pauses here until data is sent const result = await hook; if (result.approved) { console.log("Approved with comment:", result.comment); } else { console.log("Rejected:", result.comment); } } ``` The workflow will pause at `await hook` until external code sends data to resume it. <Callout type="info"> We recommend using the `using` keyword which implements the [TC39 Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management) proposal for automatic cleanup. </Callout> <Callout type="info"> See the full API reference for [`createHook()`](/docs/api-reference/workflow/create-hook) for all available options. </Callout> ### Resuming a hook To send data to a waiting workflow, use [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) from an API route, server action, or any other external context: ```typescript lineNumbers import { resumeHook } from "workflow/api"; // In an API route or external handler export async function POST(request: Request) { const { token, approved, comment } = await request.json(); try { // Resume the workflow with the approval data const result = await resumeHook(token, { approved, comment }); return Response.json({ success: true, runId: result.runId }); } catch (error) { return Response.json({ error: "Invalid token" }, { status: 404 }); } } ``` The key points: - Hooks allow you to pass **any [serializable data](/docs/foundations/serialization)** as the payload - You need the hook's `token` to resume it, but knowing the token does not authorize the caller. Check who is calling before `resumeHook()`; see [Security](#security) - The workflow will resume execution right where it left off ### Checking for token conflicts Sometimes you need to know that a hook token has been claimed, but you do not want to wait for external data yet. Await `hook.getConflict()` for that: ```typescript lineNumbers import { createHook } from "workflow"; declare function processOrder(orderId: string): Promise<void>; // @setup export async function orderWorkflow(orderId: string) { "use workflow"; using hook = createHook({ token: `order:${orderId}` }); const conflict = await hook.getConflict(); // [!code highlight] if (conflict) { // [!code highlight] // Another active run already owns this token. return { dedupedTo: conflict.runId }; } // The hook token is registered and reserved here. await processOrder(orderId); } ``` Calling `createHook()` on its own does not register the hook; registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token (see [`HookConflictError`](/docs/errors/hook-conflict)). For `hook_conflict` events persisted by older worlds that did not record the owning run's ID, `getConflict()` rejects with `HookConflictError` instead of resolving with an incomplete handle. The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()`. See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies. ### Custom tokens for deterministic hooks By default, hooks generate their own token. However, you often want to use a **custom token** that external systems can reconstruct. This is especially useful for long-running workflows where the same workflow instance should handle multiple events. For example, imagine a Slack bot where each channel should have its own workflow instance: ```typescript lineNumbers import { createHook } from "workflow"; export async function slackChannelBot(channelId: string) { "use workflow"; // Use channel ID in the token so Slack webhooks can find this workflow using hook = createHook<SlackMessage>({ token: `slack_messages:${channelId}` }); for await (const message of hook) { console.log(`${message.user}: ${message.text}`); if (message.text === "/stop") { break; } await processMessage(message); } } async function processMessage(message: SlackMessage) { "use step"; // Process the Slack message } ``` Now your Slack webhook handler can deterministically resume the correct workflow: ```typescript lineNumbers import { resumeHook } from "workflow/api"; export async function POST(request: Request) { const slackEvent = await request.json(); const channelId = slackEvent.channel; try { // Reconstruct the token using the channel ID await resumeHook(`slack_messages:${channelId}`, slackEvent); return new Response("OK"); } catch (error) { return new Response("Hook not found", { status: 404 }); } } ``` ### Receiving multiple events Hooks are _reusable_. They implement `AsyncIterable`, which means you can use `for await...of` to receive multiple events over time: ```typescript lineNumbers import { createHook } from "workflow"; export async function dataCollectionWorkflow() { "use workflow"; using hook = createHook<{ value: number; done?: boolean }>(); const values: number[] = []; // Keep receiving data until we get a "done" signal for await (const payload of hook) { values.push(payload.value); if (payload.done) { break; } } console.log("Collected values:", values); return values; } ``` Each time you call `resumeHook()` with the same token, the loop receives another value. ### Waiting with a timeout Race a hook against `sleep()` to stop waiting after a deadline. To wait again after a timeout, create the hook's promise once and race that same promise on every attempt: ```typescript lineNumbers import { createHook, sleep } from "workflow"; declare function sendReminder(): Promise<void>; // @setup export async function reminderWorkflow() { "use workflow"; using hook = createHook<{ approved: boolean }>(); const approval = hook.then((payload) => payload); // [!code highlight] for (let reminder = 0; reminder < 3; reminder++) { const result = await Promise.race([ approval, // [!code highlight] sleep("1d").then(() => "timeout" as const), ]); if (result !== "timeout") { return result.approved; } await sendReminder(); } return false; } ``` Racing a fresh `hook.then()` on each attempt also receives an event that arrives while that race is running. An event that arrives between races, for example while `sendReminder()` runs, is delivered to the awaiter left behind by the earlier race, so the workflow never sees it. Don't race `iterator.next()` from `hook[Symbol.asyncIterator]()` either. The `next()` call that lost a race keeps waiting and takes the next event, even one that arrives while a later race is running. Race the promise you created once instead. Don't await one hook from several places at the same time, for example `Promise.all([hook, hook])`. Which event each await receives depends on how far the workflow had replayed when it awaited, so it can differ between code that looks equivalent. To receive successive events, await them one after another or iterate with `for await...of`. ### Disposing hooks early When a workflow ends, hooks are automatically disposed. However, you may want to release a hook token early so another workflow can use it while your workflow continues running. Use a block scope with `using` to control when disposal happens: ```typescript lineNumbers import { createHook } from "workflow"; export async function handoffWorkflow(channelId: string) { "use workflow"; { using hook = createHook<{ message: string; handoff?: boolean }>({ token: `channel:${channelId}` }); for await (const payload of hook) { console.log("Received:", payload.message); if (payload.handoff) { break; } } } // Hook token released here // Token is now available for another workflow console.log("Continuing with other work..."); } ``` You can also manually dispose using the `dispose()` method: {/* @skip-typecheck: incomplete code sample */} ```typescript lineNumbers const hook = createHook<{ message: string }>({ token: `channel:${channelId}` }); const payload = await hook; hook.dispose(); // Manually release the token ``` <Callout type="info"> After disposal, the hook will no longer receive events and the async iterator will stop yielding values. </Callout> ### Taking over a token from another run Disposing early only works when the run holding the token cooperates. When the newest run should own a token regardless, such as a redeployed bot that must replace the run still waiting on a channel, pass `experimental_force` and let the runtime perform the handoff: ```typescript lineNumbers import { createHook } from "workflow"; import { HookForceClaimedError } from "workflow/errors"; declare function processMessage(message: { text: string }): Promise<void>; // @setup export async function channelWorkflow(channelId: string) { "use workflow"; const hook = createHook<{ text: string }>({ token: `channel:${channelId}`, experimental_force: true, // [!code highlight] }); try { for await (const message of hook) { await processMessage(message); } } catch (error) { if (HookForceClaimedError.is(error)) { // [!code highlight] // A newer run for this channel took the token. Wrap up and exit. return { replacedBy: error.claimedByRunId }; } throw error; } } ``` The run that held the token is woken and its `await hook` (or `for await...of`) rejects with [`HookForceClaimedError`](/docs/api-reference/workflow-errors/hook-force-claimed-error) once it has drained the payloads it received before the takeover. Every `resumeHook()` for the token from then on reaches the new run, including one that was already in flight, so senders never notice the handoff. Several runs forcing the same token at once chain in the same way and always end with exactly one owner. A run started at a spec version below 8 cannot be taken from; the forced hook then gets an ordinary `HookConflictError`. See [`createHook()`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds) for the full semantics. ## Understanding webhooks Hooks require you to manually handle HTTP requests and route them to workflows. **Webhooks** provide a higher-level abstraction built on top of hooks that: 1. Automatically serializes the entire HTTP [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) object 2. Provides an automatically addressable `url` property pointing to the generated webhook endpoint 3. Handles sending HTTP [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) objects back to the caller When using Workflow SDK, webhooks are automatically wired up at `/.well-known/workflow/v1/webhook/:token` without any additional setup. <Callout type="warn"> `createWebhook()` exposes a public route at `/.well-known/workflow/v1/webhook/:token`, and the token in that URL is the only authorization performed for incoming requests. Make sure to read up on [security](#security) before using a webhook for calls that need to be authenticated. </Callout> <Callout type="info"> See the full API reference for [`createWebhook()`](/docs/api-reference/workflow/create-webhook) for all available options. </Callout> ### Creating your first webhook Here's a webhook that receives HTTP requests. Like hooks, webhooks support the `using` keyword for automatic cleanup: ```typescript lineNumbers import { createWebhook } from "workflow"; export async function webhookWorkflow() { "use workflow"; using webhook = createWebhook(); // The webhook is automatically available at this URL console.log("Send HTTP requests to:", webhook.url); // Example: https://your-app.com/.well-known/workflow/v1/webhook/lJHkuMdQ2FxSFTbUMU84k // Workflow pauses until an HTTP request is received const request = await webhook; console.log("Received request:", request.method, request.url); const data = await request.json(); console.log("Data:", data); } ``` The webhook will automatically respond with a `202 Accepted` status by default. External systems can make an HTTP request to the `webhook.url` to resume your workflow. ### Sending custom responses Webhooks provide two ways to send custom HTTP responses: **static responses** and **dynamic responses**. #### Static responses Use the `respondWith` option to provide a static response that will be sent automatically for every request: ```typescript lineNumbers import { createWebhook } from "workflow"; export async function webhookWithStaticResponse() { "use workflow"; using webhook = createWebhook({ respondWith: Response.json({ success: true, message: "Webhook received" }), }); const request = await webhook; // The response was already sent automatically const data = await request.json(); await processData(data); } async function processData(data: any) { "use step"; // Long-running processing here } ``` #### Dynamic responses (manual mode) For dynamic responses based on the request content, set `respondWith: "manual"` and call the `respondWith()` method on the request: ```typescript lineNumbers import { createWebhook, type RequestWithResponse } from "workflow"; async function sendCustomResponse(request: RequestWithResponse, message: string) { "use step"; // Call respondWith() to send the response await request.respondWith( new Response( JSON.stringify({ message }), { status: 200, headers: { "Content-Type": "application/json" } } ) ); } export async function webhookWithDynamicResponse() { "use workflow"; // Set respondWith to "manual" to handle responses yourself using webhook = createWebhook({ respondWith: "manual" }); const request = await webhook; const data = await request.json(); // Decide what response to send based on the data if (data.type === "urgent") { await sendCustomResponse(request, "Processing urgently"); } else { await sendCustomResponse(request, "Processing normally"); } // Continue workflow... } ``` <Callout type="warning"> When using `respondWith: "manual"`, the `respondWith()` method **must** be called from within a step function due to serialization requirements. This requirement may be removed in the future. </Callout> ### Handling multiple webhook requests Like hooks, webhooks support iteration: ```typescript lineNumbers import { createWebhook, type RequestWithResponse } from "workflow"; async function sendAck(request: RequestWithResponse, message: string) { "use step"; await request.respondWith( Response.json({ received: true, message }) ); } async function processEvent(data: any) { "use step"; console.log("Processing event:", data); } export async function eventCollectorWorkflow() { "use workflow"; using webhook = createWebhook({ respondWith: "manual" }); console.log("Send events to:", webhook.url); for await (const request of webhook) { const data = await request.json(); if (data.type === "done") { await sendAck(request, "Workflow complete"); break; } await sendAck(request, "Event received"); await processEvent(data); } } ``` ## Hooks vs. webhooks: when to use each | Feature | Hooks | Webhooks | |---------|-------|----------| | **Data Format** | Arbitrary serializable data | HTTP `Request` objects | | **URL** | No automatic URL | Automatic `webhook.url` property | | **Response Handling** | N/A | Can send HTTP `Response` (static or dynamic) | | **Use Case** | Custom integrations, type-safe payloads | HTTP webhooks, standard REST APIs | | **Resuming** | [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) | Automatic via HTTP, or [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) | **Use hooks when:** - You need full control over the payload structure - You're integrating with custom event sources - You want strong TypeScript typing with [`defineHook()`](/docs/api-reference/workflow/define-hook) **Use webhooks when:** - You're receiving HTTP requests from external services - You need to send HTTP responses back to the caller - You want automatic URL routing without writing API handlers ## Security A hook token tells the runtime which hook a payload belongs to. It is not an authentication mechanism, and neither `resumeHook()` nor the webhook endpoint checks who is sending the payload. ### Generated tokens are hard to guess, not secret When you don't pass a `token`, the SDK generates one inside the workflow function. Workflow code must produce the same values on every replay, so the generated token comes from the run's deterministic random number generator, the same one that backs [`Math.random()` and `crypto.randomUUID()`](/docs/api-reference/workflow-globals) in workflow functions. That generator is seeded from identifiers of the run, including the run ID, not from a secret key. A generated token is hard to guess without knowing the run, but the values it is derived from are not designed to be kept secret. Treat a generated token like an unlisted link, not like a credential. Custom tokens passed to `createHook({ token })` are usually built from domain data such as an order ID, so they are even easier to reconstruct. That is what makes them useful for routing, and it is why the route that resumes them must do its own authorization. If you can not perform your own authorization on the route that calls `resume` for any reason, and need to generate an unguessable token instead, generate it in a step where `crypto` is not seeded and pass it as `token`: ```typescript lineNumbers import { createHook } from "workflow"; async function generateToken() { "use step"; // Steps run outside the workflow sandbox, so this uses the platform's // cryptographic random source instead of the run's seed. return crypto.randomUUID(); } export async function approvalWorkflow() { "use workflow"; const token = await generateToken(); // [!code highlight] using hook = createHook<{ approved: boolean }>({ token }); // [!code highlight] return (await hook).approved; } ``` The step result is recorded in the run's event log like any other step result. See [Encryption](/docs/how-it-works/encryption) to keep it encrypted at rest. ### Webhook URLs [`createWebhook()`](/docs/api-reference/workflow/create-webhook) serves a public route at `/.well-known/workflow/v1/webhook/:token`, and matching the token is the only check it performs. Anyone who has the URL, or can compute its token, can resume the workflow with a request of their choosing. That is fine for low-stakes callbacks and prototypes. When a webhook request triggers something consequential, either: - Verify each request before acting on it, for example by checking the provider's HMAC signature in a step with [`respondWith: "manual"`](#dynamic-responses-manual-mode), and keep waiting for the next request when verification fails. - Use [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own route and authorize the caller before calling [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook), as shown below. ### Authorize before calling `resumeHook()` `resumeHook()` delivers the payload to whichever hook owns the token. The route that calls it has to authenticate the caller and check that they are allowed to resume that specific hook. Knowing the token is not proof of either. One way is to record who may resume the hook in its `metadata`, then compare it to the signed-in user: ```typescript lineNumbers import { createHook } from "workflow"; export async function expenseWorkflow(approverId: string) { "use workflow"; using hook = createHook<{ approved: boolean }>({ metadata: { approverId }, // [!code highlight] }); return (await hook).approved; } ``` ```typescript lineNumbers import { getHookByToken, resumeHook } from "workflow/api"; declare function getSession(request: Request): Promise<{ userId: string } | null>; // @setup export async function POST(request: Request) { const session = await getSession(request); // [!code highlight] if (!session) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } const { token, approved } = await request.json(); const hook = await getHookByToken(token); const metadata = (await hook.metadata) as { approverId?: string } | undefined; if (metadata?.approverId !== session.userId) { // [!code highlight] return Response.json({ error: "Forbidden" }, { status: 403 }); } await resumeHook(token, { approved }); return Response.json({ success: true }); } ``` Take the user identity from your authentication layer, never from the request body. Other examples in these docs omit authorization to stay short. ### Randomness in workflow functions The same determinism applies to your own code. `Math.random()`, `crypto.randomUUID()`, and `crypto.getRandomValues()` in a workflow function return values derived from the run's seed, so they are predictable to anyone who knows it. Don't use them for secrets, passwords, one-time codes, or any value that must be unguessable. Generate those in a step, as in the token example above. ## Advanced patterns ### Type-safe hooks with `defineHook()` The [`defineHook()`](/docs/api-reference/workflow/define-hook) helper provides type safety and runtime validation between creating and resuming hooks using [Standard Schema v1](https://standardschema.dev). Use any compliant validator like Zod or Valibot: ```typescript lineNumbers import { defineHook } from "workflow"; import { z } from "zod"; // Define the hook with schema for type safety and runtime validation const approvalHook = defineHook({ // [!code highlight] schema: z.object({ // [!code highlight] requestId: z.string(), // [!code highlight] approved: z.boolean(), // [!code highlight] approvedBy: z.string(), // [!code highlight] comment: z.string().transform((value) => value.trim()), // [!code highlight] }), // [!code highlight] }); // [!code highlight] // In your workflow export async function documentApprovalWorkflow(documentId: string) { "use workflow"; using hook = approvalHook.create({ token: `approval:${documentId}` }); // Payload is type-safe and validated const approval = await hook; console.log(`Document ${approval.requestId} ${approval.approved ? "approved" : "rejected"}`); console.log(`By: ${approval.approvedBy}, Comment: ${approval.comment}`); } // In your API route - both type-safe and runtime-validated! export async function POST(request: Request) { const { documentId, ...approvalData } = await request.json(); try { // The schema validates the payload before resuming the workflow await approvalHook.resume(`approval:${documentId}`, approvalData); return new Response("OK"); } catch (error) { return Response.json({ error: "Invalid token or validation failed" }, { status: 400 }); } } ``` This pattern is especially valuable in larger applications where the workflow and API code are in separate files, providing both compile-time type safety and runtime validation. ## Best practices ### Token design Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always generate their own unique tokens. Neither kind of token authorizes the sender; see [Security](#security). When using custom tokens with `createHook()`: - **Make them deterministic**: Base them on data the external system can reconstruct, such as channel IDs or user IDs. - **Use namespacing**: Prefix tokens to avoid conflicts, such as `slack:${channelId}` or `github:${repoId}`. - **Include routing information**: Ensure the token contains enough information to identify the correct workflow instance. ### Response handling in webhooks - Use **static responses** (`respondWith: Response`) for acknowledgments - Use **manual mode** (`respondWith: "manual"`) when responses depend on request processing - Remember that `respondWith()` must be called from within a step function ### Iterating over events Both hooks and webhooks support iteration, making them perfect for long-running event loops: {/* @skip-typecheck: incomplete code sample */} ```typescript using hook = createHook<Event>(); for await (const event of hook) { await processEvent(event); if (shouldStop(event)) { break; } } ``` This pattern allows a single workflow instance to handle multiple events over time, maintaining state between events. ## Related documentation - [Serialization](/docs/foundations/serialization) - Understanding what data can be passed through hooks - [`createHook()` API Reference](/docs/api-reference/workflow/create-hook) - [`createWebhook()` API Reference](/docs/api-reference/workflow/create-webhook) - [`defineHook()` API Reference](/docs/api-reference/workflow/define-hook) - [`resumeHook()` API Reference](/docs/api-reference/workflow-api/resume-hook) - [`resumeWebhook()` API Reference](/docs/api-reference/workflow-api/resume-webhook)