Most approval workflows send one email and go silent. This lesson teaches you to build a persistent, self-healing approval system with scheduled reminders, SLA-based escalation, OOF-aware delegate routing, and automatic closure of stale requests — all backed by SharePoint state management.

Picture this: a purchase request worth $47,000 has been sitting in your CFO's inbox for nine days. The vendor's discount window closes tomorrow. Nobody noticed because the CFO was at a conference, the original requestor assumed someone was handling it, and your approval workflow politely sent a single email on day one and then went completely silent. This is the failure mode that kills otherwise well-designed approval systems — not the logic of who approves what, but the complete absence of escalation, delegation, and self-healing when humans go quiet.
Building an approval workflow that routes requests to the right person is relatively straightforward once you understand the basics. But building one that persists — that reminds approvers on a schedule, automatically escalates to a manager after a deadline, notifies a delegate when someone is out of office, and ultimately closes or redirects stale requests rather than leaving them to rot indefinitely — that's the engineering challenge that separates production-grade automation from demo-ware. By the end of this lesson, you'll have a complete, working pattern for doing exactly that, implemented in Power Automate using realistic SharePoint-backed state management, recurrence triggers, and dynamic escalation logic.
What you'll learn:
You should arrive at this lesson with solid working knowledge of Power Automate fundamentals. Specifically, you need to be comfortable with:
Start and wait for an approval and Wait for an approval — the lesson Building Approval Workflows with Power Automate covers this welladdDays(), utcNow(), formatDateTime(), and int()The most common mistake when building reminder systems is trying to encode all the logic inside the original approval flow — the one that fires when a request is submitted. People add a Delay action followed by a reminder email, then another Delay, then another email. This feels logical but it's deeply fragile. A delayed flow run that spans multiple days is sitting in Power Automate's backend waiting for a timer to expire. If your flow is turned off, re-saved, or hits a platform error during that wait, the entire thread dies silently. You've also got no way to inspect "which approvals are currently delayed" or "how many reminders have been sent" without digging through run history.
The better architecture separates concerns completely:
This architecture is observable, restartable, and debuggable. Your SharePoint list is your source of truth. You can look at it any time and know exactly where every approval stands.
Key insight
The Start and wait for an approval action is deceptively convenient for simple cases, but it holds an entire flow run open for potentially days. At scale this creates problems with run history bloat and makes escalation nearly impossible to implement cleanly. The pattern we're building here uses Create an approval (non-blocking) plus a SharePoint tracking table, which gives you full control.
Your SharePoint list is the engine of this entire system. Get the schema right and the rest of the build is straightforward. Get it wrong and you'll be rebuilding it halfway through.
Create a SharePoint list called PendingApprovals with the following columns:
| Column Name | Type | Purpose |
|---|---|---|
| Title | Single line of text | Request ID or description |
| RequestorEmail | Single line of text | Who submitted the request |
| ApproverEmail | Single line of text | Current assigned approver |
| DelegateEmail | Single line of text | Alternate approver if escalated |
| ManagerEmail | Single line of text | Escalation target |
| ApprovalId | Single line of text | Power Automate approval ID |
| SubmittedDate | Date and Time | When the request was submitted |
| LastReminderSent | Date and Time | When we last sent a reminder |
| ReminderCount | Number | How many reminders have been sent |
| EscalationLevel | Number | 0 = initial, 1 = first escalation, 2 = final |
| Status | Choice | Pending, Escalated, Expired, Approved, Rejected |
| RequestAmount | Currency | Dollar value (for this example) |
| DepartmentCode | Single line of text | For routing logic |
| MaxDaysAllowed | Number | Per-request SLA (e.g., 5 or 10 days) |
| Notes | Multiple lines of text | Audit trail notes |
The MaxDaysAllowed column is crucial — it lets you implement different SLAs for different request types. A $500 expense reimbursement might have a 10-day window while a $50,000 capital expenditure request might need approval within 3 business days. You bake the SLA into the record at submission time rather than hardcoding it in the flow.
Tip
Add an index on the Status column in SharePoint list settings. When your list grows to hundreds of pending approvals, the filter query your scheduled flow runs every few hours will thank you. Lists without indexed columns run full table scans on filtered Get Items calls.
The originating flow fires when a new request is submitted — in our example, from a Microsoft Forms submission or a Power Apps canvas app. Its job is simple: create the approval, write the tracking record, send a confirmation to the requestor, and exit.
In your flow, after whatever trigger you're using, add a Create an approval action (not "Start and wait"). Configure it as follows:
Expense Approval Required: [RequestID] — $[Amount]addDays(utcNow(), triggerOutputs()?['body/MaxDaysAllowed']) — set the approval's native due date to match your SLAAfter the Create action, capture the Approval ID from its output. Then add a Create item action to your PendingApprovals SharePoint list:
Title: [Request description]
RequestorEmail: [From trigger or form]
ApproverEmail: [Assigned approver]
ManagerEmail: [From HR data or form field]
ApprovalId: outputs('Create_an_approval')['body/approvalId']
SubmittedDate: utcNow()
LastReminderSent: utcNow()
ReminderCount: 0
EscalationLevel: 0
Status: Pending
MaxDaysAllowed: [From trigger data]
Warning
The Create an approval action generates the approval and sends the initial notification email automatically. Do not send a separate "You have a pending approval" email after this action — the approver will receive two notifications for the same request and immediately start ignoring your reminders.
Now send a confirmation to the requestor using a Send an email (V2) action. Keep it simple: "Your request [ID] has been submitted and is pending approval. You will be notified when a decision is made." Include an estimated decision date based on MaxDaysAllowed.
The originating flow is done. It runs in seconds and exits cleanly.
This is the heart of the system. The watcher flow runs on a recurrence — let's say every 4 hours during business hours. You could also run it daily if your SLAs are measured in days rather than hours. For the configuration of recurrence triggers with business hours awareness, the lesson on Scheduling and Managing Time-Based Flows in Power Automate: Recurrence Triggers, Time Zones, and Business Hours Logic covers exactly how to set hours-of-day constraints.
Add a Get items action targeting your PendingApprovals list with this OData filter:
Status eq 'Pending' or Status eq 'Escalated'
Set the top count to 500 and ensure you have pagination enabled if you might exceed that. Add a Compose action to calculate today's date as a string for use in expressions:
formatDateTime(utcNow(), 'yyyy-MM-dd')
Add an Apply to each loop over the items returned. Inside the loop, calculate the age of the request in days using integer division:
div(
sub(
ticks(utcNow()),
ticks(item()?['SubmittedDate'])
),
864000000000
)
Store this in a variable called RequestAgeDays that you initialize before the loop (as an integer, default 0). Similarly calculate DaysSinceLastReminder:
div(
sub(
ticks(utcNow()),
ticks(item()?['LastReminderSent'])
),
864000000000
)
Note
The ticks() function returns the number of 100-nanosecond intervals since January 1, 0001. Subtracting two tick values and dividing by 864000000000 (which is 10,000,000 ticks per second × 86,400 seconds per day) gives you elapsed days as an integer. This is more reliable than string-parsing date differences, especially across month and year boundaries.
Now build a condition that branches on RequestAgeDays relative to MaxDaysAllowed from the current item. This is where your business logic lives.
Branch A: Request has exceeded MaxDaysAllowed (Auto-close)
RequestAgeDays >= item()?['MaxDaysAllowed'] + 2
If this is true, the request has been pending for the full SLA period plus a 2-day grace period. Auto-close it:
Cancel an approval action with the stored ApprovalIdExpired, append to Notes: Auto-closed after [RequestAgeDays] days with no response.Branch B: Request has passed MaxDaysAllowed but is in grace period
RequestAgeDays >= item()?['MaxDaysAllowed'] and RequestAgeDays < item()?['MaxDaysAllowed'] + 2
This is the escalation window. If EscalationLevel is 0 or 1, escalate now:
Check EscalationLevel: If it's 0, escalate to the manager. If it's 1, the approval is already escalated — this is a second escalation reminder.
For EscalationLevel 0 (first escalation):
Escalated, EscalationLevel = 1, ApproverEmail = ManagerEmail, LastReminderSent = utcNow()For EscalationLevel 1 (already escalated, still pending):
Branch C: Request is within SLA but overdue for a reminder
DaysSinceLastReminder >= 2 and RequestAgeDays < item()?['MaxDaysAllowed']
This is your standard reminder loop. Send a reminder, increment the counter:
Build the reminder email with context:
Create an approval output — store this in your SharePoint record as an ApprovalLink column)Vary the urgency by ReminderCount:
Update the SharePoint item: Increment ReminderCount, set LastReminderSent = utcNow()
Here's what that email subject escalation looks like as expressions in a Switch block based on ReminderCount:
Case 0: 'Reminder: Approval Needed for [Title]'
Case 1: 'Second Reminder — Approval Deadline Approaching'
Default: concat('URGENT: Approval Required — ', sub(item()?['MaxDaysAllowed'], int(variables('RequestAgeDays'))), ' Day(s) Remaining')
Key insight
The reason you check DaysSinceLastReminder >= 2 rather than sending a reminder on every watcher run is that your watcher runs every 4 hours. Without a cooldown check, an approver would receive 6 reminders per day — which guarantees they'll create an inbox rule to delete your approval emails. Two-day reminder intervals are aggressive enough to maintain urgency without becoming noise.
Here's a feature most teams never implement but everyone wishes they had: if the approver is out of office, automatically notify their delegate instead of letting the request sit unanswered.
The Microsoft 365 Outlook connector has a Get mail tips for a mailbox action that returns OOF (Out of Office) status. Add this inside your Apply to each loop, before the escalation decision tree, when the item's EscalationLevel is 0 (i.e., it hasn't been escalated yet).
ApproverEmail from the current item.outputs('Get_mail_tips_for_a_mailbox')['body/value'][0]['isOutOfOffice'] equals trueIf the approver is OOF:
DelegateEmail is already populated in the SharePoint record. If it's not, try to retrieve the delegate from the approver's OOF auto-reply message (the mail tips response includes the OOF message text — you can parse it for a delegate mention, though this is brittle). More reliably, look up a DelegateDirectory SharePoint list you maintain separately that maps each person to their designated alternate.The lookup against a delegate directory list looks like this:
Add a Get items action against your DelegateDirectory list with filter:
EmployeeEmail eq '[ApproverEmail from current item]'
Then in a condition, check length(body('Get_items_DelegateDirectory')['value']) > 0. If true, take first(body('Get_items_DelegateDirectory')['value'])['DelegateEmail'] as your delegate.
Warning
When you cancel and recreate an approval to change the assignee, you lose the original approval thread in the Approvals hub for that user. Consider instead sending the delegate a Teams Adaptive Card notification with approve/reject buttons rather than a formal approval. This preserves the audit trail while still enabling action. The lesson on Implementing Adaptive Card-Based Human-in-the-Loop Approvals in Power Automate: Dynamic Forms, Contextual Data Injection, and Response Handling walks through exactly this pattern.
The completion flow fires when an approval decision is made. The cleanest trigger for this is When an item is modified on your PendingApprovals SharePoint list — but that fires on every modification, including reminder count updates. Instead, use a separate flow that watches for the approval to complete.
The better approach: use a dedicated When an approval is completed trigger (under the Approvals connector), or — in the non-blocking architecture — add a parallel branch to the originating flow that runs Wait for an approval against the original ApprovalId. This branch can run for days waiting for the response; when it gets one, it fires.
Here's the tricky part of non-blocking approvals: you have an approval ID from Create an approval, and you need to wait for it in a separate path. Structure your originating flow with a Parallel branch after the Create action. The left branch writes to SharePoint and sends the confirmation email (quick and exits). The right branch uses Wait for an approval with the ApprovalId — this branch sits dormant until the approver responds.
When the Wait action finally resolves:
outputs('Wait_for_an_approval')['body/outcome'] — this is either "Approve" or "Reject"Approved or Rejected, set a CompletedDate column, append the approver's comments to NotesTip
Store the approval response comments in your SharePoint list's Notes field in a structured format: [Date] [Approver]: [Comment]. This creates a readable audit trail directly in the list without needing to query Power Automate run history — which is essential because run history only persists for 30 days by default.
Your approval system becomes dramatically more useful if you also post status updates to a Teams channel — both for individual notifications and for a shared team channel that an operations manager can monitor. For an approval that's been sitting for 5 days with escalation to a manager, the manager should see this in Teams, not just email.
Using Using Power Automate with Microsoft Teams: Automate Notifications, Approvals, and Channel Messages, add a Post message in a chat or channel action inside your escalation branch:
For individual approvals, use Post a message in chat directly to the manager being escalated to. People respond faster to Teams pings than to emails in many organizations, and the combination of both channels maximizes your response rate.
The watcher flow runs every 4 hours. Over a 10-day approval window, it'll run approximately 60 times against the same pending record. You must design every action inside the loop to be idempotent — meaning running it multiple times has the same effect as running it once.
The LastReminderSent and DaysSinceLastReminder check is your primary idempotency guard. But you need to protect against race conditions too — what if the watcher runs twice simultaneously? This is unlikely on a 4-hour schedule but possible if you manually trigger the flow for testing.
Add a Status check at the very start of your loop, before any action:
item()?['Status'] eq 'Pending' or item()?['Status'] eq 'Escalated'
If the status has changed since the Get Items query ran (e.g., someone approved it 3 minutes ago), skip this item entirely. You can implement this with a condition that wraps the entire escalation logic.
For the auto-close action specifically, add an additional check: attempt to cancel the approval, but use a "Configure run after" setting on the Cancel action to handle both success and failure gracefully. If the approval is already cancelled or approved, the Cancel action will fail — configure it to continue regardless and simply update the SharePoint record. For detailed error handling patterns like this, see Master Error Handling and Retry Patterns in Power Automate for Bulletproof Flows.
Key insight
The most dangerous state in any approval system is "ambiguous" — where Power Automate thinks the approval is pending but SharePoint says it's been decided, or vice versa. Your Notes field is your insurance policy here. Log every state transition with a timestamp and the reason: "2025-03-15T14:22Z — Auto-closed after 12 days. Approval ID: AP-1234". If you ever need to audit what happened, you'll have it.
The date arithmetic in this flow relies on expressions you need to get exactly right. Let's walk through the specific expressions you'll use repeatedly.
Calculating request age in days (integer):
div(
sub(
ticks(utcNow()),
ticks(items('Apply_to_each')?['SubmittedDate'])
),
864000000000
)
Days remaining before SLA breach:
sub(
items('Apply_to_each')?['MaxDaysAllowed'],
div(
sub(ticks(utcNow()), ticks(items('Apply_to_each')?['SubmittedDate'])),
864000000000
)
)
Formatted deadline date (for email body):
formatDateTime(
addDays(items('Apply_to_each')?['SubmittedDate'], items('Apply_to_each')?['MaxDaysAllowed']),
'dddd, MMMM d, yyyy'
)
Checking if a string field is empty (for DelegateEmail check):
empty(items('Apply_to_each')?['DelegateEmail'])
Building the Notes append string:
concat(
items('Apply_to_each')?['Notes'],
'\n',
formatDateTime(utcNow(), 'yyyy-MM-dd HH:mm'),
' UTC — Reminder #',
string(add(items('Apply_to_each')?['ReminderCount'], 1)),
' sent to ',
items('Apply_to_each')?['ApproverEmail']
)
For a deep dive on the full expression language covering string manipulation, date math, and array functions, Mastering Dynamic Expressions and the Power Automate Formula Language: String, Date, and Array Functions for Real-World Data Manipulation is the reference you'll want bookmarked.
A reminder system that operates invisibly is one that nobody trusts. Build a simple visibility layer by creating a SharePoint view on your PendingApprovals list filtered to Status = Pending or Escalated, sorted by SubmittedDate ascending. Share this view link in your operations Teams channel. Anyone can see the backlog in real time without touching Power Automate.
Go further: add a daily digest flow that runs every morning at 8 AM and posts a summary to your operations channel:
join() and select() from the array:join(
select(
body('Get_items')?['value'],
item(),
concat(
item()?['Title'], ' | ',
item()?['ApproverEmail'], ' | ',
string(div(sub(ticks(utcNow()), ticks(item()?['SubmittedDate'])), 864000000000)),
' days pending'
)
),
'\n'
)
This daily digest is the difference between an automated system your leadership trusts and one that generates anxiety because nobody can see what's happening inside it.
Build the complete approval reminder system end-to-end in a development environment using this realistic scenario:
Scenario: Your IT department processes software license requests. Requests under $1,000 must be approved by the IT manager within 5 days. Requests over $1,000 require the IT director and must be decided within 3 days. All pending requests older than their deadline + 2 days auto-close.
Build tasks:
Create the PendingApprovals SharePoint list with all columns from the schema above. Add a DelegateDirectory list with EmployeeEmail and DelegateEmail columns. Add test data: 3 entries with SubmittedDate values of 1, 4, and 8 days ago, all with Status = Pending.
Build the originating flow triggered by a manual HTTP request (for testing) that creates an approval and writes a SharePoint record. Use triggerBody()?['amount'] and triggerBody()?['requestorEmail'] as inputs. Set MaxDaysAllowed to 5 if amount < 1000, else 3.
Build the watcher flow with a recurrence trigger (set to every 1 hour for testing, you'll change it later). Inside the loop, implement all three branches: auto-close, escalate, and standard reminder. Test each branch by manipulating your test data's SubmittedDate to fall into the different conditions.
Verify the OOF check works by populating one record with your own email as ApproverEmail and turning on OOF in Outlook. Run the watcher and confirm it routes to the delegate.
Build the daily digest flow that posts a summary to a test Teams channel.
Validation criteria: After building and testing, your SharePoint list should accurately reflect the state of each test item, your email inbox should contain correctly-worded reminders (with escalating urgency), and the auto-close logic should have updated the 8-day-old item to Status = Expired with a note in the Notes field.
Problem: The Cancel an approval action fails with "Approval not found"
This happens when the approval was completed (approved or rejected) between when your Get Items query ran and when your Cancel action fires. This is expected behavior — implement a "Configure run after" that also runs on failure, and in the failure handler, simply get the latest SharePoint item status and skip the update if it's already in a terminal state.
Problem: DaysSinceLastReminder is always 0
The ticks subtraction works correctly, but if LastReminderSent was set to utcNow() with a string format that doesn't match what ticks() expects, the calculation silently fails. Ensure LastReminderSent is stored as a SharePoint DateTime column, not a text column. Also verify you're not using formatDateTime() when storing — store the raw utcNow() value.
Problem: Every item in the loop triggers escalation every time the watcher runs
Check your condition ordering. The auto-close check must be >= the maximum days, the escalation check must be between MaxDaysAllowed and MaxDaysAllowed + 2, and the reminder check must include DaysSinceLastReminder >= 2. If you're missing the DaysSinceLastReminder gate, every watcher run will send a reminder.
Problem: OOF detection returns false even though the approver is out of office
The "Get mail tips for a mailbox" action requires appropriate permissions and may return incomplete data if the approver's mailbox is in a different Exchange organization or has restricted external access. Verify the connection account has at minimum Mail.ReadWrite and MailboxSettings.Read permissions. In some tenants, you may need the Microsoft Graph connector instead of the Outlook connector for OOF checks.
Problem: The watcher flow runs slowly when there are many pending items
With 200+ items in your loop and multiple actions per item, you can hit Power Automate's action limit for a single run. Break this into batches using a filter that processes items in segments (e.g., by department code), or implement concurrency by setting Apply to each concurrency to 5 parallel iterations. Be careful: if concurrent iterations both try to update the same SharePoint item (edge case), you can get write conflicts.
Problem: Auto-closed approvals aren't being marked as Expired because the Parallel branch in the originating flow is still waiting
If you used a Parallel branch with Wait for an approval, that branch will fail when the approval is cancelled — with a "Approval was cancelled" error. This is actually the correct behavior. Configure the Wait for an approval action to run after both success and failure, and in the failure handler, check the SharePoint record status. If it's Expired, send the "request was closed" email to the requestor and exit gracefully.
Warning
Don't rely on Power Automate run history as your primary audit log for this system. Run history retains for 28 days (or less depending on your plan). Your SharePoint Notes field is permanent and human-readable. Train your team to look there first when an approver disputes what happened to their request.
You've built a production-grade approval reminder system that goes well beyond "send an email and wait." The key architectural insight is state management: by keeping all approval metadata in SharePoint and using a separate scheduled watcher flow, you get a system that's observable, restartable, and independently testable. Each component has a single responsibility, and failures in one don't cascade into others.
The specific patterns you can now implement:
Where to go next:
If your approval process involves multiple sequential or parallel approvers, the patterns you've built here compose naturally with the multi-stage approval architecture in Designing Multi-Stage Approval Chains in Power Automate: Sequential, Parallel, and Delegated Sign-Off Patterns. Each stage in a sequential chain can have its own MaxDaysAllowed and EscalationLevel tracking, with the watcher flow handling reminders at every stage.
For complex routing logic — where the right approver depends on department, amount threshold, and request type simultaneously — see Building a Dynamic Multi-Condition Approval Flow in Power Automate: Routing Requests to Different Approvers Based on Form Input, Dollar Thresholds, and Department Rules. The dynamic routing and the reminder/escalation system you've built here are separate concerns that compose cleanly — your watcher doesn't need to know how the approval was routed; it just needs the current ApproverEmail and the SLA from the SharePoint record.
Finally, if this system will run at organizational scale with hundreds of daily requests, revisit your SharePoint list performance configuration, consider moving to Dataverse for its more robust querying and transaction semantics, and review run history and flow checker techniques at Using Power Automate Run History and Flow Checker to Debug and Fix Failing Flows to build your monitoring practice before you're debugging a production incident at 11 PM.