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
| Status | Meaning |
|---|---|
200 | Signal received; run queued for resume |
404 | Signal not found (unknown ID or cross-workspace) |
409 | Collision — signal_id already in use by another active waiter in this workspace |
410 | Signal already resolved (run already resumed or timed out) |
422 | Missing 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 class | When it fires |
|---|---|
SignalTimeoutError | timeout 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:
- If a
workflow_run_waitersrow already exists for(run_id, step_id), the serve adapter returns the cached result. - If the signal was already completed before the retry, the cached resolved value is returned immediately.
- 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
| Constraint | Value |
|---|---|
| Uniqueness scope | Per workspace (not per run) — use runId in signal ID |
| Collision | 409 at resume endpoint; surfaces as run failure |
| Timeout maximum | No hard platform max — use "7d" for week-long human-review flows |
| Resume body size | Limited by API gateway max body (512 KB) |
| Cross-workspace resume | Returns 404 (not 401) — workspace boundary enforced silently |