Skip to content

Power Automate Flow Setup

Phase 3 — OPTIONAL. Complete Phase 1 (POC bar) and Phase 2 (Operationalize) of the POC Quickstart first. The PowerShell sync alone is enough for a working POC.

⛔ Mutually exclusive with the Phase 1 PowerShell webhook. Running both notification paths at the same time produces duplicate Teams alerts because fsi_notifiedon provides per-row idempotency within one path only — not cross-path deduplication. Before enabling this flow:

  1. Clear $env:MCM_TEAMS_WEBHOOK_URL on every host that runs Invoke-MessageCenterSync.ps1:
    Remove-Item Env:\MCM_TEAMS_WEBHOOK_URL -ErrorAction SilentlyContinue
    
  2. Re-run Test-McmPrerequisites.ps1 — check 11 (mutual-exclusion) must report PASS before you continue building the flow.

This guide walks through creating the Message Center Monitor flow in Power Automate.

Overview

The flow: 1. Runs on a daily schedule 2. Calls Microsoft Graph API to get Message Center posts 3. Upserts posts to Dataverse 4. Sends Teams notifications for high-severity posts

Prerequisites

Before starting, ensure you have:

  • Microsoft Entra ID app registration with ServiceMessage.Read.All permission
  • Admin consent granted for the app (any administrator with permission to consent)
  • Client ID and Tenant ID ready; client secret only when using the legacy cloud-flow fallback, preferably retrieved from Key Vault
  • Dataverse table created (see README.md)
  • Azure Key Vault with the legacy client secret stored, if using the cloud-flow fallback

Step 1: Create a New Flow

  1. Go to make.powerautomate.com
  2. Click Create > Scheduled cloud flow
  3. Name: Message Center Monitor
  4. Set schedule:
  5. Start: Today
  6. Repeat every: 1 Day
  7. At: 9:00 AM (or your preferred time)
  8. Click Create

Concurrency Control: To prevent duplicate processing when a manual test run overlaps with a scheduled run, configure the Recurrence trigger's concurrency setting. Click the trigger's ... menu > Settings > enable Concurrency Control and set Degree of Parallelism to 1. This means only one instance of the flow runs at a time — if a second run is triggered while one is in progress, it queues instead of running in parallel.

Authentication note: Power Automate cloud flows do not natively support managed identity. For production deployments where managed-identity-first authentication is required by policy, run this workload in Logic Apps Standard or an Azure Function with a system-assigned managed identity instead. The remaining steps assume the client-secret + Key Vault path that is the supported fallback for cloud flows.

If using Azure Key Vault:

  1. Add action: Azure Key Vault - Get secret
  2. Configure:
  3. Vault name: Your Key Vault name
  4. Secret name: Your client secret name
  5. The output value contains your client secret

If not using Key Vault, direct secret entry in the HTTP action is a legacy non-prod fallback and is not recommended for production.

Step 3: Call Microsoft Graph API

Add action: HTTP with Microsoft Entra ID (preauthorized). In older designers this may appear as HTTP with an OAuth authentication panel; bind the connection reference fsi_cr_http_messagecenter to the current HTTP with Microsoft Entra ID connector.

Configure: - Method: GET - URI: https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/messages?$select=id,title,category,severity,services,startDateTime,endDateTime,lastModifiedDateTime,isMajorChange,actionRequiredByDateTime,body,tags,hasAttachments - Microsoft Entra ID resource / Audience: https://graph.microsoft.com - Tenant: Your tenant ID (GUID) - Client ID: Your app registration client ID - Credential Type: Secret (legacy cloud-flow fallback) - Secret: From Key Vault output; avoid direct entry except in disposable non-prod validation - Retry policy: Change from default to Fixed interval — Count: 3, Interval: PT30S (30 seconds). This handles transient Graph API errors (429 throttling, 503 service unavailable) without manual re-runs.

Microsoft Graph endpoint note: The Message Center API is available in Microsoft Graph v1.0 at /admin/serviceAnnouncement/messages; beta is not required for this solution. The separate healthOverviews and issues endpoints require ServiceHealth.Read.All and are intentionally out of scope unless you add service-health monitoring.

Authentication Screenshot Reference

