Durable Functions — Human Approval Pattern
WaitForExternalEvent, Timeout, External Signals
Introduction
Human workflows are essential in many business processes where automated decisions aren't possible or where human judgment is required. Azure Durable Functions provide the WaitForExternalEvent pattern which pauses an orchestration until an external signal is received, making it perfect for approval workflows, escalation processes, and manual intervention scenarios.
This pattern enables orchestrations to:
- Pause and wait for human decisions
- Handle timeouts for overdue approvals
- Resume processing after approval or rejection
- Escalate to supervisors if no response is received
Basic Human Approval Workflow
The Orchestrator
[FunctionName("ExpenseApprovalWorkflow")]
public static async Task<ApprovalResult> ExpenseApprovalWorkflow(
[OrchestrationTrigger] IDurableOrchestrationContext context)
{
// Get the expense request
var expenseRequest = context.GetInput<ExpenseRequest>();
// Log the start of workflow
context.SetCustomStatus(new
{
Status = "Awaiting Approval",
RequestId = expenseRequest.RequestId,
Amount = expenseRequest.Amount,
SubmittedBy = expenseRequest.SubmittedBy
});
// Create a timeout - 48 hours to approve
var approvalDeadline = context.CurrentUtcDateTime.AddHours(48);
// Wait for external event (approval or rejection)
// This is the key: the orchestration pauses here until event is received
var approvalEvent = await context.WaitForExternalEvent<ApprovalEvent>(
"ApprovalEvent",
approvalDeadline); // Timeout deadline
// Check if we received approval or if it timed out
if (approvalEvent == null)
{
// Timeout occurred - auto-reject or escalate
context.SetCustomStatus(new { Status = "Timed Out" });
await context.CallActivityAsync("NotifyRequestorOfTimeout",
expenseRequest.RequestId);
return new ApprovalResult
{
Approved = false,
Reason = "Approval timeout - 48 hours elapsed",
WasTimeout = true
};
}
// Process the approval decision
if (approvalEvent.IsApproved)
{
context.SetCustomStatus(new { Status = "Approved" });
// Execute the approved action
await context.CallActivityAsync("ProcessApprovedExpense",
expenseRequest);
// Notify the requestor
await context.CallActivityAsync("SendApprovalNotification",
new NotificationRequest
{
RequestId = expenseRequest.RequestId,
Message = $"Your expense request for ${expenseRequest.Amount} has been approved!"
});
return new ApprovalResult
{
Approved = true,
ApprovedBy = approvalEvent.ApprovedBy,
ApprovalDate = approvalEvent.ApprovalDate
};
}
else
{
context.SetCustomStatus(new { Status = "Rejected" });
// Handle rejection
await context.CallActivityAsync("NotifyRequestorOfRejection",
new RejectionNotification
{
RequestId = expenseRequest.RequestId,
Reason = approvalEvent.RejectionReason,
RejectedBy = approvalEvent.ApprovedBy
});
return new ApprovalResult
{
Approved = false,
Reason = approvalEvent.RejectionReason
};
}
}
Activity: Notify Requestor of Timeout
[FunctionName("NotifyRequestorOfTimeout")]
public static async Task NotifyRequestorOfTimeout(
[ActivityTrigger] string requestId,
ILogger log)
{
log.LogInformation("Sending timeout notification for request {RequestId}", requestId);
var request = await _expenseRepository.GetByIdAsync(requestId);
var email = new EmailMessage
{
To = request.SubmittedByEmail,
Subject = "Expense Request - Approval Required",
Body = $@"
<p>Your expense request #{request.RequestId} for ${request.Amount} has not been reviewed within 48 hours.</p>
<p>Please contact your manager or resubmit the request.</p>
"
};
await _emailService.SendAsync(email);
}
Triggering Events from External Sources
HTTP Trigger to Approve/Reject
[FunctionName("ApproveExpense HttpTrigger")]
public static async Task<IActionResult> ApproveExpense(
[HttpTrigger] HttpRequest req,
[DurableClient] IDurableOrchestrationClient client)
{
// Get the instance ID and approval details from request
var requestBody = await req.ReadAsStringAsync();
var approvalData = JsonSerializer.Deserialize<ApprovalEvent>(requestBody);
// Raise the event to the waiting orchestration
await client.RaiseEventAsync(
approvalData.InstanceId,
"ApprovalEvent", // Must match the event name in WaitForExternalEvent
new ApprovalEvent
{
IsApproved = approvalData.IsApproved,
ApprovedBy = approvalData.ApprovedBy,
ApprovalDate = DateTime.UtcNow,
RejectionReason = approvalData.RejectionReason
});
return new OkObjectResult(new
{
Message = $"Approval event raised for instance {approvalData.InstanceId}"
});
}
API Request Body Example
{
"instanceId": "expense-12345-abcde",
"isApproved": true,
"approvedBy": "manager@company.com",
"rejectionReason": null
}
API for Rejection
{
"instanceId": "expense-12345-abcde",
"isApproved": false,
"approvedBy": "manager@company.com",
"rejectionReason": "Insufficient documentation - please provide receipts"
}
Multiple Approval Stages
Some workflows require multiple levels of approval:
[FunctionName("MultiLevelApprovalWorkflow")]
public static async Task<ApprovalResult> MultiLevelApprovalWorkflow(
[OrchestrationTrigger] IDurableOrchestrationContext context)
{
var request = context.GetInput<ExpenseRequest>();
// Level 1: Manager Approval
context.SetCustomStatus(new { Stage = "Manager Approval" });
var managerApproval = await context.WaitForExternalEvent<ApprovalEvent>(
"ManagerApprovalEvent",
context.CurrentUtcDateTime.AddHours(24));
if (managerApproval == null || !managerApproval.IsApproved)
{
return new ApprovalResult { Approved = false, Stage = "Manager" };
}
// Level 2: Finance Approval (for amounts over $1000)
if (request.Amount > 1000)
{
context.SetCustomStatus(new { Stage = "Finance Approval" });
var financeApproval = await context.WaitForExternalEvent<ApprovalEvent>(
"FinanceApprovalEvent",
context.CurrentUtcDateTime.AddHours(48));
if (financeApproval == null || !financeApproval.IsApproved)
{
return new ApprovalResult { Approved = false, Stage = "Finance" };
}
}
// Level 3: Executive Approval (for amounts over $10000)
if (request.Amount > 10000)
{
context.SetCustomStatus(new { Stage = "Executive Approval" });
var executiveApproval = await context.WaitForExternalEvent<ApprovalEvent>(
"ExecutiveApprovalEvent",
context.CurrentUtcDateTime.AddHours(72));
if (executiveApproval == null || !executiveApproval.IsApproved)
{
return new ApprovalResult { Approved = false, Stage = "Executive" };
}
}
// All approvals received - process the expense
await context.CallActivityAsync("ProcessApprovedExpense", request);
return new ApprovalResult { Approved = true };
}
Timeout with Escalation Pattern
Implement automatic escalation when approval is not received in time:
[FunctionName("ApprovalWithEscalation")]
public static async Task<ApprovalResult> ApprovalWithEscalation(
[OrchestrationTrigger] IDurableOrchestrationContext context)
{
var request = context.GetInput<ApprovalRequest>();
var deadline = context.CurrentUtcDateTime.AddHours(24);
// Wait for approval with a timer
var approvalTask = context.WaitForExternalEvent<ApprovalEvent>("ApprovalEvent");
var timerTask = context.CreateTimer(deadline, CancellationToken.None);
// Wait for whichever completes first
var winner = await Task.WhenAny(approvalTask, timerTask);
if (winner == approvalTask)
{
var approval = await approvalTask;
if (approval.IsApproved)
{
await context.CallActivityAsync("ProcessApproval", request);
return new ApprovalResult { Approved = true };
}
return new ApprovalResult { Approved = false };
}
// Timer fired - approval timed out
context.SetCustomStatus(new { Status = "Timed Out - Escalating" });
// Escalate to the next level
var escalatedRequest = new EscalationRequest
{
OriginalRequest = request,
OriginalDeadline = deadline,
EscalationLevel = request.EscalationLevel + 1,
TimeOfEscalation = DateTime.UtcNow
};
await context.CallActivityAsync("EscalateToManager", escalatedRequest);
// Wait for escalated approval
var escalatedApproval = await context.WaitForExternalEvent<ApprovalEvent>(
"EscalatedApprovalEvent",
context.CurrentUtcDateTime.AddHours(12));
if (escalatedApproval?.IsApproved == true)
{
await context.CallActivityAsync("ProcessApproval", request);
return new ApprovalResult { Approved = true, WasEscalated = true };
}
return new ApprovalResult { Approved = false, WasEscalated = true };
}
Escalation Activity
[FunctionName("EscalateToManager")]
public static async Task EscalateToManager(
[ActivityTrigger] EscalationRequest request,
ILogger log)
{
log.LogWarning("Escalating request {RequestId} to level {Level}",
request.OriginalRequest.RequestId, request.EscalationLevel);
// Determine who to escalate to
var escalateTo = request.EscalationLevel switch
{
1 => "department-manager@company.com",
2 => "director@company.com",
_ => "vp@company.com"
};
var email = new EmailMessage
{
To = escalateTo,
Subject = $"URGENT: Approval Required - ${request.OriginalRequest.Amount}",
Body = $@"
<h2>Approval Required</h2>
<p>Request #{request.OriginalRequest.RequestId} requires your approval.</p>
<ul>
<li>Amount: ${request.OriginalRequest.Amount}</li>
<li>Submitted by: {request.OriginalRequest.SubmittedBy}</li>
<li>Original deadline: {request.OriginalDeadline}</li>
<li>Escalated at: {request.TimeOfEscalation}</li>
</ul>
<p>Please approve or reject within 12 hours.</p>
"
};
await _emailService.SendAsync(email);
}
Handling Multiple Event Types
A single orchestration can wait for different types of events:
[FunctionName("OrderFulfillmentWorkflow")]
public static async Task<FulfillmentResult> OrderFulfillmentWorkflow(
[OrchestrationTrigger] IDurableOrchestrationContext context)
{
var order = context.GetInput<Order>();
context.SetCustomStatus(new { Status = "Initiating Fulfillment" });
// Start the fulfillment process
await context.CallActivityAsync("InitiateFulfillment", order);
// Wait for either completion or cancellation
var completionTask = context.WaitForExternalEvent<OrderCompletedEvent>("OrderCompleted");
var cancellationTask = context.WaitForExternalEvent<OrderCancelledEvent>("OrderCancelled");
var timeoutTask = context.CreateTimer(
context.CurrentUtcDateTime.AddDays(7),
CancellationToken.None);
var completedTask = await Task.WhenAny(
Task.WhenAny(completionTask, cancellationTask),
timeoutTask);
if (completedTask == timeoutTask)
{
// Fulfillment timed out
await context.CallActivityAsync("HandleFulfillmentTimeout", order.OrderId);
return new FulfillmentResult { Status = "Timeout" };
}
var result = await Task.WhenAny(completionTask, cancellationTask);
if (result == completionTask && completionTask.Result != null)
{
await context.CallActivityAsync("CompleteOrderFulfillment", order);
return new FulfillmentResult { Status = "Completed" };
}
// Order was cancelled
await context.CallActivityAsync("CancelFulfillment", order);
return new FulfillmentResult { Status = "Cancelled" };
}
Polling Pattern for Long-Running External Processes
When external systems don't support webhooks, use polling:
[FunctionName("ExternalProcessApproval")]
public static async Task<ApprovalResult> ExternalProcessApproval(
[OrchestrationTrigger] IDurableOrchestrationContext context)
{
var request = context.GetInput<ApprovalRequest>();
// Start external approval process
var processId = await context.CallActivityAsync<string>(
"StartExternalApprovalProcess", request);
// Poll for completion every 30 seconds
var deadline = context.CurrentUtcDateTime.AddHours(2);
while (context.CurrentUtcDateTime < deadline)
{
var status = await context.CallActivityAsync<ExternalProcessStatus>(
"CheckExternalProcessStatus", processId);
if (status.IsCompleted)
{
return new ApprovalResult
{
Approved = status.Outcome == "Approved",
Result = status.Outcome
};
}
// Wait 30 seconds before next poll
await context.CreateTimer(
context.CurrentUtcDateTime.AddSeconds(30),
CancellationToken.None);
}
return new ApprovalResult { Approved = false, Reason = "External process timeout" };
}
Best Practices for Human Workflows
| Practice | Description |
|---|---|
| Set reasonable timeouts | Don't wait indefinitely - set clear deadlines |
| Provide escalation paths | Auto-escalate when timeouts occur |
| Send reminders | Use timer-triggered notifications before deadline |
| Store all decisions | Log every approval/rejection for audit |
| Use correlation IDs | Track the request through all stages |
| Return to known state | Ensure orchestrations can resume after failures |
Real-World Use Cases
- Expense Reports - Multi-level approval based on amount
- Purchase Orders - Manager, Finance, Executive approval chain
- Leave Requests - HR approval with escalation
- Invoice Processing - Finance team approval for payments
- Risk Assessments - Compliance team sign-off
Implementation Checklist
- Define approval events (approval, rejection, escalation)
- Implement timeout handlers
- Create notification activities (reminders, escalations)
- Set up HTTP endpoints to trigger events
- Add audit logging for all decisions
- Test edge cases (timeout during approval, duplicate events)
Azure Integration Hub - Advanced Level Durable Functions Series - Part 2 of 3