
Picture this: your organization's procurement team processes 200+ purchase order approvals every week. Right now, an approval email lands in a manager's inbox, they click a link, log into a portal, re-authenticate, find the record, read through context they should have been given in the first place, and finally click Approve or Reject. The whole process takes three minutes per approval when it could take fifteen seconds — if the approval lived directly inside the message, with all the relevant data already surfaced. Multiply that friction across 200 approvals and you've burned ten hours of management time on mechanics alone.
Adaptive Cards solve this problem elegantly. They turn passive notification messages into interactive, data-rich mini-applications that live inside Microsoft Teams channels and Outlook inboxes. When you combine Adaptive Cards with Power Automate's flow orchestration, you get a human-in-the-loop approval architecture that delivers contextual information precisely where the decision-maker already lives, captures structured responses without redirecting anyone to an external system, and routes outcomes back into your business logic automatically. This isn't just cosmetically nicer — it's a fundamentally different approval architecture with meaningful implications for audit trails, compliance, response time, and user adoption.
By the end of this lesson, you will have built a production-ready Adaptive Card approval system from scratch. You'll understand the card schema deeply enough to build dynamic forms that change based on business rules, you'll know how to inject live data from SharePoint, Dataverse, or any API into the card payload at runtime, and you'll handle the gnarly edge cases around response timing, card refreshing, and multi-approver scenarios that catch most practitioners off guard.
What you'll learn:
Before diving in, you should be comfortable with:
body(), outputs(), triggerBody(), and basic string functionsYou do not need prior Adaptive Card experience. We'll build that understanding from first principles.
Before writing a single flow action, you need to understand what an Adaptive Card actually is. An Adaptive Card is a JSON document that describes a UI component in a host-agnostic way. The rendering engine — Teams, Outlook, or any other Adaptive Card host — interprets the JSON and produces the visual output. This matters because the card itself contains no rendering logic — it only describes structure and data.
Here's the minimal skeleton of an Adaptive Card:
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.4",
"body": [],
"actions": []
}
The body array holds display elements — text blocks, images, fact sets, column sets. The actions array holds interactive controls — submit buttons, open URL links, and show card toggles. Understanding the distinction between body and actions is fundamental: body renders information, actions capture decisions.
For approval flows, you'll primarily use three body element types and two action types:
Body elements:
TextBlock — single or multiline text, with weight, size, color, and wrapping controlsFactSet — a two-column key-value layout, perfect for displaying record metadataInput.Text, Input.ChoiceSet, Input.Toggle — form inputs that capture approver responsesAction types:
Action.Submit — collects the current state of all inputs and posts them back to your flowAction.ShowCard — reveals a nested card inline, useful for conditional rejection reasonsHere's a realistic card body that surfaces purchase order context to an approver:
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.4",
"body": [
{
"type": "TextBlock",
"text": "Purchase Order Approval Required",
"weight": "Bolder",
"size": "Large",
"color": "Accent"
},
{
"type": "TextBlock",
"text": "PO-2024-00847 requires your authorization before processing.",
"wrap": true
},
{
"type": "FactSet",
"facts": [
{ "title": "Vendor", "value": "Contoso Supply Co." },
{ "title": "Amount", "value": "$14,750.00" },
{ "title": "Department", "value": "Infrastructure" },
{ "title": "Requested By", "value": "Maria Chen" },
{ "title": "Budget Code", "value": "INFRA-2024-Q3" },
{ "title": "Justification", "value": "Server rack expansion for Q4 capacity planning" }
]
},
{
"type": "Input.ChoiceSet",
"id": "approvalDecision",
"style": "expanded",
"isRequired": true,
"label": "Your Decision",
"choices": [
{ "title": "Approve", "value": "Approved" },
{ "title": "Reject", "value": "Rejected" },
{ "title": "Request More Information", "value": "MoreInfo" }
]
},
{
"type": "Input.Text",
"id": "approverComment",
"label": "Comments (required if rejecting or requesting more information)",
"isMultiline": true,
"placeholder": "Add context for the requester..."
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Submit Decision",
"style": "positive",
"data": {
"actionType": "poApprovalResponse",
"poNumber": "PO-2024-00847"
}
}
]
}
Notice the data property on Action.Submit. This is critical: the data object in an action is merged with the input values when the card is submitted. So when an approver clicks Submit, the response payload your flow receives contains both the input values (approvalDecision, approverComment) and the static data embedded in the action (actionType, poNumber). This is how you correlate a response back to a specific approval request.
Important: The
idproperty on every input element is the key that maps to the response payload. If you forget to setid, the input value is silently dropped from the response. Always verify your input IDs match what your response-handling logic expects.
Static card JSON is fine for demos. In production, every value in that FactSet needs to come from your business data. The right approach is to construct the card JSON as a string expression inside Power Automate, injecting dynamic values from your trigger or upstream actions.
The cleanest architectural pattern is to use a Compose action to build the card payload, then reference its output in the send action. This keeps the card JSON readable and separately maintainable.
Here's how that looks in practice. Assume your flow is triggered when a new PO record is created in SharePoint, and you've already retrieved the full PO item and the manager's profile via Graph API. Your Compose action's value would be:
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.4",
"body": [
{
"type": "TextBlock",
"text": "Purchase Order Approval Required",
"weight": "Bolder",
"size": "Large",
"color": "Accent"
},
{
"type": "TextBlock",
"text": "@{concat('Hello ', outputs('Get_Manager_Profile')?['body/displayName'], ', PO-', triggerBody()?['ID'], ' requires your authorization.')}",
"wrap": true
},
{
"type": "FactSet",
"facts": [
{ "title": "Vendor", "value": "@{triggerBody()?['Vendor']}" },
{ "title": "Amount", "value": "@{formatNumber(triggerBody()?['Amount'], 'C', 'en-US')}" },
{ "title": "Department", "value": "@{triggerBody()?['Department']}" },
{ "title": "Requested By", "value": "@{triggerBody()?['RequestorName']}" },
{ "title": "Submitted", "value": "@{formatDateTime(triggerBody()?['Created'], 'MMMM d, yyyy')}" },
{ "title": "Justification", "value": "@{triggerBody()?['Justification']}" }
]
},
{
"type": "Input.ChoiceSet",
"id": "approvalDecision",
"style": "expanded",
"isRequired": true,
"label": "Your Decision",
"choices": [
{ "title": "Approve", "value": "Approved" },
{ "title": "Reject", "value": "Rejected" },
{ "title": "Request More Information", "value": "MoreInfo" }
]
},
{
"type": "Input.Text",
"id": "approverComment",
"label": "Comments",
"isMultiline": true,
"placeholder": "Optional context for the requester..."
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Submit Decision",
"style": "positive",
"data": {
"actionType": "poApprovalResponse",
"poId": "@{triggerBody()?['ID']}",
"poNumber": "@{concat('PO-', triggerBody()?['ID'])}",
"flowInstanceId": "@{workflow()?['run/name']}"
}
}
]
}
Notice the flowInstanceId being embedded in the action data. This is how you correlate the card response back to the specific flow run that sent it — essential when you have many concurrent approvals in flight. We'll use this when building the response handler.
Architecture note: Some practitioners build card JSON using string concatenation with
concat(). This works but becomes unmaintainable fast. Using a Compose action with inline expressions keeps the structure visually intact and lets you validate the JSON output in the flow run history without mental reconstruction.
Here's where things get powerful. Suppose POs over $10,000 require an additional acknowledgment checkbox, and POs from new vendors (under 90 days in your system) need a vendor verification flag. You can't hard-code these into a single card — you need to build the card body array dynamically.
The technique is to build sub-arrays conditionally using Compose actions and then merge them:
Compose_BaseBody → always included
Compose_HighValueSection → included if Amount > 10000
Compose_NewVendorSection → included if VendorAge < 90
Compose_FinalBody → union(outputs('Compose_BaseBody'), if(greater(...), outputs('Compose_HighValueSection'), json('[]')), ...)
The union() function in Power Automate expressions merges arrays, and json('[]') produces an empty array when a section shouldn't appear. The merged result becomes your card body.
For the high-value section:
[
{
"type": "TextBlock",
"text": "⚠️ High-Value Authorization Required",
"weight": "Bolder",
"color": "Warning",
"wrap": true
},
{
"type": "Input.Toggle",
"id": "highValueAcknowledged",
"title": "I confirm I have verified this expenditure against Q3 budget allocations",
"valueOn": "true",
"valueOff": "false"
}
]
This approach scales: you can have a library of card section templates as Compose actions and selectively include them based on runtime data. Your card schema stays clean because each section is independently manageable.
Teams is the primary channel for synchronous approvals in most Microsoft 365 organizations. The Power Automate Teams connector offers two relevant actions: Post a card in a chat or channel and Post an Adaptive Card and wait for a response. These are architecturally very different, and choosing the wrong one is one of the most common mistakes in this space.
Post a card in a chat or channel is fire-and-forget. The card goes out, but Power Automate has no native mechanism to wait for a response. You'd need a separate trigger flow to catch the response — more complex, but appropriate for non-blocking scenarios or when you're building your own response correlation system.
Post an Adaptive Card and wait for a response is the synchronous pattern. Power Automate pauses the flow execution (the run enters a waiting state, not consuming compute) until the approver responds or the timeout expires. This is what you want for sequential approval chains.
The action has several fields that trip people up:
outputs('Compose_CardPayload')The action outputs a body object containing whatever the approver submitted. You access the individual fields like this:
body('Post_adaptive_card_and_wait_for_a_response')?['approvalDecision']
body('Post_adaptive_card_and_wait_for_a_response')?['approverComment']
body('Post_adaptive_card_and_wait_for_a_response')?['highValueAcknowledged']
Note that every input ID from your card becomes a direct property on the response body. The static data from the Submit action is also merged in, so actionType, poId, and flowInstanceId are there too.
Every "wait for response" action in Power Automate has an implicit timeout. For Teams cards, this defaults to 24 hours, but you can override it with flow run settings or by implementing your own timeout logic using a parallel branch.
Here's the timeout pattern you should actually use:
Build a parallel branch structure after sending the card:
Use a scope around each branch, then a condition after the parallel gateway to detect which path completed. The variable you set to track outcome tells you whether you got a real response or a timeout-driven escalation.
Warning: Power Automate flows can run for up to 30 days. Cards submitted after the 24-hour default Teams timeout will appear to succeed visually to the user but the flow will not receive the response. Always communicate the response window clearly in the card body text.
Outlook Adaptive Card support (called Actionable Messages) has a meaningfully different architecture than Teams. Understanding the difference prevents a lot of debugging pain.
In Teams, the card interaction is handled entirely within the Microsoft 365 infrastructure — the response goes directly to Power Automate via the connector's callback mechanism. In Outlook, Actionable Messages require an originator ID registered with Microsoft, and responses are sent to a webhook endpoint you specify. This means the Outlook path requires more setup but gives you more control over the response handling endpoint.
For most Power Automate scenarios, you'll use the Send an email with options action or the Post an Adaptive Card in an email action — but these have important limitations. The email-based card actions only support a predefined set of response types and don't let you use arbitrary form inputs the way Teams cards do.
If you need full Adaptive Card form inputs in Outlook (custom text fields, choice sets, toggles), you need to:
hideOriginalBody property and appropriate Outlook-compatible schema elementsFor many enterprise approval scenarios, the pragmatic answer is: use Teams for interactive approvals and use Outlook only for simple approve/reject notifications. If your approvers are not Teams users, the full Outlook Actionable Message path is necessary, but budget extra implementation time for it.
For the common case where you need a richer experience than "approve/reject" but simpler than full Outlook Actionable Messages, use the Send Email (V2) action with an HTML body containing a prominent Teams deep link button. The card lives in Teams; the email is just a notification and redirect mechanism.
This hybrid approach works well in practice because it meets approvers where they are while keeping all the state management in Teams where the flow infrastructure handles it cleanly.
Once you have a card response, the real work begins. Your response handler needs to:
Here's the flow structure for the response handling section:
[Teams Card Response received]
↓
[Compose: Parse Response Body]
↓
[Switch on approvalDecision]
├── Case "Approved"
│ ├── Update SharePoint PO item: Status = "Approved"
│ ├── Write Audit Log entry (SharePoint list or Dataverse table)
│ ├── HTTP POST to Finance API (create PO in ERP)
│ └── Send approval confirmation email to requestor
├── Case "Rejected"
│ ├── Update SharePoint PO item: Status = "Rejected"
│ ├── Write Audit Log entry
│ └── Send rejection email to requestor with approver comments
└── Case "MoreInfo"
├── Update SharePoint PO item: Status = "Pending Info"
├── Write Audit Log entry
├── Send email to requestor requesting information
└── Trigger sub-flow: Wait for requestor response and re-initiate approval
The audit log deserves particular attention. Every approval system in a regulated environment needs an immutable record of who decided what and when. Your audit log entry should include:
{
"PONumber": "PO-2024-00847",
"Decision": "Approved",
"ApproverEmail": "manager@contoso.com",
"ApproverDisplayName": "James Whitfield",
"DecisionTimestamp": "2024-09-15T14:23:07Z",
"CardSentTimestamp": "2024-09-15T13:45:00Z",
"ResponseTimeMinutes": 38,
"ApproverComment": "Confirmed against Q3 infrastructure budget.",
"FlowRunId": "08585432...",
"CardPayloadHash": "sha256:abc123..."
}
The ResponseTimeMinutes field is valuable for SLA reporting. The CardPayloadHash is a more advanced practice — hashing the card payload that was sent allows you to prove the approver saw exactly the data you claim they saw, which matters in disputes. Compute this with the base64 and uriComponentToBase64 expressions before sending the card.
One gap developers hit quickly: the Teams "wait for response" action doesn't directly return the responder's email address in the body. It returns it in a separate output property. Access it like this:
outputs('Post_adaptive_card_and_wait_for_a_response')?['body/responder/email']
outputs('Post_adaptive_card_and_wait_for_a_response')?['body/responder/displayName']
These are different from the card input values, which come from body(...). This separation trips up developers who look only at the body and wonder why the approver identity is missing.
Security note: Never trust the
datapayload of the card action as proof of approver identity. TheflowInstanceIdembedded in the action data could theoretically be known to someone other than the intended approver in a channel scenario. Always use theresponderoutput from the flow action as the authoritative identity source.
Single-approver scenarios are straightforward. Production approval workflows often require sequential multi-step approvals (manager, then director, then VP for amounts over certain thresholds) or parallel multi-approver scenarios (all team leads must approve). These require deliberate architectural choices.
The cleanest pattern for sequential approvals is a loop over an array of approvers, where each iteration sends a card and waits for a response before proceeding. Build your approver chain as an array in a Compose action:
[
{ "email": "manager@contoso.com", "role": "Direct Manager", "threshold": 0 },
{ "email": "director@contoso.com", "role": "Department Director", "threshold": 5000 },
{ "email": "vp@contoso.com", "role": "VP Finance", "threshold": 25000 }
]
Filter this array at runtime based on the PO amount, then loop through the filtered array with an Apply to Each. Inside the loop:
Early exit from Apply to Each in Power Automate is done by wrapping the card send action in a condition that checks your rejection flag variable. Power Automate doesn't have a native break statement, so the flag pattern is the standard workaround.
Parallel approvals where any one approval is sufficient are trickier because you need to send multiple cards simultaneously and capture the first response, then invalidate the remaining cards. Here's the architecture:
The card invalidation step is important for UX — you don't want three approvers all acting on the same request. After the first decision comes in, use the Update a card in a chat or channel action to replace the remaining live cards with a "This approval has already been processed" message.
When all approvers must respond and you need to aggregate results, send all cards simultaneously using parallel branches, then use a barrier synchronization pattern: each branch writes its response to a SharePoint list row, and a loop polls the list until all expected responses have arrived. This is more complex but handles cases where the number of approvers is dynamic (e.g., all members of a SharePoint group).
One of the most important UX details: after an approver submits a card in Teams, what do they see? Without explicit card update logic, Teams shows a spinner briefly and then... nothing. The interactive card remains in place, still looking submittable.
The "Post Adaptive Card and wait for a response" action handles this via its Update message and Should update card settings. When both are configured, Teams automatically replaces the card with the update message text after submission. But this is a plain-text replacement — you lose the formatting of the original card.
For a better experience, update the card with a formatted Adaptive Card confirmation instead of plain text. You do this by using the Update a card in a chat or channel action after capturing the response, passing a new card JSON that shows the decision made:
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.4",
"body": [
{
"type": "TextBlock",
"text": "✅ Decision Recorded",
"weight": "Bolder",
"size": "Medium",
"color": "Good"
},
{
"type": "FactSet",
"facts": [
{ "title": "Decision", "value": "Approved" },
{ "title": "Recorded at", "value": "September 15, 2024 at 2:23 PM" },
{ "title": "Reference", "value": "PO-2024-00847" }
]
},
{
"type": "TextBlock",
"text": "The requester has been notified. No further action needed.",
"wrap": true,
"color": "Default",
"size": "Small"
}
]
}
This requires storing the messageId from the original card send action output. Access it via:
outputs('Post_adaptive_card_and_wait_for_a_response')?['body/messageId']
Store this in a variable at send time so it's available after the response arrives.
Build a complete purchase order approval flow with the following specifications:
Fabrikam Manufacturing receives POs via a SharePoint list. POs under $5,000 need one-level approval (direct manager). POs between $5,000 and $25,000 need two-level approval (manager, then department head). POs over $25,000 need two-level approval plus a high-value acknowledgment checkbox. All approvals route through Teams DMs, with a 4-hour SLA enforced by automatic escalation to the approver's manager if no response is received.
Create a SharePoint list called PurchaseOrders with columns: PONumber (text), Vendor (text), Amount (number), RequestorEmail (text), RequestorName (text), Justification (multi-line text), Status (choice: Pending/Approved/Rejected/Escalated/MoreInfo), ApprovalLevel (number), ManagerEmail (text), DeptHeadEmail (text).
Create a second list called POApprovalLog with columns: PONumber, Approver, Decision, Comments, Level, Timestamp, FlowRunId.
PurchaseOrderstriggerBody()?['RequestorEmail'] to retrieve manager chainapprovalDecision (string), currentApproverEmail (string), cardMessageId (string)Create separate Compose actions for:
Compose_BaseCardBody: The standard FactSet and decision inputsCompose_HighValueSection: The high-value acknowledgment toggle (conditional)Compose_FinalCardBody: Uses if() to merge base + optional sectionsFor the final body merge, use an expression like:
if(
greater(triggerBody()?['Amount'], 25000),
union(outputs('Compose_BaseCardBody'), outputs('Compose_HighValueSection')),
outputs('Compose_BaseCardBody')
)
approvalDecision variable and write the log entryaddHours(variables('cardSentTime'), 4)approvalDecisionPOApprovalLogPOApprovalLog with correct timestampsSymptom: The flow stays in running state indefinitely. Cause 1: The approver submitted the card but the flow's callback URL expired (this happens if the flow has been in waiting state for over 30 days — essentially impossible to hit in normal operations, but testable by checking the flow run's wait start time). Cause 2: The card was sent to a channel, and someone other than the intended approver tried to submit it. The Teams connector's "wait for response" in channel mode only accepts responses from the originally specified recipient. If a channel member clicks Submit first, the flow may behave unpredictably. Resolution: Use DM (chat with Flow bot) mode for all accountable approval scenarios.
Symptom: Your input values (approvalDecision, approverComment) come back as null.
Cause: The id property is missing from the input elements in your card JSON, or the property name in your expression doesn't match the id value exactly (including case sensitivity).
Resolution: Always validate the card in the Adaptive Cards Designer before wiring it into your flow. Check the flow run history's action outputs to see the raw response body.
Symptom: The "Post adaptive card" action fails with a 400 or 422 error.
Cause: The card JSON contains invalid characters (unescaped quotes, special characters in dynamic values) or your expression produces malformed JSON.
Resolution: Use the replace() function to escape any string values that may contain double quotes before injecting them into the card JSON: replace(triggerBody()?['Justification'], '"', '\"'). For production systems, sanitize all user-supplied strings before card injection.
Symptom: Teams DM card fails to send. Cause: The Flow bot app is not installed for the target user. This is an IT policy issue in tenants with restricted Teams app installations. Resolution: Have your Teams admin install the Power Automate app for all users via a Teams app setup policy. Alternatively, use a custom Teams app as the sender.
Symptom: Amount shows as 14750 instead of $14,750.00.
Cause: formatNumber() requires explicit culture and format parameters. The function works differently than expected when your tenant locale differs from the format string's target locale.
Resolution: Always specify culture explicitly: formatNumber(triggerBody()?['Amount'], 'C2', 'en-US'). Test with edge values like 0, 999.99, and 1000000.
Symptom: After an approver rejects, the flow sends a card to the next approver anyway. Cause: Apply to Each doesn't support native break. The condition checking the rejection flag must wrap the entire card-send logic, but if the condition check is incorrect or the variable isn't set before the next iteration begins, the loop continues. Resolution: Set the rejection flag variable as the very first action inside the "Rejected" case of your switch, before any other actions. Then wrap all card-send logic in a condition that checks the flag before executing.
Symptom: The confirmation card shows the wrong time or UTC instead of local time.
Cause: utcNow() returns UTC. Power Automate has no automatic timezone conversion.
Resolution: Use convertTimeZone(utcNow(), 'UTC', 'Eastern Standard Time', 'MMMM d, yyyy h:mm tt') with the appropriate IANA timezone for your approvers. If approvers are in multiple timezones, retrieve the approver's timezone from their user profile and use it dynamically.
If you update a card template while approvals are in flight, you can end up with inconsistent response handling. Approvers who received v1 of the card submit with v1 input IDs; your response handler expects v2 input IDs. This creates silent data loss.
The mitigation is to embed the card version as a static value in the action data object and branch your response handler based on the card version received. This is the same pattern used in API versioning and is equally important here.
Dataverse tables offer row-level security, better query performance, and native integration with Power Apps and Power BI — making them superior to SharePoint lists for audit log storage in regulated environments. The tradeoff is that Dataverse requires a premium license. For organizations already on Dataverse (using Dynamics 365 or Power Apps Premium), this is the obvious choice. For others, SharePoint works fine with disciplined column design.
The Teams connector has service protection limits. In high-volume approval scenarios (hundreds of concurrent cards), you can hit throttling errors on the card send action. The connector implements automatic retry with exponential backoff, but if your volume consistently exceeds limits, you should implement a queue-based approach: write pending approvals to a queue (Service Bus or Storage Queue), and a separate flow processes the queue with deliberate pacing. This is a significant architectural shift but necessary at scale.
When you send approval cards to a Teams channel rather than a DM, any channel member can submit the card. This creates an authorization problem. You can partially mitigate it by embedding the expected approver's email in the action data and validating the responder's email against it in your response handler — but this is detection after the fact, not prevention. If you need strict authorization enforcement, DM-based cards are the only secure approach.
You now have a complete, production-ready mental model for Adaptive Card-based approval systems in Power Automate. Let's consolidate what we've covered:
The architecture: Adaptive Cards are JSON documents that describe interactive UI. Power Automate constructs them dynamically by injecting business data into a JSON template, sends them to Teams or Outlook, and captures structured responses via connector-managed callbacks. The flow waits in a suspended state during this time, consuming no compute.
Dynamic card construction: Use Compose actions to build card JSON with inline expressions. Use conditional merging with union() and if() to include or exclude card sections based on runtime business rules. Sanitize all user-supplied strings before injection.
Response handling: The response body contains all input values keyed by their id. The responder identity comes from a separate output path. Always correlate responses to flow instances using an embedded flowInstanceId. Write audit logs with timestamps, responder identity, and decision rationale.
Multi-approver patterns: Sequential chains use Apply to Each with a rejection flag for early exit. Parallel any-wins patterns use parallel branches with card invalidation on first response. All-must-approve patterns use a barrier synchronization via SharePoint or Dataverse polling.
Channels vs. DM, Teams vs. Outlook: Use Teams DMs for accountable, interactive approvals. Use channel posting only for visibility, not accountability. Use Outlook Actionable Messages for full email-native interactive approvals, but budget additional setup time. Use email + Teams deep link for hybrid scenarios.
From here, explore these directions:
Power Apps + Power Automate integration: Build a requestor-facing Power App where users submit POs and can track approval status in real time, with your Adaptive Card flow as the backend.
AI Builder integration: Add a document processing step that extracts PO data from uploaded invoice PDFs using AI Builder's form processing model, pre-populating the SharePoint record before the approval card is sent.
Power BI approval dashboards: Connect your POApprovalLog list to Power BI for SLA analysis — average approval time by department, rejection rates by vendor category, escalation frequency trends.
Custom Teams app with Bot Framework: For very high-volume or highly customized scenarios, consider a custom Teams bot that handles card responses natively and uses the Bot Framework's built-in card infrastructure. This removes dependency on Power Automate's connector limits.
Azure Logic Apps for enterprise scale: The same Adaptive Card patterns work in Azure Logic Apps, with all the additional scalability, networking, and governance controls that come with Azure. If your organization's approval volumes or compliance requirements push against Power Automate's boundaries, Logic Apps is the natural evolution.
The most important thing to internalize is that human-in-the-loop approval systems are process infrastructure. They're not automations in the traditional sense — they're structured interfaces between your automated systems and human judgment. Getting the architecture right means decisions are fast, auditable, and made by the right people with the right information. That's the standard to build toward.
Learning Path: Flow Automation Basics