Skip to content

Human-in-the-Loop Approval in a Step Functions Workflow

9 min read · updated August 11, 2026

A model drafts something that a person must approve before it is sent. The workflow has to stop, possibly for days, with no polling loop, no idle compute, and no way for the pause itself to fail silently.

What waitForTaskToken does

Step Functions has three service integration patterns, selected by what you append to the Resource ARN. Bare means request/response: call the service, move on. .sync means run a job and wait for it to finish. .waitForTaskToken means something else again — Step Functions generates a token, hands it to the integration as part of the payload, and then suspends the state indefinitely until somebody calls back with that token.

The suspension is genuinely free. There is no process waiting, no container held, and no cost accruing beyond the execution’s own storage. AWS documents that a task waiting on a token will wait until the execution reaches the one-year service quota, which is both the feature and the hazard: an approval nobody ever gives is an execution that stays open for a year unless you bound it.

One restriction shapes the whole design. AWS documents that Express Workflows support only the request/response pattern — both .sync and .waitForTaskToken are Standard-only. If the approval step is being retrofitted into an Express workflow, the workflow type has to change, which changes its billing model, its execution history and its at-most-once versus at-least-once semantics. Better to split: keep the fast part Express and start a Standard child workflow for the part that waits.

The task state

The token is available through the context object at $$.Task.Token, and it is your job to put it somewhere the approver’s system will send back. Any integration with a field to carry it will do — SQS, SNS, EventBridge, Lambda, and any of the AWS SDK integrations that have a suitable parameter:

"AwaitHumanApproval": {
  "Type": "Task",
  "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken",
  "Parameters": {
    "QueueUrl": "https://sqs.us-east-1.amazonaws.com/111122223333/approvals",
    "MessageBody": {
      "draftId.$": "$.draftId",
      "documentUri.$": "$.documentUri",
      "modelUsed.$": "$.modelUsed",
      "executionId.$": "$$.Execution.Id",
      "taskToken.$": "$$.Task.Token"
    }
  },
  "HeartbeatSeconds": 259200,
  "ResultPath": "$.approval",
  "Catch": [
    {
      "ErrorEquals": ["States.Timeout"],
      "ResultPath": "$.timeoutInfo",
      "Next": "EscalateUnapproved"
    }
  ],
  "Next": "PublishApprovedDraft"
}

The .$ suffix on a parameter name is what tells Step Functions the value is a path rather than a literal. A single $ at the start of the path reads from the state input; $$ reads from the context object. Getting this wrong is a quiet failure: without the suffix, the approver receives the literal string $$.Task.Token and the workflow waits for a callback that can never match.

Include enough in the message for the approver to decide without a second lookup. A token and a database id means the approval UI has to fetch the draft; a token and the draft’s location means it does not. Include the model that produced it, too — the person signing off has a legitimate interest in whether this came from the cheap fallback.

Returning the decision

The approving system resumes the workflow with one of two API calls, neither of which needs to know anything about the state machine:

# Approved
aws stepfunctions send-task-success \
  --task-token "$TOKEN" \
  --task-output '{"decision":"approved","approver":"[email protected]","at":"2026-08-11T09:15:00Z"}'

# Rejected — a decision, not an error, but routed as one
aws stepfunctions send-task-failure \
  --task-token "$TOKEN" \
  --error "ApprovalRejected" \
  --cause "Cites a source that does not exist"

Whether rejection should be SendTaskFailure or a SendTaskSuccess carrying a rejected verdict is a real design choice. Failure is cleaner if rejection ends the workflow, because the error name you pass becomes catchable by name in a Catch block. Success with a verdict field is better if rejection feeds back into a revision loop, because you keep the reviewer’s comments in the state output where the next model call can read them — which is usually what an editing workflow actually wants.

The output of SendTaskSuccess lands wherever ResultPath says. In the state above that is $.approval, so the input is preserved and the decision is added beside it rather than replacing it. Omitting ResultPath in a JSONPath workflow defaults it to $, which overwrites the entire state input with the approval payload — and every downstream reference to the draft breaks.

Bounding the wait

Left alone, the state waits up to a year. HeartbeatSeconds is the documented bound: if no SendTaskSuccess, SendTaskFailure or SendTaskHeartbeat arrives within that window, the task fails with the error name States.Timeout, which the Catch block above routes to an escalation branch.

The three-day value in the example is a choice about your process, not about the service. Pick it from how long an approval realistically takes plus a weekend, and treat the timeout branch as a real path — page a rota, reassign, or expire the draft. The alternative, SendTaskHeartbeat, exists for the case where the approver is a system that can prove it is still working: it resets the clock without resolving the task, so a long-running external review can hold the door open while a dead one still falls through.

One subtlety in AWS’s documentation is easy to miss and will break an approval queue: if a task using a callback token times out and is retried, a new random token is generated. Any token you stored against a pending approval is now stale, and a callback with it will fail. Store the token keyed on the execution ARN rather than treating it as a permanent identifier for the approval, and re-emit the new one when the retry fires.

Handling the token with care

The callback identifies the task by the token itself. There is no state machine ARN in the call and no per-approval resource to attach a policy to — possession of the string is what authorises the resumption. That makes it a bearer credential, and it should be handled like one:

  • Do not put it in a URL. An approval link with the token in the query string ends up in browser history, in referrer headers and in your access logs. Put the token server-side against a short opaque approval id, and let the link carry the id.
  • Authorise the human separately. Step Functions checks the token, not the person. Whether [email protected] is allowed to approve this particular draft is a decision your approval service makes before it calls SendTaskSuccess, and nothing downstream will make it for you.
  • Cross-account callbacks do not work. AWS documents that task tokens must be sent from principals within the same AWS account. A shared approval tool in a separate account needs to assume a role in the workflow’s account to make the call.
  • Record the decision outside the execution. Standard workflow execution history is retained, but it is not an audit system, and it is not queryable the way a table is. If “who approved what, when” is a compliance question, write it down where you can answer it.

The failure branches around this state deserve the same care as the happy path — retries and error handling for model calls covers the retrier fields the rest of the workflow will need.