Intermediate
You’ve automated your deployment pipeline, and it’s humming along — until someone says, “We need a manager to approve production deployments before they go through.” Suddenly, you need your state machine to pause, wait for a human to click a button, and then resume execution. This is exactly what AWS Step Functions’ callback pattern with task tokens was designed for.
This article walks you through building a production-ready human approval workflow using Step Functions wait states, the .waitForTaskToken callback pattern, and task tokens. We’ll wire up Lambda, SNS, and API Gateway to create a complete approve/reject flow that you can drop into any pipeline.
Who should read this: DevOps engineers, backend developers, and cloud architects who are familiar with AWS Step Functions basics and want to implement asynchronous human-in-the-loop workflows. You should be comfortable with the AWS Console, AWS CLI, and have working knowledge of Lambda and IAM.
Prerequisites
- An AWS account with permissions to create Step Functions, Lambda functions, SNS topics, API Gateway endpoints, and IAM roles
- AWS CLI v2 installed and configured (
aws configure) - Python 3.9+ (for Lambda function code)
- Basic understanding of Amazon States Language (ASL) — the JSON-based language that defines Step Functions state machines
- Node.js or Python environment for local testing (optional)
Understanding Wait States vs. Callback Patterns
Before we build anything, let’s clarify two distinct mechanisms in Step Functions that people often confuse:
Wait States
A Wait state pauses execution for a fixed duration or until a specific timestamp. It’s purely time-based — no external signal is involved.
{
"WaitForTimer": {
"Type": "Wait",
"Seconds": 3600,
"Next": "NextState"
}
}
You can also wait until an absolute timestamp:
{
"WaitUntilDeadline": {
"Type": "Wait",
"TimestampPath": "$.approvalDeadline",
"Next": "NextState"
}
}
Wait states are useful for rate limiting, scheduling delays, or waiting for eventual consistency — but they cannot respond to external events.
Callback Pattern with Task Tokens (.waitForTaskToken)
The callback pattern pauses execution at a Task state and generates a unique task token. The state machine stays paused indefinitely (or until a configured timeout) until an external process calls SendTaskSuccess or SendTaskFailure with that token. This is the mechanism we need for human approvals.
The key integration pattern uses the .waitForTaskToken suffix on the resource ARN:
{
"WaitForApproval": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
"Parameters": {
"FunctionName": "SendApprovalRequest",
"Payload": {
"taskToken.$": "$$.Task.Token",
"executionId.$": "$$.Execution.Id"
}
},
"TimeoutSeconds": 86400,
"Next": "ApprovedState"
}
}
Notice $$.Task.Token — this is a context object reference. The double dollar sign ($$) accesses Step Functions context data (execution ID, state name, task token, etc.), as opposed to the single $ which references the state input.
Architecture Overview: The Complete Approval Workflow
Here’s what we’re building end-to-end:
# Flow:
# 1. State machine starts (triggered by CodePipeline, EventBridge, etc.)
# 2. Lambda sends approval email via SNS with approve/reject links
# 3. State machine PAUSES (waitForTaskToken)
# 4. Human clicks approve/reject link → hits API Gateway
# 5. API Gateway triggers Lambda that calls SendTaskSuccess or SendTaskFailure
# 6. State machine RESUMES and proceeds accordingly
The AWS services involved:
| Service | Role |
| AWS Step Functions | Orchestrates the workflow, pauses for approval |
| AWS Lambda (x2) | Sends approval request; processes approval response |
| Amazon SNS | Delivers email notification to approvers |
| Amazon API Gateway | Exposes HTTPS endpoints for approve/reject actions |
| AWS IAM | Least-privilege permissions for all components |
Step 1: Create the SNS Topic and Subscription
First, create an SNS topic for approval notifications:
# Create the SNS topic
aws sns create-topic --name deployment-approval-requests
# Note the TopicArn from the output, e.g.:
# arn:aws:sns:us-east-1:123456789012:deployment-approval-requests
# Subscribe an approver's email
aws sns subscribe \
--topic-arn arn:aws:sns:us-east-1:123456789012:deployment-approval-requests \
--protocol email \
--notification-endpoint approver@yourcompany.com
The subscriber will receive a confirmation email — they must click the confirmation link before they’ll receive notifications.
Step 2: Build the Lambda Functions
Lambda 1: Send Approval Request
This function is invoked by Step Functions. It receives the task token, constructs approve/reject URLs, and sends the notification via SNS.
"""send_approval_request.py - Lambda that sends approval email with task token"""
import json
import os
import urllib.parse
import boto3
sns_client = boto3.client('sns')
SNS_TOPIC_ARN = os.environ['SNS_TOPIC_ARN']
API_ENDPOINT = os.environ['API_ENDPOINT'] # e.g., https://abc123.execute-api.us-east-1.amazonaws.com/prod
def lambda_handler(event, context):
print(f"Received event: {json.dumps(event)}")
task_token = event['taskToken']
execution_id = event.get('executionId', 'unknown')
deployment_info = event.get('deploymentInfo', {})
# URL-encode the task token (it contains special characters)
encoded_token = urllib.parse.quote(task_token, safe='')
approve_url = f"{API_ENDPOINT}/respond?action=approve&taskToken={encoded_token}"
reject_url = f"{API_ENDPOINT}/respond?action=reject&taskToken={encoded_token}"
message = f"""A deployment requires your approval.
Execution ID: {execution_id}
Service: {deployment_info.get('service', 'N/A')}
Environment: {deployment_info.get('environment', 'N/A')}
Requested by: {deployment_info.get('requestedBy', 'N/A')}
To APPROVE this deployment, open this URL:
{approve_url}
To REJECT this deployment, open this URL:
{reject_url}
This request will expire in 24 hours.
"""
sns_client.publish(
TopicArn=SNS_TOPIC_ARN,
Subject=f"Approval Required: {deployment_info.get('service', 'Deployment')} to {deployment_info.get('environment', 'production')}",
Message=message
)
print(f"Approval request sent for execution: {execution_id}")
# IMPORTANT: Do NOT return anything that would signal completion.
# The state machine will remain paused until SendTaskSuccess/SendTaskFailure is called.
return {
'statusCode': 200,
'body': 'Approval request sent'
}
Lambda 2: Process Approval Response
This function is invoked by API Gateway when the approver clicks the link. It calls SendTaskSuccess or SendTaskFailure on the Step Functions API.
"""process_approval_response.py - Lambda that resumes Step Functions execution"""
import json
import urllib.parse
import boto3
sfn_client = boto3.client('stepfunctions')
def lambda_handler(event, context):
print(f"Received event: {json.dumps(event)}")
# Extract query string parameters from API Gateway
query_params = event.get('queryStringParameters', {})
action = query_params.get('action', '').lower()
task_token = query_params.get('taskToken', '')
# URL-decode the task token
task_token = urllib.parse.unquote(task_token)
if not task_token:
return {
'statusCode': 400,
'headers': {'Content-Type': 'text/html'},
'body': 'Error
Missing task token.
'
}
try:
if action == 'approve':
sfn_client.send_task_success(
taskToken=task_token,
output=json.dumps({
'status': 'Approved',
'approvedBy': 'human-approver',
'comment': 'Approved via email link'
})
)
response_message = "Deployment has been APPROVED. The pipeline will now continue."
elif action == 'reject':
sfn_client.send_task_failure(
taskToken=task_token,
error='DeploymentRejected',
cause='Deployment was rejected by approver via email link'
)
response_message = "Deployment has been REJECTED. The pipeline has been stopped."
else:
return {
'statusCode': 400,
'headers': {'Content-Type': 'text/html'},
'body': 'Error
Invalid action. Use approve or reject.
'
}
except sfn_client.exceptions.TaskTimedOut:
response_message = "This approval request has expired. The task timed out."
except sfn_client.exceptions.InvalidToken:
response_message = "This approval link is invalid or has already been used."
return {
'statusCode': 200,
'headers': {'Content-Type': 'text/html'},
'body': f'Done
{response_message}
'
}
Deploy both Lambda functions:
# Package and deploy the first Lambda
zip send_approval_request.zip send_approval_request.py
aws lambda create-function \
--function-name SendApprovalRequest \
--runtime python3.12 \
--handler send_approval_request.lambda_handler \
--role arn:aws:iam::123456789012:role/LambdaApprovalSendRole \
--zip-file fileb://send_approval_request.zip \
--environment "Variables={SNS_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:deployment-approval-requests,API_ENDPOINT=https://abc123.execute-api.us-east-1.amazonaws.com/prod}" \
--timeout 30
# Package and deploy the second Lambda
zip process_approval_response.zip process_approval_response.py
aws lambda create-function \
--function-name ProcessApprovalResponse \
--runtime python3.12 \
--handler process_approval_response.lambda_handler \
--role arn:aws:iam::123456789012:role/LambdaApprovalProcessRole \
--zip-file fileb://process_approval_response.zip \
--timeout 30
Step 3: Create the API Gateway Endpoint
We need a simple REST API with one GET method that triggers the ProcessApprovalResponse Lambda:
# Create the REST API
aws apigateway create-rest-api \
--name "ApprovalAPI" \
--description "API for handling deployment approval responses"
# Get the API ID and root resource ID from the output
# Then create the /respond resource and GET method
API_ID="your-api-id"
ROOT_RESOURCE_ID="your-root-resource-id"
# Create /respond resource
aws apigateway create-resource \
--rest-api-id $API_ID \
--parent-id $ROOT_RESOURCE_ID \
--path-part "respond"
# Note the resource ID from the output
RESOURCE_ID="your-resource-id"
# Create GET method
aws apigateway put-method \
--rest-api-id $API_ID \
--resource-id $RESOURCE_ID \
--http-method GET \
--authorization-type NONE
# Set up Lambda proxy integration
aws apigateway put-integration \
--rest-api-id $API_ID \
--resource-id $RESOURCE_ID \
--http-method GET \
--type AWS_PROXY \
--integration-http-method POST \
--uri "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:ProcessApprovalResponse/invocations"
# Deploy the API
aws apigateway create-deployment \
--rest-api-id $API_ID \
--stage-name prod
# Grant API Gateway permission to invoke the Lambda
aws lambda add-permission \
--function-name ProcessApprovalResponse \
--statement-id apigateway-invoke \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:us-east-1:123456789012:${API_ID}/*/GET/respond"
Step 4: Define the Step Functions State Machine
Here’s the complete state machine definition in Amazon States Language:
{
"Comment": "Human approval workflow with callback pattern",
"StartAt": "PrepareDeployment",
"States": {
"PrepareDeployment": {
"Type": "Pass",
"Comment": "Simulate deployment preparation — replace with your actual logic",
"Result": {
"status": "ready",
"artifact": "myapp-v2.3.1"
},
"ResultPath": "$.preparation",
"Next": "RequestHumanApproval"
},
"RequestHumanApproval": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
"Parameters": {
"FunctionName": "arn:aws:lambda:us-east-1: