step.waitForEvent suspends the current workflow run until a specific event is published to your workspace via rotor.send(). If no matching event arrives before the timeout, the step returns null.
Signature
const result = await step.waitForEvent<T>(
id: string,
opts: {
event: string; // event name to wait for
match?: string; // filter expression (see below)
timeout: string; // required — e.g. "1h", "7d", "30m"
}
): Promise<T | null>Returns the data field of the matching event, or null if the timeout elapses first.
Basic example
const opened = await step.waitForEvent<{ openedAt: string }>("wait-for-open", {
event: "email.opened",
timeout: "7d",
});
if (opened === null) {
// No open event within 7 days
return { status: "no-open" };
}
console.log("Email opened at:", opened.openedAt);Filtering with match
The match field is a filter expression that lets you wait for a specific event among many. It uses a CEL-like syntax operating on the incoming event's data field.
const opened = await step.waitForEvent<{ openedAt: string }>("wait-for-open", {
event: "email.opened",
match: `data.contactId == "${event.data.contactId}"`,
timeout: "7d",
});The expression is evaluated against each incoming event of the given name. The step resumes with the first event where the expression evaluates to true.
Supported operators:
| Operator | Example |
|---|---|
| Equality | data.userId == "u_123" |
| Inequality | data.status != "bounced" |
| Logical AND | data.campaignId == "c1" && data.step == "opened" |
| Logical OR | data.source == "web" || data.source == "mobile" |
| Numeric comparison | data.score >= 50 |
Always use match when you have multiple workflow runs that could each be
waiting for the same event name. Without a filter, any workflow run waiting
for email.opened will resume on the first open event for any contact.
Triggering the event
From anywhere in your application — a webhook handler, another workflow, or the SDK:
const rotor = new Rotor({ apiKey: process.env.ROTOR_API_KEY! });
await rotor.send("email.opened", {
contactId: "cid_123",
openedAt: new Date().toISOString(),
campaignId: "camp_456",
});rotor.send() fans out to all workflow runs currently suspended at a matching step.waitForEvent.
Timeout behavior
When the timeout elapses before a matching event arrives, step.waitForEvent returns null — it does not throw. Check the return value:
const reply = await step.waitForEvent<{ approved: boolean }>("wait-for-reply", {
event: "campaign.reply",
match: `data.contactId == "${contactId}"`,
timeout: "3d",
});
if (reply === null) {
// Follow up after 3 days of silence
await step.run("send-followup", () => sendFollowUp(contactId));
return { status: "followed-up" };
}Difference from step.waitForSignal
step.waitForEvent | step.waitForSignal | |
|---|---|---|
| Triggered by | rotor.send(eventName, data) | POST /v1/signals/:id/complete |
| ID collision | Multiple runs can match the same event with filters | Signal ID must be globally unique per workspace |
| On timeout | Returns null | Throws SignalTimeoutError |
| Best for | Waiting for user behaviour (opens, clicks, replies) | Approval gates, human-in-the-loop steps |
Use step.waitForEvent when you're waiting for an event that happens naturally in your system. Use step.waitForSignal when you need an explicit approval or external trigger with an unambiguous ID.
Memoization
step.waitForEvent is memoized. If the workflow retries after the event was already received and cached, the step returns the cached event data immediately without re-suspending.