┌─────────────────────────────────────────────┐
│ Authentication                              │
├─────────────────────────────────────────────┤
│ Connector: HTTP with Microsoft Entra ID     │
│                                             │
│ Tenant:      xxxxxxxx-xxxx-xxxx-xxxx-xxxx   │
│ Audience:    https://graph.microsoft.com    │
│ Client ID:   xxxxxxxx-xxxx-xxxx-xxxx-xxxx   │
│ Credential Type: Secret (legacy fallback)   │
│ Secret:      [Key Vault output]             │
└─────────────────────────────────────────────┘

Step 4: Parse JSON Response

Add action: Parse JSON

  • Content: @{body('HTTP')}
  • Schema: Click "Generate from sample" and paste this sample:
{
  "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#admin/serviceAnnouncement/messages",
  "@odata.nextLink": "https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/messages?$skip=100",
  "value": [
    {
      "id": "MC123456",
      "title": "Sample Message Center Post",
      "category": "planForChange",
      "severity": "normal",
      "services": ["Microsoft 365 suite"],
      "startDateTime": "2026-01-01T00:00:00Z",
      "endDateTime": "2026-02-01T00:00:00Z",
      "lastModifiedDateTime": "2026-01-01T12:00:00Z",
      "isMajorChange": false,
      "actionRequiredByDateTime": null,
      "body": {
        "contentType": "html",
        "content": "<p>Message content here</p>"
      },
      "tags": ["Feature update"],
      "hasAttachments": false
    }
  ]
}

Note: The @odata.nextLink field appears when there are more results. Include it in your schema so Parse JSON doesn't fail when pagination is present.

Current Message Center categorization

Microsoft Graph serviceUpdateMessage returns category values planForChange, stayInformed, preventOrFixIssue, and unknownFutureValue; severity values are normal, high, critical, and unknownFutureValue. The Switch mappings below handle the documented values and default unknown future values to Admin/Normal for safe triage.

Message Center UI filtering is based on Service, Tag, and Message state. Tags currently include Admin impact, Data privacy, Feature update, Major update, New feature, Retirement, User impact, and Updated message. Graph returns services[] and tags[] as Microsoft-controlled strings, so route Copilot Studio, Power Platform, or other AI-related posts through configurable rules instead of hard-coding a closed service list.

Step 5: Handle Pagination (Important)

Microsoft Graph API returns paged results. Without handling pagination, you may only get the first page (~100 posts) and miss older messages.

When more results exist than fit in one response, Graph API includes @odata.nextLink - a URL to fetch the next page. You must loop until this value is absent.

Page Size: The default page size is 100 items. You can request up to 1000 items per page by adding the Prefer: odata.maxpagesize=1000 header to your HTTP request. This reduces the number of API calls needed for tenants with many posts.

Pattern: Do Until Loop

  1. Initialize variable nextLink (String) = https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/messages?$select=id,title,category,severity,services,startDateTime,endDateTime,lastModifiedDateTime,isMajorChange,actionRequiredByDateTime,body,tags,hasAttachments
  2. Initialize variable allMessages (Array) = []
  3. Do Until @empty(variables('nextLink')):
  4. HTTP GET to @{variables('nextLink')} (with same authentication and retry policy: Fixed interval, Count: 3, Interval: PT30S — same as the Step 3 HTTP action, to handle transient 429/503 errors during pagination)
  5. Set variable allMessages = @{union(variables('allMessages'), body('HTTP_2')?['value'])}

Warning: Do NOT use "Append to array" here. Append to array adds a single element, so it would create a nested array [[page1_msgs], [page2_msgs]] instead of a flat list. Use Set variable with union() to merge page results correctly.

Action naming: If you already added an HTTP action in Step 3, Power Automate auto-renames this second HTTP action to HTTP_2. The expressions above use body('HTTP_2') to reflect this. If you delete the Step 3 HTTP action and use only the Do Until pattern, rename references back to body('HTTP'). Always verify the action name in your flow matches the expressions.

Loop Limits: Configure the Do Until's Limits settings (click the menu > Settings on the Do Until action). Set Count to 60 (maximum iterations) and Timeout to PT1H (1 hour). While these are Power Automate's defaults, setting them explicitly prevents runaway execution if the API returns malformed @odata.nextLink values that never resolve to empty.

  • Set variable nextLink = @{coalesce(body('HTTP_2')?['@odata.nextLink'], '')}
  • Process allMessages in the Apply to each loop

Simplified Flow Diagram

┌─ Initialize nextLink = Graph URL
├─ Initialize allMessages = []
├─ Do Until (nextLink is empty)
│   ├─ HTTP GET nextLink
│   ├─ Set allMessages = union(allMessages, value)
│   └─ Set nextLink = @odata.nextLink (or empty)
└─ Apply to each (allMessages)
    └─ Process message...

