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:

OperatorExample
Equalitydata.userId == "u_123"
Inequalitydata.status != "bounced"
Logical ANDdata.campaignId == "c1" && data.step == "opened"
Logical ORdata.source == "web" || data.source == "mobile"
Numeric comparisondata.score >= 50
Warning

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.waitForEventstep.waitForSignal
Triggered byrotor.send(eventName, data)POST /v1/signals/:id/complete
ID collisionMultiple runs can match the same event with filtersSignal ID must be globally unique per workspace
On timeoutReturns nullThrows SignalTimeoutError
Best forWaiting 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.