Table of Contents

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 parked WaitForSignal steps 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 with WaitForSignal step requires a non-empty 'signalName' input.
  • timeoutSeconds (optional) — absolute deadline. When elapsed without delivery, the step transitions to Failed with a descriptive reason. null or 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:

  1. Wrap the dashboard route group in your standard auth middleware (UseAuthentication() / UseAuthorization() before MapFlowDashboard()).
  2. 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 IFlowRunRuntimeStore registered (custom storage predating the interface): also immediate. The engine guards its claim acquisition with the same is not null check, 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.started on every invocation (initial park, signal arrival, timeout fire).
  • step.pending after the first invocation parks the step.
  • step.completed on successful delivery.
  • step.failed on 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.