Note: For new deployments, this pattern is recommended. However, if you're processing daily and your tenant has fewer than 100 active posts, the basic single-request approach will work.

Parse JSON with Pagination

When using the pagination pattern above, the allMessages variable is a flat array of message objects (not wrapped in a { "value": [...] } envelope). If you want to validate the flat array with Parse JSON, use this schema instead:

{
  "type": "array",
  "items": {
    "type": "object",
    "properties": {
      "id": { "type": "string" },
      "title": { "type": "string" },
      "category": { "type": "string" },
      "severity": { "type": "string" },
      "services": { "type": "array", "items": { "type": "string" } },
      "startDateTime": { "type": "string" },
      "endDateTime": { "type": ["string", "null"] },
      "lastModifiedDateTime": { "type": "string" },
      "isMajorChange": { "type": "boolean" },
      "actionRequiredByDateTime": { "type": ["string", "null"] },
      "body": {
        "type": "object",
        "properties": {
          "contentType": { "type": "string" },
          "content": { "type": "string" }
        }
      },
      "tags": { "type": "array", "items": { "type": "string" } },
      "hasAttachments": { "type": "boolean" }
    }
  }
}

Tip: Parse JSON is optional when using pagination—the Apply to each loop can directly iterate over allMessages. Use Parse JSON only if you want schema validation or IntelliSense for field names in subsequent actions.


Step 6: Loop Through Messages

Add action: Apply to each

⚠️ Keep execution sequential (default). Do not enable parallel execution ("Concurrency Control") on this Apply to each loop. The loop uses a non-atomic read-then-update pattern: it reads notifiedOn from the upsert response, checks if null, then sends a Teams notification and updates notifiedOn. If parallel execution is enabled, two iterations could both read notifiedOn == null for the same post before either writes the update, causing duplicate Teams notifications.

  • Select an output from previous steps: @{variables('allMessages')} (if using pagination) or @{body('Parse_JSON')?['value']} (basic)

Inside the loop, add these actions:

6a: Upsert to Dataverse

Recommended: Alternate Key Approach

The schema script scripts/create_mcm_dataverse_schema.py already provisions an alternate key (EntityKey on fsi_messagecenterid) when you run it with --output-docs. No manual key creation in the maker UI is required for tenants deployed via the script. If you deployed an earlier schema version manually, re-run the script to provision the key.

Use the Dataverse — Update or insert (upsert) a row action:

  1. Table: Message Center Logs (entity set fsi_messagecenterlogs)
  2. Alternate Key column: fsi_messagecenterid
  3. Alternate Key value: @{items('Apply_to_each')?['id']}

The Web API resolves this to the canonical alternate-key URL:

…/api/data/v9.2/fsi_messagecenterlogs(fsi_messagecenterid='<MC######>')

This replaces the List + Condition logic.

Alternative: Manual Check Pattern

If you cannot use the upsert action (e.g., the alternate key has not propagated yet, or you are on a region that does not support the action), use this pattern:

  1. Add action: Dataverse - List rows
  2. Table: Message Center Logs
  3. Filter: fsi_messagecenterid eq '@{items('Apply_to_each')?['id']}'

  4. Add Condition: Check if row exists

  5. If yes: Update a row
  6. If no: Add a row

Duplicate prevention with List rows: When using this pattern instead of upsert, check the notifiedOn field from the List rows response in your notification condition: equals(first(outputs('List_rows')?['body/value'])?['fsi_notifiedon'], null). If no row exists yet (new post), first() returns null and the condition is satisfied, allowing the first notification.

Field mappings for Add/Update/Upsert:

Naming: Column shown is the logical name (lowercased, no underscores between words). The Power Automate column picker may display a different label (Display Name) — confirm the underlying logical name matches before saving.

Dataverse Column Expression
fsi_messagecenterid @{items('Apply_to_each')?['id']}
fsi_title @{items('Apply_to_each')?['title']}
fsi_category Integer from Switch (see Choice Field Implementation with Switch below)
fsi_severity Integer from Switch (see Choice Field Implementation with Switch below)
fsi_services @{join(coalesce(items('Apply_to_each')?['services'], json('[]')), ', ')}
fsi_startdatetime @{items('Apply_to_each')?['startDateTime']}
fsi_actionrequiredbydatetime @{items('Apply_to_each')?['actionRequiredByDateTime']}
fsi_lastmodifieddatetime @{items('Apply_to_each')?['lastModifiedDateTime']}
fsi_enddatetime @{items('Apply_to_each')?['endDateTime']}
fsi_ismajorchange @{items('Apply_to_each')?['isMajorChange']}
fsi_body @{coalesce(items('Apply_to_each')?['body']?['content'], '')}
fsi_tags @{join(coalesce(items('Apply_to_each')?['tags'], json('[]')), ', ')}
fsi_hasattachments @{items('Apply_to_each')?['hasAttachments']}
fsi_assessmentstatus 100000000 (NotAssessed — set on insert only; do not overwrite on update)

