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

PracticeDescription
Set reasonable timeoutsDon't wait indefinitely - set clear deadlines
Provide escalation pathsAuto-escalate when timeouts occur
Send remindersUse timer-triggered notifications before deadline
Store all decisionsLog every approval/rejection for audit
Use correlation IDsTrack the request through all stages
Return to known stateEnsure orchestrations can resume after failures

Real-World Use Cases

  1. Expense Reports - Multi-level approval based on amount
  2. Purchase Orders - Manager, Finance, Executive approval chain
  3. Leave Requests - HR approval with escalation
  4. Invoice Processing - Finance team approval for payments
  5. 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