Overview

step.waitForSignal suspends a workflow run until a named signal arrives via POST /v1/signals/:signal_id/complete. This is the idiomatic Rotor pattern for human-in-the-loop workflows: approval gates, review queues, external integration callbacks, and any case where a workflow needs to wait for an action that originates outside the system.

Unlike step.waitForEvent — which matches against the global event stream — step.waitForSignal targets exactly one run by a signal ID you control. No fan-out, no broadcast: one signal resumes one run.

API shape

const approval = await step.waitForSignal<{ approved: boolean; reason?: string }>(
  "approval-gate",
  {
    signal: `approval-${runId}`,   // unique per run — include runId to avoid collisions
    timeout: "48h",                // REQUIRED — throws SignalTimeoutError if no POST arrives
  }
);
 
if (!approval.approved) {
  throw new Error(`Rejected: ${approval.reason}`);
}
// Full type signature:
waitForSignal<R = unknown>(
  id: string,
  opts: {
    signal: string;    // workspace-unique signal ID
    timeout: string;   // e.g. "24h", "7d" — required
  }
): Promise<R>

Return value

step.waitForSignal returns the data field from the resume POST body, typed via the <R> generic. If the POST body is { "data": { "approved": true } }, the step returns { approved: true }.

Resume mechanism

Resume a waiting run by POSTing to the signal's completion endpoint:

POST /v1/signals/:signal_id/complete
Authorization: Bearer <rt_ws_key>
Content-Type: application/json

{
  "data": { "approved": true, "reviewer": "[email protected]" }
}

Authentication

The API key must belong to the same workspace that owns the signal. Cross-workspace resume attempts receive a 404 (not a 401) to prevent signal enumeration across tenants — see the 404/401 note below.

Response codes

StatusMeaning
200Signal received; run queued for resume
404Signal not found (unknown ID or cross-workspace)
409Collision — signal_id already in use by another active waiter in this workspace
410Signal already resolved (run already resumed or timed out)
422Missing or invalid data field in body

404 on cross-workspace — not 401

Rotor returns 404 for both unknown signal IDs and cross-workspace resume attempts. Returning 401 for cross-workspace would leak signal existence (an attacker could enumerate live signal IDs by observing 401 vs 404). The 404 response keeps signal existence opaque across workspace boundaries.

Error model

Error classWhen it fires
SignalTimeoutErrortimeout elapsed before a POST was received
import { SignalTimeoutError } from "@rotor/sdk/errors";
 
try {
  const result = await step.waitForSignal("human-review", {
    signal: `review-${runId}`,
    timeout: "72h",
  });
  return { status: "approved", data: result };
} catch (err) {
  if (err.name === "SignalTimeoutError") {
    await step.run("auto-reject", () => recordAutoRejection(runId));
    return { status: "auto-rejected" };
  }
  throw err;
}

signal_id uniqueness scope

Signal IDs are unique per workspace — not per run and not globally. Two simultaneous runs in the same workspace cannot share the same signal ID. Recommended pattern:

// Use runId (unique per workflow execution) as part of the signal ID
const signal = `approval-${runId}`;
 
// If you need multiple signals per run, add a qualifier:
const signal1 = `budget-approval-${runId}`;
const signal2 = `legal-approval-${runId}`;

If a signal ID collision occurs (e.g., you accidentally reuse an ID that's already waiting), you get a 409 response from the POST endpoint, which surfaces as a run failure in the orchestrator.

Collision behavior

A 409 from the workflow_run_waiters UNIQUE constraint (on workspace_id + signal_id) surfaces as a run failure with a distinct error message. Your consumer code (the entity POSTing to the signal endpoint) should treat 409 as a bug in signal ID generation — not a retry-able transient error.

Memoization

On parent retry, step.waitForSignal does not create a duplicate waiter:

  1. If a workflow_run_waiters row already exists for (run_id, step_id), the serve adapter returns the cached result.
  2. If the signal was already completed before the retry, the cached resolved value is returned immediately.
  3. If the signal is still pending (awaiting POST), the run re-suspends on the existing waiter.

Common patterns

Approval gate

export const outreachWithApproval = createFunction(
  { id: "outreach-with-approval", trigger: { event: "outreach.requested" }, retries: 2 },
  async ({ event, step, runId }) => {
    const draft = await step.run("draft-message", async () =>
      generateDraft(event.data.contactId)
    );
 
    // Post to Slack with a link to your approval UI
    await step.run("notify-approver", async () =>
      slack.send({
        channel: "#outreach-approvals",
        text: `New outreach draft for ${event.data.contactId}`,
        actions: [
          { text: "Approve", url: `https://app.example.com/approve?signal=approval-${runId}` },
          { text: "Reject",  url: `https://app.example.com/reject?signal=approval-${runId}` },
        ],
      })
    );
 
    // Pause until the approver clicks Approve or Reject
    const { approved, reason } = await step.waitForSignal<{ approved: boolean; reason?: string }>(
      "wait-for-approval",
      { signal: `approval-${runId}`, timeout: "48h" }
    );
 
    if (!approved) {
      return { status: "rejected", reason };
    }
 
    await step.run("send-message", () => sendOutreach(draft, event.data.contactId));
    return { status: "sent" };
  }
);

Your approval UI POSTs to complete the signal:

// In your approval UI API handler:
await fetch(`https://api.rotor.sh/v1/signals/approval-${runId}/complete`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ROTOR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ data: { approved: true, reviewer: "[email protected]" } }),
});

Human review with fallback

let reviewResult: { action: "approve" | "escalate" } | null = null;
 
try {
  reviewResult = await step.waitForSignal("human-review", {
    signal: `review-${runId}`,
    timeout: "24h",
  });
} catch (err) {
  if (err.name === "SignalTimeoutError") {
    // Auto-escalate after 24h of no response
    await step.run("escalate", () => escalateToManager(runId));
    return { status: "escalated" };
  }
  throw err;
}

External integration callback

Use step.waitForSignal when an external service calls back asynchronously — e.g., a KYC provider, a document signing service, or a payment processor:

// Register your signal ID with the external service before suspending
await step.run("register-kyc-webhook", () =>
  kycProvider.startVerification({
    userId: event.data.userId,
    callbackSignalId: `kyc-${runId}`,
  })
);
 
const { verified, failureReason } = await step.waitForSignal<KycResult>(
  "wait-for-kyc",
  { signal: `kyc-${runId}`, timeout: "7d" }
);

Your KYC webhook handler then calls POST /v1/signals/kyc-${runId}/complete with the result.

Cancellation

Cancelling a workflow run that is paused on step.waitForSignal automatically deletes the waiter row — the signal ID is released and the run moves to cancelled. Any subsequent POST to that signal ID returns 404.

Limits

ConstraintValue
Uniqueness scopePer workspace (not per run) — use runId in signal ID
Collision409 at resume endpoint; surfaces as run failure
Timeout maximumNo hard platform max — use "7d" for week-long human-review flows
Resume body sizeLimited by API gateway max body (512 KB)
Cross-workspace resumeReturns 404 (not 401) — workspace boundary enforced silently