Severity and category mapping: These columns are Choice (Picklist) fields backed by global option sets. Dataverse rejects text labels — bind them to the integer values produced by the Switch blocks below.

6b: Choice Field Implementation with Switch

Category and severity columns are Dataverse Choice (Picklist) fields backed by global option sets — they reject text labels and require the integer values defined in create_mcm_dataverse_schema.py (and reflected in docs/dataverse-schema.md). Use a Switch action to map the Microsoft Graph API enum values to the option-set integers, then write the integer to the Choice column.

Category Switch (writes to fsi_category, option set fsi_MCM_messagecategory):

  1. Add action: Switch
  2. On: @{items('Apply_to_each')?['category']}
  3. Add cases:
  4. Case planForChange: Set variable categoryValue = 100000000 (Feature)
  5. Case stayInformed: Set variable categoryValue = 100000001 (Admin)
  6. Case preventOrFixIssue: Set variable categoryValue = 100000002 (Security)
  7. Default: Set variable categoryValue = 100000001 (Admin)

Severity Switch (writes to fsi_severity, option set fsi_MCM_messageseverity):

  1. Add action: Switch
  2. On: @{items('Apply_to_each')?['severity']}
  3. Add cases:
  4. Case high: Set variable severityValue = 100000000 (High)
  5. Case normal: Set variable severityValue = 100000001 (Normal)
  6. Case critical: Set variable severityValue = 100000002 (Critical)
  7. Default: Set variable severityValue = 100000001 (Normal)

The variables are typed as Integer so the Update/Upsert Row action can write them directly to the Choice column. See docs/dataverse-schema.md for the canonical option-set definitions, or create_mcm_dataverse_schema.py:OPTION_SETS for the source of truth.

Step 7: Teams Notification for High Severity

Inside the Apply to each loop, after the Dataverse action:

Add Condition to notify when action is truly needed:

Notification Condition (includes duplicate prevention):

Important: The flow runs daily and re-evaluates ALL posts. Without duplicate prevention, previously notified posts trigger alerts again on every run. The fsi_notifiedon column (defined in docs/dataverse-schema.md) tracks which posts have already been notified.

Action naming: If you delete and re-add the upsert action, Power Automate may rename it to Upsert_a_row_2. Verify the action name in any expression like outputs('Upsert_a_row')?['body/fsi_notifiedon'] matches your flow's actual action name — otherwise the expression resolves to null and duplicate prevention silently breaks.

Option A: Basic Check

@and(
  equals(outputs('Upsert_a_row')?['body/fsi_notifiedon'], null),
  contains(
    split(toLower(<replace-with-fsi_MCM_NotifySeverities-env-var>), ','),
    toLower(items('Apply_to_each')?['severity'])
  )
)

The contains(split(<env-var>, ','), …) pattern reads the comma-separated severity list from the fsi_MCM_NotifySeverities environment variable (e.g., high,critical) instead of hard-coding literal 'high'/'critical' checks. Replace the <replace-with-fsi_MCM_NotifySeverities-env-var> token with whichever expression you use to retrieve the env var value (e.g., outputs('Get_Environment_Variable')?['body/value']).

Note: The Upsert_a_row response body returns the full Dataverse record, including the existing notifiedOn value. Do not use items('Apply_to_each')?['notifiedOn'] — Graph API messages do not contain this field.

OR (visual editor equivalent):

In the condition editor, create the following structure: - Row 1: @{outputs('Upsert_a_row')?['body/fsi_notifiedon']} is equal to null - AND (click "Add group" → OR group): - Row 2a: @{items('Apply_to_each')?['severity']} is equal to high - OR - Row 2b: @{items('Apply_to_each')?['severity']} is equal to critical

Grouping matters: You must nest the two severity rows inside an OR group, then AND that group with the null check. Without explicit grouping, the default evaluation would be (notifiedOn == null AND severity == 'high') OR severity == 'critical', which bypasses duplicate prevention for critical-severity posts.

