WaitForSignal Step — Human-in-the-Loop Workflows
The built-in WaitForSignal step parks a flow indefinitely until an external HTTP signal is delivered. Use it for approval workflows, content moderation gates, manual QA sign-off, or any "pause until a human (or external system) says go" pattern — without burning a worker thread on a polling loop.
It is the smallest possible primitive that unlocks the entire approval workflow category: ship value in a weekend, not a quarter.
When To Use It vs. Polling
| Pattern | Use case | Mechanism |
|---|---|---|
PollableStepHandler<T> |
Wait for an external system that will eventually respond on its own (job status, file appearance, blob upload completion). | Step re-runs on a fixed cadence; you write the fetch + condition. |
WaitForSignal |
Wait for an external system or human that will push a notification when ready (manager approval, webhook from third party, async batch completion). | Step parks; an HTTP POST wakes it up. |
Rule of thumb: if you'd hit a rate limit polling once a minute, WaitForSignal is the right choice.
Manifest
["wait_for_approval"] = new StepMetadata
{
Type = "WaitForSignal",
RunAfter = new RunAfterCollection { ["submit_request"] = [StepStatus.Succeeded] },
Inputs = new Dictionary<string, object?>
{
["signalName"] = "approval",
["timeoutSeconds"] = 86400 // optional; null/omitted = wait indefinitely
}
}
Fields:
signalName(optional, defaults to"default") — the logical name addressed by the signal endpoint. Must be unique among the parkedWaitForSignalsteps in the same run, so always set it explicitly when a run has more than one waiter. Supplying an explicitly empty or whitespace value fails the step withWaitForSignal step requires a non-empty 'signalName' input.timeoutSeconds(optional) — absolute deadline. When elapsed without delivery, the step transitions toFailedwith a descriptive reason.nullor non-positive values mean "wait forever".
End-to-End Example
public sealed class ApprovalFlow : IFlowDefinition
{
public Guid Id { get; } = new("00000000-0000-0000-0000-000000000007");
public string Version => "1.0";
public FlowManifest Manifest { get; set; } = new()
{
Triggers = new FlowTriggerCollection
{
["manual"] = new TriggerMetadata { Type = TriggerType.Manual }
},
Steps = new StepCollection
{
["submit"] = new StepMetadata
{
Type = "LogMessage",
Inputs = new Dictionary<string, object?> { ["message"] = "Awaiting manager approval" }
},
["wait_for_approval"] = new StepMetadata
{
Type = "WaitForSignal",
RunAfter = new() { ["submit"] = [StepStatus.Succeeded] },
Inputs = new Dictionary<string, object?>
{
["signalName"] = "approval",
["timeoutSeconds"] = 86400
}
},
["finalize"] = new StepMetadata
{
Type = "LogMessage",
RunAfter = new() { ["wait_for_approval"] = [StepStatus.Succeeded] },
Inputs = new Dictionary<string, object?>
{
["message"] = "@steps('wait_for_approval').output.approver"
}
}
}
};
}
From the dashboard, open the run detail page and click Send Signal on the parked WaitForSignal step — the dashboard reads signalName from the step inputs and prompts for the JSON payload. Or POST directly with curl:
curl -X POST http://localhost:5000/flows/api/runs/<runId>/signals/approval \
-H "Content-Type: application/json" \
-d '{"approver":"alice@example.com","approved":true}'
Response on success:
{
"delivered": true,
"stepKey": "wait_for_approval",
"deliveredAt": "2026-05-01T14:33:21+00:00"
}
Downstream steps consume the payload via the same expression syntax used for any step output:
["message"] = "@steps('wait_for_approval').output.approver"
Delivering a Signal In-Process
HTTP is not the only path. IFlowSignalDispatcher is registered by AddFlowOrchestrator and is exactly what the dashboard endpoint calls — inject it when your application (or a test) already has the runId in hand:
public sealed class ApprovalService(IFlowSignalDispatcher signals)
{
public async Task ApproveAsync(Guid runId, string approver)
{
var result = await signals.DispatchAsync(
runId,
"approval",
$$"""{"approver":"{{approver}}","approved":true}""");
// result.Status is Delivered / NotFound / AlreadyDelivered;
// result.StepKey and result.DeliveredAt are populated on success.
}
}
The returned SignalDeliveryResult carries the same outcomes the HTTP endpoint maps to 200 / 404 / 409.
Endpoint Status Codes
| Status | Meaning |
|---|---|
200 |
Signal delivered. Body includes stepKey and deliveredAt. |
404 |
Run does not exist, or no waiter is registered for that signal name on the run. |
409 |
A signal has already been delivered for this waiter — second delivery rejected. |
400 |
Body is not valid JSON, or run is no longer in Running status. |
Timeout Patterns
Set timeoutSeconds to fail the step (and therefore the run, unless you wire a recovery branch with a downstream step that runs on [StepStatus.Failed]) after a deadline:
["wait_for_approval"] = new StepMetadata
{
Type = "WaitForSignal",
RunAfter = new() { ["submit"] = [StepStatus.Succeeded] },
Inputs = new Dictionary<string, object?>
{
["signalName"] = "approval",
["timeoutSeconds"] = 3600 // 1 hour
}
},
["escalate_to_director"] = new StepMetadata
{
Type = "NotifyDirector",
RunAfter = new() { ["wait_for_approval"] = [StepStatus.Failed] }
}
The step's FailedReason reads Signal '<name>' not received within <n>s. so it is easy to distinguish from a handler exception in the dashboard.
Multi-Signal Patterns
A run may have multiple WaitForSignal steps, each with its own signalName. They can run in parallel (no runAfter between them) or in sequence.
Both manager and finance must approve: two parallel waiters, both must succeed:
["wait_manager"] = new StepMetadata
{
Type = "WaitForSignal",
Inputs = new Dictionary<string, object?> { ["signalName"] = "manager-approval" }
},
["wait_finance"] = new StepMetadata
{
Type = "WaitForSignal",
Inputs = new Dictionary<string, object?> { ["signalName"] = "finance-approval" }
},
["finalize"] = new StepMetadata
{
Type = "Echo",
RunAfter = new()
{
["wait_manager"] = [StepStatus.Succeeded],
["wait_finance"] = [StepStatus.Succeeded]
}
}
Manager and finance each POST to their respective signal name; only when both have arrived does finalize run.
Security
The signal endpoint accepts any JSON body and is not authenticated by default. Production deployments should:
- Wrap the dashboard route group in your standard auth middleware (
UseAuthentication()/UseAuthorization()beforeMapFlowDashboard()). - Or front the dashboard with API gateway / mTLS / service-mesh policy that verifies the caller has authority to deliver signals.
The endpoint enforces only one structural check beyond your middleware: the run must be in Running status. A delivered signal cannot resurrect a cancelled or completed run.
Resume Latency
Delivering a signal does not execute the parked step inline. The endpoint persists the payload, then dispatches a resume nudge through the active runtime, and the step wakes when a worker picks that nudge up. How long that takes is a property of the runtime, not of WaitForSignal:
| Runtime | Resume latency after a successful POST |
|---|---|
InMemory (UseInMemoryRuntime()) |
Channel write, picked up immediately |
| Azure Service Bus | Message sent to the flow-steps topic with no ScheduledEnqueueTime, delivered immediately |
Hangfire (UseHangfire()) |
Queue-pickup time — provided the nudge is enqueued rather than scheduled, see below |
On the rare delayed branch described below, each runtime falls back to its deferred primitive instead: a Task.Delay before the channel write, a scheduled Service Bus message, and BackgroundJob.Schedule respectively.
Why Hangfire used to add up to 15 seconds
Hangfire routes delayed work and immediate work through different paths. BackgroundJob.Schedule(...) places the job in the Scheduled set, and DelayedJobScheduler only promotes it to a queue on its poll tick — BackgroundJobServerOptions.SchedulePollingInterval, 15 seconds by default. BackgroundJob.Enqueue(...) goes straight to the queue and skips that window entirely.
Before the fix for #188 the resume nudge always took the delayed path with a 500 ms delay, to sidestep a race: the step registers its waiter — which is what makes delivery possible at all — a few milliseconds before the engine releases its execution claim on the Pending path. A resume that lands inside that window loses TryClaimStepAsync and is dropped silently. On Hangfire that 500 ms intent became 0.5 s + (0–15 s) of real latency, which is what the issue reported.
The dispatcher now reads the step's claim state and picks the path deliberately:
- claim already released — the overwhelming majority of deliveries, where the step has been parked for seconds or longer: dispatched immediately via
EnqueueStepAsync. No scheduled-set poll. - claim still held — only the few-millisecond window above: the short delayed nudge, which is what it was there for.
- no
IFlowRunRuntimeStoreregistered (custom storage predating the interface): also immediate. The engine guards its claim acquisition with the sameis not nullcheck, so with no runtime store there is no claim to lose and the race cannot occur. - claim state unreadable (transient storage fault): the delayed path, logged at
Warning— a persistently failing claim store would otherwise silently reinstate the latency this fix removes.
The nudge is dispatched with CancellationToken.None, never the caller's token. The signal is durably persisted before the nudge, so the resume has to outlive the request that delivered it — the dashboard endpoint passes http.RequestAborted, which trips the moment the caller disconnects.
No configuration is required, and SchedulePollingInterval no longer gates signal resume. If you lowered it purely to speed up WaitForSignal, you can put it back — note that it is a global setting affecting every delayed job in your app (step retries, ForEach delays, every polling step) and costs one scheduled-set query per server per tick.
The safety net is not always prompt
If the nudge fails to dispatch (queue outage, broker error), the signal is still durably delivered and the step wakes on the safety-net invocation scheduled when it parked. That invocation is only prompt when the step declares timeoutSeconds — without one the park interval is 24 hours. Set a timeout on any WaitForSignal whose recovery you care about.
Observability
When event persistence is enabled (builder.Observability.EnableEventPersistence = true), the engine emits these events for each WaitForSignal step:
step.startedon every invocation (initial park, signal arrival, timeout fire).step.pendingafter the first invocation parks the step.step.completedon successful delivery.step.failedon timeout.
The dashboard run detail page shows the events in chronological order, plus the resolved input/output and any handler logs surfaced through IExecutionContext.
Restart Safety
Waiters live in your configured storage (FlowSignalWaiters table for SQL Server, flow_signal_waiters for PostgreSQL, ConcurrentDictionary for in-memory). A process restart does not lose pending waits: when the worker comes back up, FlowRunRecoveryHostedService restores active runs, and the next invocation of the parked step picks up the persisted waiter row. In-memory storage is lost on restart, by design.
Out of Scope
For deliberate simplicity, this primitive does not include:
- HMAC-signed signal endpoints. Use auth middleware instead.
- Broadcast multi-recipient signals. Each waiter is identified by
(RunId, StepKey); one signal targets one waiter. - Signal cancellation API. Cancel the run instead.
- Long-poll API for callers waiting for delivery confirmation.
- Persistent signal queues for signals that arrive before a step is ready to receive them.
If you find yourself needing one of these, you've outgrown the primitive — consider modeling the workflow as a separate service that triggers FlowOrchestrator, rather than driving FlowOrchestrator from the wait point.