Standard approval actions in Power Automate send a request and wait — but they don't remind, escalate, or enforce deadlines on their own. This lesson teaches you to build a production-grade escalation system using Do Until loops, UTC timestamp comparisons, and structured Teams notifications that actually drive action before SLAs breach.

Picture this: your company's procurement policy says every purchase order over $10,000 must be approved within 24 hours. Simple enough on paper. But in practice, approvers are in back-to-back meetings, traveling across time zones, or simply swamped with email. The approval sits there, unresponded to, while the vendor's deadline passes. The business misses a discount window. Someone has to scramble. And when leadership asks why the automation didn't catch this, the answer is usually the same — "the flow sent an email and then just... waited."
That's the gap this lesson closes. A well-designed escalation flow doesn't just route an approval and hope for the best — it actively monitors the clock, fires reminder notifications at defined intervals, escalates to a fallback approver when thresholds are breached, and logs exactly what happened so you can prove SLA compliance. Building this in Power Automate requires you to combine several moving parts: Do Until loops to poll for a response, timeout expressions to calculate deadline boundaries, conditional logic to branch on outcomes, and Microsoft Teams notifications to create a real-time escalation trail that people actually see.
By the end of this lesson, you'll have built a production-grade approval escalation flow from scratch. You'll understand not just how to assemble the pieces, but why each design decision matters — and where the traps are when you try to take shortcuts.
What you'll learn:
This is an expert-level lesson. You should already be comfortable with:
You'll need a Power Automate Per User or Per Flow license (the approval connector is a premium connector), access to a SharePoint site for the approval request list, and a Teams channel where escalations will be posted.
When most Power Automate builders first tackle approvals with deadlines, they reach for the built-in timeout on the Start and wait for an approval action. You've probably seen it: expand the settings, enter a duration in ISO 8601 format like PT24H, and the action will stop waiting after 24 hours. That's real and useful.
But the built-in timeout has a critical limitation: it's binary. Either the approval comes in before the deadline, or it doesn't, and you get a single branch to handle the "timed out" case. What you cannot do with a plain timeout is:
To build any of those behaviors, you need to take explicit control of the timing loop yourself. That's where the Do Until pattern becomes your primary tool.
Key insight
The Do Until loop pattern treats time as a first-class variable. Instead of delegating timeout control to a single action, you poll on a schedule, check both the approval status and the clock, and decide what to do next. This gives you fine-grained control that the native timeout setting simply cannot provide.
The pattern also makes your flow more resilient. If Power Automate's approval service has a brief hiccup and the Wait for approval action doesn't exit cleanly, your loop's time condition acts as a backstop. You get defense-in-depth rather than a single point of failure.
Before touching the designer, get the architecture clear in your head. For a 24-hour SLA on a purchase order approval, here's the escalation ladder we're building:
| Time Elapsed | Event |
|---|---|
| 0h | Approval request sent to primary approver; approval ID stored in a variable |
| 12h | If no response: send Teams reminder to primary approver |
| 20h | If still no response: send Teams notification to primary and their manager; update SharePoint record status to "Escalated" |
| 24h | If still no response: auto-reject (or auto-approve, depending on your policy); send Teams message to finance channel; update SharePoint to "SLA Breached" |
The flow itself has three logical segments:
The SharePoint list that backs this flow has these columns:
Title (text) — PO numberRequestorEmail (text)ApproverEmail (text)ManagerEmail (text)Amount (number)SubmittedAt (date/time)DeadlineAt (date/time)ApprovalStatus (choice: Pending / Approved / Rejected / Escalated / SLA Breached)ApprovalOutcome (text) — stores the final response textApprovalID (text) — stores the GUID from the approval actionThis list is your audit trail. Every state change writes back to it, so you can build a Power BI report on top of it or satisfy a compliance audit without digging through flow run history.
Create a new automated cloud flow triggered by When an item is created on your SharePoint approval requests list. This is a natural fit: a new row appears when someone submits a purchase order form, and the escalation logic kicks off immediately.
After the trigger, your first block of actions initializes the variables the loop will use. Add a Initialize variable action for each of the following — the order matters because later initializations can reference earlier ones:
Name: varApprovalID Type: String Value: (empty)
Name: varApprovalResponse Type: String Value: Pending
Name: varLoopShouldExit Type: Boolean Value: false
Name: varEscalationTier Type: Integer Value: 0
Name: varDeadlineISO Type: String Value: (expression below)
Name: varReminderISO Type: String Value: (expression below)
Name: varEscalationISO Type: String Value: (expression below)
For varDeadlineISO, use this expression, which takes the trigger timestamp and adds 24 hours:
addHours(triggerOutputs()?['body/SubmittedAt'], 24)
For varReminderISO (12-hour mark):
addHours(triggerOutputs()?['body/SubmittedAt'], 12)
For varEscalationISO (20-hour mark):
addHours(triggerOutputs()?['body/SubmittedAt'], 20)
Warning
These expressions use triggerOutputs()?['body/SubmittedAt'] assuming your SharePoint column returns an ISO 8601 timestamp. If your column stores the submission time as a separate field rather than using the SharePoint item's Created column, double-check the output schema in a test run. Power Automate's addHours() function requires a valid ISO 8601 input — passing a locale-formatted date string will cause a runtime error that's surprisingly hard to diagnose.
Now write the deadline back to SharePoint immediately. Add an Update item action pointing to your list, setting DeadlineAt to variables('varDeadlineISO'). This is important: if your flow ever fails mid-run and needs to be restarted, the deadline timestamp is already persisted and won't shift forward.
Add a Start and wait for an approval action. Use the Approve/Reject — First to respond type. Key fields:
PO Approval Required — [Title from trigger] ($[Amount from trigger])[ApproverEmail from trigger]Critically, do not set the built-in timeout on this action. You want the action to wait indefinitely, because your loop will handle time enforcement. Setting both a built-in timeout and a Do Until timeout creates a race condition that produces unpredictable behavior.
After this action, the flow pauses until an approver responds. When it resumes, you have the full approval response object available. Immediately set your variables:
varApprovalID to: outputs('Start_and_wait_for_an_approval')?['body/approvalId']varApprovalResponse to: outputs('Start_and_wait_for_an_approval')?['body/outcome']Wait — there's a conceptual issue here that trips up many builders. If you use Start and wait for an approval without a timeout, the entire flow pauses at that action. The Do Until loop below it won't run until after someone responds. That means you can't use this action inside the loop for the initial request — you'd need to restructure.
The correct architecture is: send the initial approval before the loop with no timeout (letting it wait as long as it takes), and use the loop only to monitor for elapsed time thresholds while the approval is pending. But Power Automate's execution model doesn't let you simultaneously wait for an approval and run loop iterations.
This is the architectural challenge that separates expert flows from beginner ones, and there are two valid approaches.
The cleaner approach for escalation flows is to use Start an approval (without "wait") and then use the Do Until loop to poll for its response using Wait for an approval or by checking the approval's status through the connector.
Here's how this works in practice:
approvalId without blocking the flowvarApprovalID to the returned approvalIdApprovalID column with this valuevarApprovalResponse and varLoopShouldExit accordinglyThis approach keeps time-check logic and approval-response logic inside the same loop iteration, making the escalation behavior precise to the polling interval (30 minutes in our case).
Note
"Precise to the polling interval" means a reminder scheduled for the 12-hour mark might actually fire at 12h 29m if the previous poll completed just before the threshold and the next one completes just after. For most business SLAs, 30-minute precision is more than adequate. If you need tighter precision, reduce the poll interval — but be aware that each loop iteration consumes flow actions against your plan's limits.
Add a Do Until action after the Start an approval action. In the loop's condition, you want it to exit when either of these is true:
varLoopShouldExit equals trueSet the loop condition to:
@equals(variables('varLoopShouldExit'), true)
You'll handle the deadline-breach exit condition inside the loop by setting varLoopShouldExit to true when appropriate.
Configure the loop limits:
PT26H (ISO 8601 for 26 hours — this is the outer failsafe)Inside the loop, structure the logic in this order:
Add a Wait for an approval action inside the loop, using variables('varApprovalID') as the approval ID. Set its timeout to PT30M (30 minutes). This action will either return the approval response or time out silently after 30 minutes.
Configure the run after settings of the next action to run on both success and time out, so the flow continues regardless.
Add a condition action that checks whether the Wait for an approval action produced an outcome. The expression that detects a successful response:
@not(empty(outputs('Wait_for_an_approval')?['body/outcome']))
If true (approval came in):
varApprovalResponse to outputs('Wait_for_an_approval')?['body/outcome']varLoopShouldExit to trueApprovalStatus to the outcome value (Approved or Rejected)If false (still waiting), move to the time-threshold checks.
Inside the "false" branch of Step 2, add a nested condition:
@and(
greater(utcNow(), variables('varDeadlineISO')),
equals(variables('varLoopShouldExit'), false)
)
If past the deadline:
varApprovalResponse to Timed OutvarLoopShouldExit to trueApprovalStatus to SLA BreachedIf not yet past deadline, add another nested condition to check escalation tier thresholds:
@and(
greater(utcNow(), variables('varEscalationISO')),
less(variables('varEscalationTier'), 2)
)
If past the 20-hour mark and escalation tier is below 2 (meaning we haven't fired this escalation yet):
varEscalationTier to 2ApprovalStatus to EscalatedThen check the reminder threshold similarly:
@and(
greater(utcNow(), variables('varReminderISO')),
less(variables('varEscalationTier'), 1)
)
If past the 12-hour mark and tier is below 1:
varEscalationTier to 1Key insight
The varEscalationTier integer acts as a state machine. By only firing an escalation when the tier is below the threshold, you prevent the same notification from firing repeatedly on every loop iteration once a threshold is crossed. This is the single most common bug in DIY escalation flows — forgetting to track what you've already sent.
A notification that says "Your approval is pending" is nearly useless. People see it, mentally register it as noise, and move on. If you want your escalation notifications to produce responses, they need to provide immediate context and a clear next step.
For the 12-hour reminder, send an adaptive card to the approver via Teams. Adaptive cards let you include rich formatting, the key details of the request, and an Approve/Reject button. For this pattern, use the Post an Adaptive Card and wait for a response action as an alternative approval channel — but only if your architecture supports tracking two possible response channels (the original approval and the card). Simpler but still effective: send a Teams message that includes the direct link to the approval.
Here's what the 12-hour reminder message should contain, structured as a formatted Teams message using HTML in the message body:
⏰ Reminder: Approval Needed — [PO Number]
You have a purchase order approval waiting that was submitted 12 hours ago.
SLA deadline: [varDeadlineISO formatted as readable date]
Request Details:
• Requestor: [RequestorEmail]
• Amount: $[Amount]
• PO Number: [Title]
Action Required: Please approve or reject this request within the next 12 hours
to meet the SLA deadline.
[Direct link to approval: Click here to respond]
For the 20-hour escalation, send to both the approver and their manager, but in separate messages with different framing:
Approver message:
🚨 Urgent: Approval SLA at Risk — [PO Number]
This approval is now 20 hours old and your SLA deadline expires in 4 hours.
Your manager [ManagerEmail] has been notified.
Please respond immediately: [approval link]
Manager message:
🚨 Escalation Notice: Approval SLA at Risk
[ApproverEmail] has not responded to a purchase order approval that expires
in 4 hours. If no response is received, the request will be automatically
[rejected/escalated to you] at [deadline time].
PO Details:
• Amount: $[Amount]
• Requestor: [RequestorEmail]
• Submitted: [SubmittedAt]
You may respond directly if needed: [approval link]
For the SLA breach notification to the finance channel:
🔴 SLA BREACH — PO Approval Expired Without Response
PO: [Title] | Amount: $[Amount]
Original Approver: [ApproverEmail]
Submitted: [SubmittedAt]
Deadline: [DeadlineISO]
This request was automatically [rejected] due to no response within 24 hours.
Flow run ID: [workflow()['run']['name']]
Including the flow run ID in the breach notification is a practice borrowed from Master Error Handling and Retry Patterns in Power Automate for Bulletproof Flows — it gives your team a direct path to the run history when investigating what happened.
Tip
Use formatDateTime() in your expressions to convert ISO timestamps to human-readable formats before inserting them into notification messages. formatDateTime(variables('varDeadlineISO'), 'dddd, MMMM d \'at\' h:mm tt') will render as "Monday, March 24 at 3:00 PM" — infinitely more useful than "2025-03-24T15:00:00.0000000Z" in a Teams message.
When the Do Until loop exits — for any reason — you move into the resolution phase. At this point, varApprovalResponse holds one of three values: Approve, Reject, or Timed Out.
Add a Switch action (not a nested condition tree) on variables('varApprovalResponse'). Switch gives you cleaner branches for three or more outcomes than nested if-else conditions.
Case: Approve
ApprovalStatus to ApprovedApprovalOutcome to the approver's comments (from the approval response object)Case: Reject
ApprovalStatus to RejectedApprovalOutcome to the rejection reasonCase: Timed Out
Warning
Never silently auto-approve based on non-response. Auto-approve-on-timeout is a serious audit risk. If your process requires a decision when no one responds, the safest pattern is auto-reject with a note explaining why, combined with a clear resubmission path. This preserves accountability and creates a clean paper trail.
After the Switch action, add a final Update item action that stamps the SharePoint record with a completion timestamp in a ResolvedAt column. This makes it trivial to calculate actual vs. SLA duration later in Power BI.
Some organizations have two-tier approval requirements — say, department head approval for POs over $10,000 and finance director approval for anything over $50,000. In this case, your escalation logic needs to reset after each tier is successfully approved and then initialize a fresh escalation cycle for the next approver.
The cleanest way to handle this is with a Scope action that wraps the entire "one tier of approval" logic — variables, Do Until loop, and resolution — and then conditionally run the next Scope based on the first tier's outcome and the dollar amount.
This is the pattern described in depth in Designing Multi-Stage Approval Chains in Power Automate: Sequential, Parallel, and Delegated Sign-Off Patterns. The key adaptation when adding escalation: you must reinitialize varEscalationTier to 0 and recalculate the deadline variables before each Scope runs. The second-tier approver gets their own fresh 24-hour clock — or a tighter window if the first tier ate into a shared overall deadline.
For shared-deadline scenarios, calculate all deadline timestamps once at the beginning of the flow (relative to the original submission time), then pass them as variables through each tier. This way, if Tier 1 approval takes 18 hours, Tier 2 only has 6 hours before the overall SLA fires.
Cloud flows have a maximum run duration of 30 days, but they can fail. If your escalation flow fails at hour 18 due to a transient connector error and Power Automate retries it from scratch, the retry would reinitialize varEscalationTier to 0 and recalculate deadlines from the current time rather than the original submission time — which would give the approver a fresh 24-hour clock. That defeats the entire purpose.
The fix is to make your flow's restart behavior aware of existing state.
At the very beginning of your flow (right after the trigger), add a Get item action that reads the current SharePoint record. Then add a condition:
@not(equals(triggerOutputs()?['body/ApprovalStatus'], 'Pending'))
If the status is anything other than Pending, terminate the flow immediately with a Terminate action set to "Succeeded." This prevents a restarted flow from double-processing a record that already reached resolution.
For the deadline variables, instead of calculating them from triggerOutputs() on a restart, use the DeadlineAt value that was already written to SharePoint. Add a condition after the variable initialization block:
@not(empty(triggerOutputs()?['body/DeadlineAt']))
If the SharePoint record already has a DeadlineAt value, set varDeadlineISO to that existing value rather than recalculating it. This ensures deadline persistence across restarts.
For varEscalationTier, you'll need to infer the current tier from the SharePoint ApprovalStatus column:
Pending → tier 0Escalated → tier 2 (skip the reminder, go straight to monitoring for deadline breach)This makes your flow genuinely resilient rather than merely hoping that transient failures won't happen. For a deeper treatment of retry and resilience patterns, see Master Error Handling and Retry Patterns in Power Automate for Bulletproof Flows.
Time comparison expressions are the engine of the entire escalation pattern. Let's be precise about the functions you'll use, because getting them wrong produces bugs that only surface days after deployment.
utcNow() — returns the current UTC timestamp as an ISO 8601 string. Always use UTC throughout your flow. Never use a regional timestamp for comparisons — the greater() function does lexicographic comparison on ISO strings, which only works correctly in UTC format.
greater(utcNow(), variables('varDeadlineISO')) — returns true if current time is past the deadline. This works because ISO 8601 UTC strings sort lexicographically in chronological order.
addHours(timestamp, hours) — adds hours to an ISO timestamp. Use this for deadline calculations.
addMinutes(timestamp, minutes) — for finer-grained threshold calculations.
dateDifference() — not available in Power Automate; use a workaround with ticks():
div(
sub(
ticks(utcNow()),
ticks(triggerOutputs()?['body/SubmittedAt'])
),
36000000000
)
This expression calculates elapsed hours by converting both timestamps to ticks (100-nanosecond intervals since January 1, 0001) and dividing the difference by the number of ticks per hour. It's verbose but reliable.
formatDateTime(timestamp, format) — for human-readable output in notifications. Common formats:
'yyyy-MM-dd HH:mm' → 2025-03-24 15:00'ddd, MMM d h:mm tt' → Mon, Mar 24 3:00 PMTip
Store all your pre-calculated deadline strings (varDeadlineISO, varReminderISO, varEscalationISO) in human-readable format as separate variables in addition to the ISO format ones. Use the ISO versions for comparisons and the readable versions for notification messages. This avoids calling formatDateTime() inside notification actions repeatedly, which keeps your expressions readable and your action count down.
Build the following complete escalation flow for a vendor contract review scenario. Specifications:
Scenario: Legal team receives contract review requests via a SharePoint list. Contracts must be reviewed within 48 hours of submission. Reviews over $500,000 in contract value must also be approved by the General Counsel.
Build requirements:
SharePoint list — Create a list called "Contract Reviews" with these columns: Title (contract name), ContractValue (number), RequestorEmail (text), LegalReviewerEmail (text), GCEmail (text), SubmittedAt (date/time), DeadlineAt (date/time), ReviewStatus (choice: Pending / In Review / Approved / Rejected / Escalated / SLA Breached), EscalationTier (number).
Flow trigger — When an item is created in Contract Reviews.
Tier 1 escalation ladder:
Tier 2 (conditional): If ContractValue > 500,000 AND Tier 1 was Approved, restart the escalation cycle with GCEmail as approver and a fresh 24-hour deadline.
Resilience: Implement the state-restoration logic so the flow doesn't reset deadlines if restarted.
Notification quality: All Teams messages must include the contract name, value, requestor, deadline in human-readable format, and a direct link to the SharePoint item.
Challenge extension: Add a Planner task for the reviewer at the moment of initial approval request, and mark that task complete when the review resolves (either outcome). Reference Automating Planner Task Creation, Assignment, and Status Updates with Power Automate: End-to-End Project Workflow Integration for the Planner connector details.
Symptom: The Do Until loop reaches its iteration count limit (50) without exiting, even after an approval response comes in.
Cause: The Wait for an approval action inside the loop has "run after" settings configured to only run on success. When it times out after 30 minutes, the subsequent condition that checks varLoopShouldExit skips, and the loop never gets the chance to set it to true.
Fix: Select every action inside the loop and configure its run after settings to run on success, failure, skipped, and timed out. In Power Automate's designer, right-click each action (or use the three-dot menu) to access run after settings. This is one of the most impactful settings in Power Automate and one of the least-used by beginners.
Symptom: The approver receives 15 reminder messages instead of one.
Cause: varEscalationTier is not being set before the condition check, or the condition check uses less than or equal to instead of less than, allowing the same tier to trigger again on the next iteration.
Fix: Verify that your tier-update action (Set variable) runs inside the conditional branch that fires the notification, and that the condition gates on less(variables('varEscalationTier'), 1) rather than lessOrEquals. Check your run history — in each loop iteration's detail view, you can see exactly which branch executed and what variable values were at that moment.
Symptom: The Wait for an approval action inside the loop fails with "Approval not found" or "ApprovalId is invalid."
Cause: The Start an approval action returned an approval ID but the approval was already responded to through the email/Teams notification before the loop's Wait for an approval checked for it. The approval is in a completed state, and the wait action doesn't handle that gracefully.
Fix: Wrap the Wait for an approval action in a try-catch pattern using a Scope with error handling. Configure a parallel branch that runs if the Scope fails, which reads the approval's current status directly. Alternatively, use the approval connector's Get approval action to retrieve status by ID before calling Wait for an approval, and only call the wait if status is still Pending.
Warning
This error is far more common than most documentation suggests. In high-traffic flows where approvers are responsive, they'll often approve within seconds of receiving the notification — before the loop even starts its first Wait for an approval call. Always build your loop to handle the "already completed" case.
Symptom: Approvals that haven't been acted on in 30 days cause the flow to terminate with a "Flow run exceeded maximum duration" error.
Cause: Power Automate cloud flows have a hard maximum run duration of 30 days. This is an infrastructure limit, not something you can configure around.
Fix: For approval workflows with possible 30+ day windows, restructure so each loop iteration creates a new child flow run rather than staying inside a single parent run. A scheduled flow that polls a SharePoint "pending approvals" list daily and fires escalations as needed is architecturally cleaner for very long-running scenarios, though it adds complexity. See Scheduling and Managing Time-Based Flows in Power Automate: Recurrence Triggers, Time Zones, and Business Hours Logic for the scheduled polling alternative.
Symptom: The escalation message is sent to the approver's email address but shows up in a different Teams user's chat, or fails silently.
Cause: The Teams connector matches users by UPN (User Principal Name), which is usually the same as email but not always — especially in organizations that have undergone domain migrations or mergers.
Fix: Use the Microsoft 365 Users connector's Get user profile (V2) action to resolve the email to a confirmed UPN before passing it to Teams actions. This adds one action but eliminates a class of intermittent failures that are very difficult to debug after the fact.
You've built a production-grade approval escalation system. The key architectural insights from this lesson:
The Do Until loop is your time machine. By polling on a defined interval and comparing the current UTC time against pre-calculated threshold variables, you get precise, auditable control over escalation timing that the native timeout setting can't provide.
State is your resilience mechanism. Writing escalation tier, deadlines, and approval status to SharePoint at every state change means your flow can survive failures, retries, and restarts without losing context or misfiring notifications.
Notifications need to drive action. Every Teams message should include the context someone needs to respond immediately — not just an alert that something needs attention. The difference between a 12% response rate and a 78% response rate on approval reminders often comes down to message quality, not message frequency.
The "already completed" case is always possible. Any time you use Wait for an approval after Start an approval, assume that the approval might already be resolved. Build your error handling accordingly.
From here, your natural next steps are:
SubmittedAt, DeadlineAt, and ResolvedAt columns you built into the SharePoint list give you everything you need for cycle-time analysisThe escalation pattern you've learned here applies beyond procurement approvals. Any time-sensitive human decision — incident acknowledgment, content review, access requests, compliance sign-offs — can be governed with this same architecture. The variables change; the structure doesn't.