Option B: Refined Check (Recommended)

Use an expression to only notify when actionRequiredByDateTime is in the future:

@and(
  equals(outputs('Upsert_a_row')?['body/fsi_notifiedon'], null),
  or(
    contains(
      split(toLower(<replace-with-fsi_MCM_NotifySeverities-env-var>), ','),
      toLower(items('Apply_to_each')?['severity'])
    ),
    and(
      not(equals(items('Apply_to_each')?['actionRequiredByDateTime'], null)),
      greater(items('Apply_to_each')?['actionRequiredByDateTime'], utcNow())
    )
  )
)

This prevents notifications for posts with past deadlines and means each post triggers at most one notification.

After sending a Teams notification, add a Dataverse - Update a row action to set fsi_notifiedon to @{utcNow()}. This marks the post as notified so it won't trigger again on the next run. If you later want to re-notify (e.g., deadline approaching), reset fsi_notifiedon to null or add a separate reminder condition.

Optional Enhancement: Also check isMajorChange:

@and(
  equals(outputs('Upsert_a_row')?['body/fsi_notifiedon'], null),
  or(
    contains(
      split(toLower(<replace-with-fsi_MCM_NotifySeverities-env-var>), ','),
      toLower(items('Apply_to_each')?['severity'])
    ),
    equals(items('Apply_to_each')?['isMajorChange'], true),
    and(
      not(equals(items('Apply_to_each')?['actionRequiredByDateTime'], null)),
      greater(items('Apply_to_each')?['actionRequiredByDateTime'], utcNow())
    )
  )
)

If yes:

Add action: Microsoft Teams - Post card in a chat or channel

Note: The action was previously called "Post adaptive card in a chat or channel" but has been renamed. If you see the old name in an existing flow, it should continue to work; new flows should use the current connector action name.

  • Post as: Flow bot
  • Post in: Channel
  • Team: Your team
  • Channel: Your alerts channel
  • Adaptive Card: Use the template from teams-notification-card.json

Replace placeholders in the card with dynamic content: - {title}@{items('Apply_to_each')?['title']} - {severity}@{items('Apply_to_each')?['severity']} - {category}@{items('Apply_to_each')?['category']} - {services}@{join(coalesce(items('Apply_to_each')?['services'], json('[]')), ', ')} - {startDateTime}@{formatDateTime(items('Apply_to_each')?['startDateTime'], 'MMM dd, yyyy')} - {actionRequiredByDateTime}@{if(equals(items('Apply_to_each')?['actionRequiredByDateTime'], null), 'None', formatDateTime(items('Apply_to_each')?['actionRequiredByDateTime'], 'MMM dd, yyyy'))} - {id}@{items('Apply_to_each')?['id']} - {recordId}@{outputs('Upsert_a_row')?['body/fsi_messagecenterlogid']}

Step 8: Error Handling

Configure Run After

For the Apply to each action: 1. Click the three dots (...) > Configure run after 2. Ensure it runs after HTTP succeeds

Add Scope for Error Handling

Wrap the main processing logic in a Scope action for better error handling:

  1. Add Scope action (call it "Try")
  2. Move inside the Try scope:
  3. HTTP action (or Do Until loop for pagination)
  4. Parse JSON action
  5. Apply to each loop (including Dataverse upsert and Teams notification)
  6. Add another Scope after (call it "Catch")
  7. Configure "Catch" to run after "Try" has failed
  8. In "Catch", add a Teams notification for errors:
  9. Post a message using this expression to include the error details and timestamp:

    Message Center Monitor flow failed at @{utcNow()}.
    Error: @{result('Try')?[0]?['error']?['message']}
    Check run history for details.
    
  10. This enables faster operational triage without requiring manual navigation to flow run history.

Why include Apply to each in Try? If a single message fails to process (e.g., malformed data), the entire loop stops. Wrapping it in Try means you get notified of partial failures. For even more granular handling, you can add a nested Try/Catch inside the Apply to each loop to handle individual message failures without stopping the entire run.

Step 9: Save and Test

  1. Click Save
  2. Click Test > Manually > Test
  3. Wait for the flow to complete
  4. Check:
  5. Dataverse table has new rows
  6. Teams channel received notifications (if high-severity posts exist)

Complete Flow Structure

