Learn how to build a production-ready multi-step approval workflow in Power Automate that collects responses via Outlook, logs every decision to a SharePoint audit trail, and fires a Teams notification the moment every required approver has responded. This lesson covers sequential and conditional approval stages, completeness detection, and rejection handling — all the parts that basic tutorials skip.

Picture this: your organization runs a capital expenditure approval process. A department head submits a purchase request, and it needs sign-off from three people — their direct manager, the finance controller, and the VP of Operations. Right now, this happens over email. The manager approves and CC's finance. Finance replies-all with approval but the VP never sees it because the thread got buried. Two weeks later, the vendor calls. Nobody knows where the request stands. The purchase order never went out.
This is exactly the problem that multi-step approval workflows were designed to solve — and Power Automate gives you the tooling to build one that's actually reliable. By the end of this lesson, you'll have a flow that sends structured approval requests through Outlook, tracks every response in a SharePoint list, and fires a Teams notification the moment the last required approver has weighed in. No more hunting through email chains. No more ambiguous "I thought you approved it?"
What makes this lesson different from a basic approval tutorial is the emphasis on completeness detection — the logic that knows when all the right people have responded, not just the first one. That's where most flows break down, and it's what we're going to get right.
What you'll learn:
You should be comfortable with the fundamentals before diving in. Specifically:
The single biggest mistake people make with multi-step approval flows is opening Power Automate and starting to drag actions around before they've thought through the data model. Five hundred actions later, the flow is a tangle of parallel branches and nobody can figure out why the completeness check fires prematurely.
Let's design on paper first.
The scenario we're building: A SharePoint list called PurchaseRequests stores submitted requests. When a new item is created, the flow kicks off. Three approvers are involved:
This is a hybrid model: partially sequential (Manager must go before Finance), partially conditional (VP is only looped in above a threshold). It's the kind of real-world complexity that breaks naive approval flows.
What we need to track:
The data store: We'll use a second SharePoint list called ApprovalLog with the following columns:
| Column Name | Type | Purpose |
|---|---|---|
| RequestID | Number | Links back to the PurchaseRequests item |
| ApproverName | Single line of text | Display name of the approver |
| ApproverEmail | Single line of text | Email address |
| Decision | Choice (Approve/Reject/Pending) | The outcome |
| Comments | Multiple lines of text | Free-text response |
| ResponseTimestamp | Date and Time | When they responded |
| Stage | Choice (Manager/Finance/VP) | Which approval stage |
Key insight
Logging each approval decision as a separate row in the ApprovalLog (rather than updating fields on the original request item) gives you a genuine audit trail. If Finance approves, then gets escalated and changes their mind, you see both events. Updating a single row would overwrite the history.
The flow itself will be an automated cloud flow triggered by "When an item is created" on the PurchaseRequests list.
Before building the flow, create both lists. In your SharePoint site:
PurchaseRequests list — the minimum columns you need for this lesson:
Title (default, use it for the request name)RequestorName — Single line of textRequestorEmail — Single line of textAmountUSD — Number (this drives the VP threshold logic)Description — Multiple lines of textManagerEmail — Single line of text (the requestor's direct manager)OverallStatus — Choice column with options: Pending, Approved, RejectedApprovalLog list — create with all the columns defined in the table above. Make sure Decision is a Choice column with exactly these options: Approve, Reject, Pending. Case matters when you later filter on it.
Tip
Set the default value of Decision to "Pending" and OverallStatus on PurchaseRequests to "Pending." This means every new item and every new log row starts in a known state without requiring extra initialization actions in the flow.
Once the lists exist, note the site URL and both list names — you'll paste them into the SharePoint connector repeatedly.
Open Power Automate and create a new automated cloud flow. Search for the trigger "When an item is created" under the SharePoint connector. Set the Site Address to your SharePoint site and the List Name to PurchaseRequests.
Immediately after the trigger, add a series of "Initialize variable" actions. Variables let you carry data across the branches of a parallel approval without relying on dynamic content that may or may not be in scope at a given point.
Add these variables:
managerDecision — String, default value: ""
financeDecision — String, default value: ""
vpDecision — String, default value: "N/A" ← default means VP not required
vpRequired — Boolean, default value: false
requestID — Integer, default value: 0
Then add a "Compose" action to capture the request ID from the trigger:
Compose: triggerOutputs()?['body/ID']
And immediately follow it with a "Set variable" action to store that output into requestID:
Set variable: requestID = outputs('Compose')
Why not just use the trigger's ID field directly throughout the flow? Because in long-running flows with parallel branches, dynamic content from the trigger can be referenced inconsistently once you're deep inside loops or nested conditions. Storing it explicitly in a variable is the defensive choice.
Warning
Do not initialize variables inside condition branches or loops. Variables initialized inside a branch are scoped to that branch in some contexts, and you'll get "variable not found" errors in sibling branches. Always initialize at the top level of the flow, before any branching.
Add a "Condition" action. Check whether the amount crosses the threshold:
Condition: triggerOutputs()?['body/AmountUSD'] is greater than or equal to 10000
In the Yes branch: Add a "Set variable" action — set vpRequired to true.
In the No branch: No action needed; vpRequired stays false and vpDecision stays "N/A".
This condition runs before any approval requests are sent, so you know immediately which approvers are actually in scope.
After the VP-required condition resolves (both branches rejoin at the parent level), add the first approval action.
Search for "Start and wait for an approval" under the Approvals connector. This is the action that sends the request to the approver's Outlook inbox as an actionable message — they can respond directly from the email without logging into any portal.
Configure it as follows:
Purchase Request Approval Required: + triggerOutputs()?['body/Title']triggerOutputs()?['body/ManagerEmail']Compose — managerApprovalDetails:
concat(
'Request: ', triggerOutputs()?['body/Title'], '\n',
'Requestor: ', triggerOutputs()?['body/RequestorName'], '\n',
'Amount: $', string(triggerOutputs()?['body/AmountUSD']), '\n',
'Description: ', triggerOutputs()?['body/Description']
)
Use the output of that Compose as the Details field on the approval action.
/Lists/PurchaseRequests/DispForm.aspx?ID= + the requestID variable)When the approval action completes, the flow continues synchronously — "Start and wait" blocks until the approver responds. The response is available in the outputs of the approval action.
Add a "Set variable" action:
Set variable: managerDecision = outputs('Start_and_wait_for_an_approval')?['body/responses'][0]['approverResponse']
The responses array contains one entry per approver who responded. Since this is a "First to respond" approval with one assignee, index [0] is always the manager's response.
Tip
The approverResponse field returns either "Approve" or "Reject" — note the capital first letter. When you write conditions against this value later, match the case exactly. "approve" will never equal "Approve" in Power Automate expressions.
Now create the SharePoint log entry for the manager's response. Add a "Create item" action on the ApprovalLog list:
variables('requestID')outputs('Start_and_wait_for_an_approval')?['body/responses'][0]['responder']['displayName']outputs('Start_and_wait_for_an_approval')?['body/responses'][0]['responder']['email']variables('managerDecision')outputs('Start_and_wait_for_an_approval')?['body/responses'][0]['comments']outputs('Start_and_wait_for_an_approval')?['body/responses'][0]['requestDate']After logging, add a Condition:
Condition: variables('managerDecision') is equal to 'Approve'
In the No branch (manager rejected):
OverallStatus to "Rejected"Key insight
Use "Terminate" with Status "Succeeded" rather than "Failed" when the workflow completed its intended logic (detecting a rejection is successful behavior). Reserve "Failed" for genuine errors. This keeps your run history clean and makes debugging with run history far less confusing.
In the Yes branch, the flow continues to Stage Two.
Inside the Yes branch of the manager approval condition, add another "Start and wait for an approval" action. This time, assign it to the Finance Controller.
You'll need the Finance Controller's email. For a real scenario, this is often a fixed address (a specific person or a shared mailbox like finance-approvals@yourcompany.com). Hard-code it in a "Compose" action at the top of the flow where it's easy to update, rather than burying it inside the action configuration.
Configure the Finance approval similarly to the Manager's, but update the Details to include the manager's decision:
Compose — financeApprovalDetails:
concat(
'Request: ', triggerOutputs()?['body/Title'], '\n',
'Requestor: ', triggerOutputs()?['body/RequestorName'], '\n',
'Amount: $', string(triggerOutputs()?['body/AmountUSD']), '\n',
'Manager Decision: ', variables('managerDecision'), '\n',
'Description: ', triggerOutputs()?['body/Description']
)
Including the prior decision in the approval email gives Finance context — they can see that the manager already reviewed and approved it. This is a small UX detail that dramatically reduces "wait, has anyone else looked at this?" replies.
After Finance responds, log their decision the same way you logged the manager's:
Set variable: financeDecision = outputs('Start_and_wait_for_an_approval_2')?['body/responses'][0]['approverResponse']
Create an ApprovalLog entry with Stage set to "Finance."
Add another condition on financeDecision:
This is where the architecture gets interesting. Inside the Finance approval's Yes branch, add a Condition that checks the vpRequired boolean variable:
Condition: variables('vpRequired') is equal to true
In the Yes branch (VP required):
Add a third "Start and wait for an approval" action, this time assigned to the VP of Operations. Same pattern for logging and checking the response. After logging:
Set variable: vpDecision = outputs('Start_and_wait_for_an_approval_3')?['body/responses'][0]['approverResponse']
Create an ApprovalLog entry with Stage set to "VP."
Add a Condition on vpDecision:
vpDecision to "Approve" (it's already set, but this confirms the flow path).In the No branch (VP not required):
No approval action needed. The vpDecision variable already defaults to "N/A," which we'll use in the completeness check to confirm that VP sign-off isn't expected.
Both branches then rejoin.
Note
You might be tempted to use a parallel branch structure for the Finance and VP approvals instead of this sequential nesting. Resist that temptation for this scenario. Parallel branches are appropriate when approvers have no dependency on each other — for example, when you need two peers to independently review the same document. Here, Finance can't sensibly approve without knowing the manager approved, and VP is conditional on Finance. Sequential nesting reflects the actual business logic.
For a deeper treatment of when to use parallel versus sequential patterns, Designing Multi-Stage Approval Chains in Power Automate: Sequential, Parallel, and Delegated Sign-Off Patterns is worth reading alongside this lesson.
After the VP condition resolves — both Yes and No branches rejoin — you're at the point where you know every required approver has responded. The flow's sequential structure means you can't reach this point without having gone through all mandatory stages.
But let's make the completeness detection explicit rather than relying purely on structural assumptions. This matters for a few reasons:
Add a "Compose" action to evaluate final status:
Compose — finalDecision:
if(
and(
equals(variables('managerDecision'), 'Approve'),
equals(variables('financeDecision'), 'Approve'),
or(
equals(variables('vpDecision'), 'Approve'),
equals(variables('vpDecision'), 'N/A')
)
),
'Approved',
'Rejected'
)
This expression returns "Approved" if every required approver gave a thumbs-up (accounting for the VP being optional), and "Rejected" otherwise. Even though the sequential structure means a rejection would have terminated early, this explicit check is your safety net.
Now update the PurchaseRequests item with the final status:
Add an "Update item" action on the PurchaseRequests list:
variables('requestID')outputs('Compose_-_finalDecision')Before notifying Teams, add one final SharePoint log entry that records the overall completion of the workflow — not just an individual decision. This gives you a query target if you ever want to report on "all workflows completed this month."
Add a "Create item" action on ApprovalLog:
variables('requestID')outputs('Compose_-_finalDecision')concat('Workflow completed. Manager: ', variables('managerDecision'), ' | Finance: ', variables('financeDecision'), ' | VP: ', variables('vpDecision'))utcNow()This row is a synthetic record — it's not a real person's approval, but it's the most useful query target for reporting. When you build a Power BI report or a SharePoint view showing "completed approvals this week," this is the row you filter on.
For a broader look at managing SharePoint list item lifecycles from a flow, Automating SharePoint List Item Lifecycle Management with Power Automate covers the full pattern including archiving and deletion.
Now the satisfying part. Add a "Post message in a chat or channel" action from the Microsoft Teams connector.
Configure it to post to a Channel (not a chat). Set:
Compose — teamsMessage:
concat(
'<b>Approval Workflow Complete</b><br>',
'<b>Request:</b> ', triggerOutputs()?['body/Title'], '<br>',
'<b>Requestor:</b> ', triggerOutputs()?['body/RequestorName'], '<br>',
'<b>Amount:</b> $', string(triggerOutputs()?['body/AmountUSD']), '<br>',
'<b>Final Decision:</b> ', outputs('Compose_-_finalDecision'), '<br>',
'<br>',
'<b>Approval Chain:</b><br>',
'• Manager (', triggerOutputs()?['body/ManagerEmail'], '): ', variables('managerDecision'), '<br>',
'• Finance Controller: ', variables('financeDecision'), '<br>',
'• VP of Operations: ', variables('vpDecision'), '<br>',
'<br>',
'View the full audit log in <a href="', 'https://yourtenant.sharepoint.com/sites/yoursite/Lists/ApprovalLog', '">SharePoint</a>.'
)
Use the output of this Compose as the Message body in the Teams action.
Tip
Teams channel posts from Power Automate support a subset of HTML: bold (<b>), italic (<i>), line breaks (<br>), and hyperlinks (<a href="">). They do not render full Markdown or complex table HTML. Stick to these tags and test the output — what looks clean in the expression editor can look garbled in the actual channel post if you use unsupported markup.
For more sophisticated Teams integration including Adaptive Cards that let approvers respond directly from the Teams message itself, Implementing Adaptive Card-Based Human-in-the-Loop Approvals in Power Automate is the natural next step.
Also consider notifying the original requestor via email at this point. Add a "Send an email (V2)" action from the Office 365 Outlook connector, addressed to triggerOutputs()?['body/RequestorEmail'], with a plain-language summary of the outcome. This closes the loop for the person who started the process.
You've already added rejection handling after each stage (Update OverallStatus, Terminate). But let's make those rejection notifications consistent — right now they just silently update SharePoint and stop. The requestor doesn't know what happened.
Go back to each rejection branch and add two actions before the Terminate:
concat(
'Your purchase request "', triggerOutputs()?['body/Title'], '" was not approved.\n\n',
'It was rejected at the ', [Stage], ' review stage.\n',
'Reason provided: ', [Comments from the approval response], '\n\n',
'Please contact your manager if you have questions.'
)
You're essentially mirroring the "all approved" notification path for rejections. The symmetry matters — anyone watching the Teams channel should see both outcomes without having to dig into SharePoint.
Warning
If you send a Teams notification from multiple rejection branches AND from the final approval path, make sure all notifications go to the same channel and use a consistent format. A common mistake is posting rejection notifications to the wrong channel or with a different message structure, which makes the channel history confusing to read.
Build the full flow described in this lesson, but extend it with the following additions:
Exercise Part 1 — Reminder on inaction: The Manager approval should send an automatic reminder email if no response is received within 48 hours. Use a "Do Until" loop pattern around the approval action, or explore the built-in reminder functionality in the Approvals connector (the "Request options" section has a "Request options" field where you can configure this). For a deeper pattern on escalation when approvers go silent, Automating Recurring Approval Reminders in Power Automate covers this exactly.
Exercise Part 2 — Dynamic approver lookup:
Instead of hard-coding the Finance Controller's email, add a "Get items" action on a SharePoint list called ApproverDirectory (create it with columns: Role, Email). Filter it for Role eq 'Finance Controller' and use the resulting email address in your approval action. This makes the flow maintainable without editing — you just update the list.
Exercise Part 3 — Filtering the audit log:
After building the flow, create a SharePoint view on the ApprovalLog list filtered to Stage eq 'Complete' and sorted by ResponseTimestamp descending. This gives you a "completed approvals dashboard" in SharePoint with zero additional tooling.
Exercise Part 4 — Test a rejection: Trigger the flow by creating a PurchaseRequest item. When the Manager approval email arrives in Outlook, reject it with a comment. Verify that:
Problem: The flow fails at "Start and wait for an approval" with "Unable to send approval."
This almost always means the email address in the "Assigned to" field is invalid, or the approver's account doesn't have access to the Approvals connector (which requires a Microsoft 365 license). Double-check the email address by fetching it dynamically from Microsoft 365 users if possible, rather than using raw text from the trigger — people mistype their manager's email in forms.
Problem: The flow succeeds but no approval email appears in the approver's Outlook.
Check the approver's Spam/Junk folder first. The approval emails come from noreply@microsoft.com and some Exchange rules catch them. Alternatively, the approver may have the Microsoft Teams app installed and see the approval card there instead. Both delivery channels are active simultaneously; the approver only needs to respond in one place.
Problem: The responses[0] expression returns an error.
This happens when the approval action was cancelled or when the approval request expired without a response. Add error handling using the "Configure run after" setting on the actions that follow the approval — set them to run on "is successful" only. Then add a parallel branch configured to run "has failed" or "has timed out" that handles the exception gracefully. For a comprehensive pattern on this, Master Error Handling and Retry Patterns in Power Automate for Bulletproof Flows is the reference article.
Problem: The Teams notification posts before all approvers have responded.
This suggests a structural issue in the flow — likely that the Teams action is outside a condition branch and runs regardless of which path was taken. Review your flow structure to confirm the Teams notification only appears after the final if(all approvers responded) Compose. Use the run history to see which path the flow actually took.
Problem: SharePoint log rows appear in the wrong order or with missing data.
Power Automate executes actions sequentially within a branch, so ordering should be deterministic. If rows are missing data, the most likely cause is that the expression referencing the approval response output is incorrect — check the action name in the expression (it should exactly match the action's display name) and verify you're indexing responses[0] correctly for single-approver stages.
Problem: The vpDecision variable is "N/A" even when VP approval was required and they approved.
Check the Set variable action inside the VP approval's Yes branch — make sure the action name in the expression matches the exact display name of your third approval action. Power Automate generates names like Start_and_wait_for_an_approval_3 but if you renamed the action, the expression needs to use the new name. Use the dynamic content picker rather than hand-typing expressions to avoid this.
Problem: The flow runs for 30 days and then times out.
Cloud flows have a maximum run duration of 30 days. If your approval chain involves human decisions that might take weeks, you need to architect differently — using a scheduled flow that polls for status, or breaking the workflow into child flows triggered by SharePoint item updates. This is an architectural constraint you should communicate to business stakeholders when scoping the solution.
Warning
Do not attempt to work around the 30-day timeout by adding delay actions that check for completion. This just means your flow fails at day 30 with a delay action rather than an approval action. The right solution is architectural decomposition into shorter-lived flows with state managed in SharePoint.
Once you have this pattern working, you'll likely need to apply it to multiple request types — IT access requests, vendor onboarding, budget amendments. Rather than copying the entire flow three times, extract the "log to ApprovalLog and send Teams notification" logic into an instant child flow that accepts parameters. The parent flows call it via "Run a Child Flow."
This is the Power Automate equivalent of writing a function instead of copy-pasting code. Changes to the logging format or Teams message template propagate everywhere instead of requiring you to update five flows.
The approach we built hard-codes the number of approval stages. A more flexible architecture stores the approval chain in a SharePoint list: each row represents one stage with columns for Stage Order, Role, Required (boolean), and Threshold Amount. The flow reads this list at runtime, loops through the stages in order, and dynamically generates approval requests. This lets business administrators reconfigure the approval chain without touching the flow.
The tradeoff is complexity — this pattern requires loops, variables to track loop position, and careful handling of the "all stages complete" signal. But it pays off at scale. The lesson on Building a Dynamic Multi-Condition Approval Flow in Power Automate explores the threshold-based routing side of this pattern in depth.
A fully approved purchase request shouldn't just sit in SharePoint — it should trigger the next business process. Depending on your environment, that might mean:
The flow you've built in this lesson ends with a Teams notification, but that's designed to be a stepping stone. The OverallStatus = "Approved" column on the PurchaseRequests list is a natural trigger for a second flow — one that handles fulfillment, not approval. This two-flow architecture keeps each flow focused and testable.
For patterns on connecting multiple Microsoft 365 services in sequence, Chaining Microsoft 365 Connectors in a Single Flow is directly applicable.
Let's recap what you built. You now have a Power Automate flow that:
The architecture decisions we made — initializing variables at the top, logging each decision as a separate row, using the "Terminate" action with "Succeeded" status for expected rejection paths, and separating the approval logic from downstream fulfillment — are all patterns you'll reuse across many workflows.
Where to go from here:
The natural extensions of this lesson are timeout handling (what happens when an approver doesn't respond for a week?) and escalation (who gets notified, and can they delegate?). Automating Time-Sensitive Approval Escalations in Power Automate covers the Do Until loop pattern for SLA enforcement, which plugs directly into the flow architecture you've built here.
If your organization uses Microsoft Forms as the intake mechanism rather than SharePoint direct entry, Automating Microsoft Forms Responses: Collecting, Routing, and Storing Survey Data with Power Automate shows how to use a Forms trigger and route the response data into this same approval pipeline.
The flow you have now is production-ready for low-to-medium volume scenarios. As request volume grows, you'll want to think about how to monitor flow health, handle throttling on SharePoint writes, and manage connections across service accounts. Those concerns belong to the Enterprise Cloud Flows learning path — but you've built the foundation they'll extend.