Signals let workflows communicate at runtime. A workflow can pause and wait for
a signal, and another workflow (or your application code) can send that signal
to wake it up with data attached.
This is useful any time a workflow needs to wait for something that isnβt on a
timer: a human approval, a webhook callback, a payment confirmation, or a
coordination message from another workflow.
Basic Usage
Waiting for a Signal
Use step.waitForSignal() inside a workflow to pause until a matching signal
arrives:
import { defineWorkflow } from "openworkflow";
export const approvalWorkflow = defineWorkflow(
{ name: "approval-workflow" },
async ({ input, step }) => {
await step.run({ name: "request-approval" }, async () => {
await slack.send({
channel: "#approvals",
text: `Please approve order ${input.orderId}`,
});
});
// wait until someone sends the "approval" signal
const decision = await step.waitForSignal<{ approved: boolean }>({
signal: `approval:${input.orderId}`,
timeout: "7d",
});
if (decision?.data.approved) {
await step.run({ name: "process-order" }, async () => {
await orders.process(input.orderId);
});
}
},
);
Sending a Signal
Send a signal from another workflow using step.sendSignal():
export const reviewWorkflow = defineWorkflow(
{ name: "review-workflow" },
async ({ input, step }) => {
const verdict = await step.run({ name: "run-review" }, async () => {
return await reviewService.evaluate(input.orderId);
});
await step.sendSignal({
signal: `approval:${input.orderId}`,
data: { approved: verdict.passed },
});
},
);
Or send a signal from your application code using the client:
import { ow } from "./openworkflow/client";
// from an API route, webhook handler, etc.
await ow.sendSignal({
signal: `approval:${orderId}`,
data: { approved: true },
});
Signals are not buffered. If you send a signal before any workflow is waiting
for it, the signal is lost.
Signal Names
Signal names are arbitrary strings scoped to the backend namespace. Use
descriptive, unique names β often including an entity ID β to avoid collisions:
// Good - scoped to a specific entity
await step.waitForSignal({ signal: `payment:${invoiceId}` });
await step.waitForSignal({ signal: `approval:order:${orderId}` });
// Bad - too generic, could collide across workflows
await step.waitForSignal({ signal: "done" });
Step Names
Like other step types, signal steps need unique names within a workflow. If you
donβt provide one, OpenWorkflow uses the signal name as the step name.
// Implicit β step name defaults to signal name
await step.waitForSignal({ signal: `payment:${invoiceId}` });
// Explicit step name
await step.waitForSignal({
name: "wait-for-payment",
signal: `payment:${invoiceId}`,
});
Timeout
step.waitForSignal accepts an optional timeout. If the signal doesnβt
arrive before the timeout, the step resolves with null instead of blocking
forever.
const result = await step.waitForSignal({
signal: `approval:${orderId}`,
timeout: "24h",
});
if (result === null) {
// timed out β no signal arrived within 24 hours
await step.run({ name: "escalate" }, async () => {
await alerts.send("Approval timed out");
});
}
timeout accepts a duration string, a
number of milliseconds, or a Date.
If no timeout is specified, the wait defaults to 1 year.
Schema Validation
Validate signal payloads at receive time using any
Standard Schema compatible validator:
import { z } from "zod";
const approvalSchema = z.object({
approved: z.boolean(),
reviewedBy: z.string(),
});
const decision = await step.waitForSignal({
signal: `approval:${orderId}`,
schema: approvalSchema,
timeout: "7d",
});
// `decision` is typed as { data: { approved: boolean; reviewedBy: string } } | null
If the signal data doesnβt match the schema, the step fails permanently (no
retries) to surface the contract violation immediately.
Idempotency
When sending signals from the client, you can provide an idempotency key to
safely retry without delivering the signal twice:
await ow.sendSignal({
signal: `payment:${invoiceId}`,
data: { amount: 99.99 },
idempotencyKey: `payment-confirmed:${invoiceId}`,
});
If a signal with the same idempotency key has already been sent and delivered to
at least one waiter, the call returns the original result without re-delivering.
Fan-Out: One Signal, Many Waiters
A single sendSignal call delivers to every workflow currently waiting on
that signal name. This makes fan-out coordination straightforward:
// Multiple workflows waiting on the same signal
const workflowA = defineWorkflow({ name: "listener-a" }, async ({ step }) => {
const config = await step.waitForSignal({ signal: "config-updated" });
// handle update
});
const workflowB = defineWorkflow({ name: "listener-b" }, async ({ step }) => {
const config = await step.waitForSignal({ signal: "config-updated" });
// handle update
});
// One signal wakes both workflows
await ow.sendSignal({
signal: "config-updated",
data: { version: 42 },
});
Sending Signals from Workflows
Use step.sendSignal() inside a workflow to send signals durably. The send is
recorded as a step attempt, so it wonβt be repeated on replay:
const result = await step.sendSignal({
signal: `approval:${orderId}`,
data: { approved: true },
});
// result.workflowRunIds contains IDs of workflows that received the signal
Common Patterns
Human-in-the-Loop Approval
export const purchaseWorkflow = defineWorkflow(
{ name: "purchase" },
async ({ input, step }) => {
await step.run({ name: "send-approval-request" }, async () => {
await email.send({
to: input.managerEmail,
subject: `Approve purchase: $${input.amount}`,
body: `Click to approve: ${approvalUrl(input.purchaseId)}`,
});
});
const approval = await step.waitForSignal<{ approved: boolean }>({
signal: `purchase-approval:${input.purchaseId}`,
timeout: "3d",
});
if (!approval?.data.approved) {
await step.run({ name: "notify-rejected" }, async () => {
await email.send({
to: input.requesterEmail,
subject: "Purchase request denied",
});
});
return { status: "rejected" };
}
await step.run({ name: "process-purchase" }, async () => {
await purchasing.submit(input.purchaseId);
});
return { status: "approved" };
},
);
// in your API route handler:
app.post("/approve/:purchaseId", async (req, res) => {
await ow.sendSignal({
signal: `purchase-approval:${req.params.purchaseId}`,
data: { approved: req.body.approved },
});
res.json({ ok: true });
});
Webhook Callback
export const paymentWorkflow = defineWorkflow(
{ name: "payment" },
async ({ input, step }) => {
const checkout = await step.run({ name: "create-checkout" }, async () => {
return await stripe.checkout.sessions.create({
metadata: { workflowSignal: `payment:${input.orderId}` },
// ...
});
});
const payment = await step.waitForSignal({
signal: `payment:${input.orderId}`,
timeout: "1h",
});
if (!payment) {
return { status: "expired" };
}
await step.run({ name: "fulfill-order" }, async () => {
await orders.fulfill(input.orderId);
});
return { status: "paid" };
},
);
// in your Stripe webhook handler:
app.post("/webhooks/stripe", async (req, res) => {
const event = req.body;
if (event.type === "checkout.session.completed") {
await ow.sendSignal({
signal: event.data.object.metadata.workflowSignal,
data: { sessionId: event.data.object.id },
idempotencyKey: event.id,
});
}
res.sendStatus(200);
});
Workflow-to-Workflow Coordination
const producer = defineWorkflow(
{ name: "data-producer" },
async ({ input, step }) => {
const data = await step.run({ name: "generate-data" }, async () => {
return await heavyComputation(input);
});
await step.sendSignal({
signal: `data-ready:${input.batchId}`,
data: { recordCount: data.length },
});
},
);
const consumer = defineWorkflow(
{ name: "data-consumer" },
async ({ input, step }) => {
const notification = await step.waitForSignal({
signal: `data-ready:${input.batchId}`,
timeout: "1h",
});
if (notification) {
await step.run({ name: "process-data" }, async () => {
await processRecords(input.batchId, notification.data.recordCount);
});
}
},
);