┌─ Recurrence (Daily at 9 AM)
├─ [Scope: Try]
│   ├─ Get secret (Azure Key Vault)
│   ├─ Initialize variables (nextLink, allMessages)
│   │
│   ├─ Do Until (nextLink is empty)
│   │   ├─ HTTP GET nextLink
│   │   ├─ Set allMessages = union(allMessages, value)
│   │   └─ Set nextLink = @odata.nextLink
│   │
│   ├─ Parse JSON (optional, for schema validation)
│   │
│   └─ Apply to each (allMessages)
│       ├─ Upsert row (alternate key)
│       │   OR
│       │   ├─ List rows (check if exists)
│       │   └─ Condition → Add or Update
│       │
│       └─ Condition (high severity OR future action required? AND not yet notified)
│           └─ Yes: Post adaptive card to Teams
│                   Update notifiedOn = utcNow()
└─ [Scope: Catch] (runs on failure)
    └─ Post error notification to Teams

Troubleshooting

HTTP action fails with 401

  • Verify app registration has ServiceMessage.Read.All
  • Confirm admin consent was granted
  • Check tenant ID and client ID are correct
  • Verify client secret hasn't expired

HTTP action fails with 403

  • ServiceMessage.Read.All requires Application permission, not Delegated
  • Admin consent must be granted by an administrator with permission to consent to enterprise applications

Parse JSON fails

  • Check HTTP response for error messages
  • Verify the schema matches the actual response
  • Use "Generate from sample" with real API response

Dataverse actions fail

  • Verify table and column names match exactly
  • Check that choice values are mapped correctly
  • Ensure the connection has sufficient permissions

Teams notifications not appearing

  • Verify the Teams connector is properly authenticated
  • Check that the team and channel exist
  • Review the adaptive card JSON for syntax errors

Rate Limits

Microsoft Graph service communications API calls are throttled per tenant and application. Honor 429 responses and Retry-After headers; the retry policy above handles transient throttling for cloud flows.

Daily polling is well within typical operational needs. Even hourly polling is usually unnecessary because most Message Center posts are planning updates rather than urgent incident signals.


Environment Variables for ALM Portability

The steps above hardcode the polling schedule, severity thresholds, and Teams channel ID directly in the flow. For single-environment use this is fine, but when promoting the solution across environments (dev → test → prod), use Dataverse environment variables so values can be updated per-environment without editing the flow definition.

Recommended environment variables:

Display Name Schema Name Type Example Value
Polling Interval (days) fsi_MCM_PollingIntervalDays Integer 1
Notification Severity Threshold fsi_MCM_NotifySeverities Text high,critical
Teams Channel ID fsi_MCM_TeamsChannelId Text 19:abc123@thread.tacv2
Teams Team ID fsi_MCM_TeamsTeamId Text xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Dataverse URL fsi_MCM_DataverseUrl Text https://org.crm.dynamics.com
Key Vault Secret Name fsi_MCM_KeyVaultSecretName Text MessageCenterClientSecret

Note: Create these in your solution via Solutions > your solution > Add > Environment variable. The env vars are also auto-provisioned by scripts/create_mcm_environment_variables.py.

To reference an environment variable in the flow, use the Dataverse - List rows action to query the Environment Variable Values table, or use the @{outputs('Get_Environment_Variable')?['body/value']} pattern after adding a dedicated lookup action. This aligns with ALM best practices and simplifies managed solution deployments.


Optional Optimizations

Delta Tracking with lastModifiedDateTime

After your first sync, you can filter to only retrieve recently modified posts. This reduces processing time and API payload size.

Pattern:

  1. Store the timestamp of your last successful sync (options):
  2. Dataverse config table row
  3. Environment variable
  4. Flow variable persisted to a file/SharePoint

  5. On subsequent runs, filter the API call:

https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/messages?$filter=lastModifiedDateTime ge 2025-01-25T00:00:00Z
  1. Update the stored timestamp after successful processing

Expression for filter:

$filter=lastModifiedDateTime ge @{variables('lastSyncTime')}

First Run vs. Subsequent Runs:

  • First run: No filter, retrieve all posts
  • Subsequent runs: Filter by lastModifiedDateTime
  • Suggested: Also run a full sync weekly to catch any edge cases

Combining Optimizations

For busy tenants, combine pagination with delta tracking:

┌─ Get lastSyncTime from config
├─ Set API URL with $filter parameter
├─ Do Until (pagination loop)
│   └─ Process new/updated messages
└─ Update lastSyncTime in config

This approach minimizes both API calls and processing time while ensuring you don't miss any posts.