Back to Home
M365 Message Center Automated Export
Microsoft 365, Security23. April 2026

M365 Message Center Automated Export

Drago Petrovic

Drago Petrovic

Microsoft MVP

Microsoft Graph API · Service Communications

Complete technical documentation for retrieving and persisting Microsoft 365 Message Center notifications via the Microsoft Graph API into a SQL database.

Graph API v1.0 ServiceMessage.Read.All Azure App Registration SQL / Azure SQL

The Graph API Endpoint

Microsoft has fully migrated the older manage.office.com Service Communications API into the Microsoft Graph API. All Message Center notifications are now accessible via the following stable v1.0 endpoint:

GET https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/messages

Returns all Message Center entries for the tenant. Supports OData filtering, pagination, and field selection.

Note: The old manage.office.com/api/v1.0/{tenant}/ServiceComms/Messages endpoint is officially retired and should no longer be used.

Authentication (OAuth 2.0 Client Credentials)

For automated, non-interactive exports (e.g. as a Scheduled Task or Azure Function) use the Client Credentials Flow — no signed-in user required.

// Token request to Microsoft Identity Platform
POST https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token

Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id={CLIENT_ID}
&client_secret={CLIENT_SECRET}
&scope=https://graph.microsoft.com/.default

The returned access_token is then passed as a Bearer token in every Graph API request:

Authorization: Bearer {access_token}

Required Permissions

The App Registration in Microsoft Entra ID (Azure AD) requires the following API permissions:

Permission Type Purpose Status
ServiceMessage.Read.All Application Read Message Center notifications Required
ServiceHealth.Read.All Application Read Service Health status Optional
ServiceMessageViewpoint.Write Application Mark messages as read / archive them Optional
Admin Consent required! Application Permissions always require explicit Admin Consent from a Global Administrator or Privileged Role Administrator in the tenant.

OData Query Parameters

The Graph API supports powerful OData parameters for filtered and optimised queries:

// Full example query with all relevant parameters
GET https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/messages
  ?$select=id,title,category,severity,tags,startDateTime,
          endDateTime,services,details,body,isMajorChange,
          actionRequiredByDateTime,lastModifiedDateTime
  &$filter=startDateTime ge 2024-01-01
          and services/any(p:p in ('Microsoft 365', 'Exchange Online', 'Teams'))
  &$orderby=startDateTime desc
  &$top=100
  &$count=true
??
$filter
Filter by date, service, category or tags. Supports ge, le, eq, any().
??
$select
Restricts returned fields — significantly reduces payload size and processing time.
??
$top & @odata.nextLink
Pagination. When more than 100 entries exist, the API returns a nextLink for the next page.
??
$orderby
Sort by startDateTime, lastModifiedDateTime, etc.

Available Fields

Every Message Center notification contains the following properties. The body field contains HTML-formatted text and must be sanitised before storing in SQL.

// Example Graph API response (abbreviated)
{
  "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#...",
  "@odata.count": 482,
  "value": [
    {
      "id":                     "MC123456",
      "title":                  "New feature: Copilot in Teams",
      "category":               "planForChange",
      "severity":               "normal",
      "isMajorChange":          false,
      "startDateTime":          "2024-03-15T08:00:00Z",
      "endDateTime":            "2024-06-15T08:00:00Z",
      "lastModifiedDateTime":   "2024-03-20T14:22:00Z",
      "actionRequiredByDateTime":null,
      "tags":                   ["New feature", "User impact"],
      "services":               ["Microsoft Teams"],
      "details": [
        { "name": "ExternalLink", "value": "https://aka.ms/..." }
      ],
      "body": {
        "contentType": "html",
        "content": "<p>Microsoft is introducing...</p>"
      }
    }
  ]
}

SQL Database Schema

Recommended table structure for Azure SQL / SQL Server. Array fields (services, tags) are stored as JSON to leverage native SQL JSON functions.

CREATE TABLE dbo.MessageCenter
(
    -- Primary key (from Graph API)
    Id                      NVARCHAR(50)   NOT NULL PRIMARY KEY,

    -- Core data
    Title                   NVARCHAR(500)  NOT NULL,
    Category                NVARCHAR(100)  NULL,   -- planForChange, stayInformed, ...
    Severity                NVARCHAR(50)   NULL,   -- normal, high, critical
    IsMajorChange           BIT            NULL,

    -- Timestamps
    StartDateTime           DATETIME2      NULL,
    EndDateTime             DATETIME2      NULL,
    LastModifiedDateTime    DATETIME2      NULL,
    ActionRequiredByDateTime DATETIME2     NULL,

    -- Array fields stored as JSON
    Services                NVARCHAR(MAX)  NULL,   -- JSON array: ["Teams", "Exchange"]
    Tags                    NVARCHAR(MAX)  NULL,   -- JSON array: ["New feature"]
    Details                 NVARCHAR(MAX)  NULL,   -- JSON array with ExternalLinks etc.

    -- Content (raw HTML / plain text)
    BodyHtml                NVARCHAR(MAX)  NULL,   -- Original HTML from body.content
    BodyText                NVARCHAR(MAX)  NULL,   -- HTML tags stripped (plain text)

    -- Audit fields
    ImportedAt              DATETIME2      NOT NULL DEFAULT GETUTCDATE(),
    LastSyncedAt            DATETIME2      NULL,

    -- Constraints
    CONSTRAINT CHK_Services_JSON CHECK (ISJSON(Services) = 1 OR Services IS NULL),
    CONSTRAINT CHK_Tags_JSON     CHECK (ISJSON(Tags)     = 1 OR Tags     IS NULL)
);

-- Indexes for common filter patterns
CREATE INDEX IX_MC_StartDateTime        ON dbo.MessageCenter (StartDateTime DESC);
CREATE INDEX IX_MC_Category             ON dbo.MessageCenter (Category);
CREATE INDEX IX_MC_LastModifiedDateTime  ON dbo.MessageCenter (LastModifiedDateTime DESC);
JSON queries in SQL: Use JSON_VALUE and OPENJSON to filter array fields directly in SQL — e.g. WHERE JSON_VALUE(Services, '$[0]') = 'Microsoft Teams' or via CROSS APPLY OPENJSON(Tags) for full normalisation.

Export Options in Detail

Option A – PowerShell Script

The simplest solution for IT admins without a development background. The Microsoft Graph PowerShell SDK provides the Get-MgServiceAnnouncementMessage cmdlet. The script can be scheduled as a Windows Task Scheduler job or Azure Automation Runbook.

Complexity
Low — PowerShell only
Setup Time
~1–2 hours
Scalability
Medium
#Requires -Modules Microsoft.Graph.Identity.SignIns, SqlServer

## -- Configuration ------------------------------------------
$TenantId     = "YOUR-TENANT-ID"
$ClientId     = "YOUR-CLIENT-ID"
$ClientSecret = "YOUR-CLIENT-SECRET"  # Better: Key Vault
$SqlServer    = "yourserver.database.windows.net"
$SqlDb        = "M365Monitoring"
$DaysBack     = 7  # How many days back to retrieve

## -- Authentication -----------------------------------------
$SecureSecret = ConvertTo-SecureString $ClientSecret -AsPlainText -Force
$Credential   = New-Object System.Management.Automation.PSCredential($ClientId, $SecureSecret)
Connect-MgGraph -TenantId $TenantId -ClientSecretCredential $Credential

## -- Fetch messages -----------------------------------------
$SinceDate = ((Get-Date).AddDays(-$DaysBack)).ToString("s") + "Z"
$Messages = Get-MgServiceAnnouncementMessage `
    -Filter "startDateTime ge $SinceDate" `
    -All  # Automatic pagination
Write-Host "Retrieved: $($Messages.Count) messages"

## -- Write to SQL (UPSERT) ----------------------------------
foreach ($msg in $Messages) {
    $bodyText = $msg.Body.Content -replace '<[^>]+>', ''
    $services = $msg.Services | ConvertTo-Json -Compress
    $tags     = $msg.Tags     | ConvertTo-Json -Compress
    $sql = @"
MERGE dbo.MessageCenter AS target
USING (VALUES (N'$($msg.Id)', N'$($msg.Title -replace "'","''")',
    N'$($msg.Category)', N'$($msg.Severity)',
    $($msg.IsMajorChange.ToString().ToUpper()),
    '$($msg.StartDateTime)', '$($msg.LastModifiedDateTime)',
    N'$($services -replace "'","''")', N'$($tags -replace "'","''")',
    N'$($bodyText -replace "'","''")'
)) AS source (Id,Title,Category,Severity,IsMajorChange,
    StartDateTime,LastModifiedDateTime,Services,Tags,BodyText)
ON target.Id = source.Id
WHEN MATCHED THEN UPDATE SET
    Title=source.Title, LastModifiedDateTime=source.LastModifiedDateTime,
    BodyText=source.BodyText, LastSyncedAt=GETUTCDATE()
WHEN NOT MATCHED THEN INSERT
    (Id,Title,Category,Severity,IsMajorChange,StartDateTime,
     LastModifiedDateTime,Services,Tags,BodyText,ImportedAt)
VALUES (source.Id,source.Title,source.Category,source.Severity,
    source.IsMajorChange,source.StartDateTime,source.LastModifiedDateTime,
    source.Services,source.Tags,source.BodyText,GETUTCDATE());
"@
    Invoke-Sqlcmd -ServerInstance $SqlServer -Database $SqlDb `
                  -Query $sql -AccessToken (Get-AzAccessToken -ResourceUrl "https://database.windows.net").Token
}
Write-Host "? Sync complete"
Disconnect-MgGraph

Option B – Power Automate (Low-Code)

Ideal for organisations with Power Platform licences. No code deployment, no server — runs entirely in the Microsoft Cloud.

Complexity
Very low — No-Code
Setup Time
~2–4 hours
Scalability
Low (API limits)
  1. Trigger: Scheduled (Recurrence)

    Run the flow daily (e.g. 06:00 UTC).

  2. HTTP Action: Fetch OAuth2 Token

    POST to https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token with Client ID and Secret from a Key Vault reference.

  3. HTTP Action: Call Graph API

    GET https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/messages?$filter=startDateTime ge ... using the Bearer token from step 2.

  4. Parse JSON

    Parse the response body to access the value array.

  5. Apply to Each + SQL Connector

    Iterate over each message and write to the database using SQL Server – Insert Row or Execute SQL Query (for MERGE/UPSERT).

Warning: Power Automate has a default loop limit of 5,000 iterations per flow run. For large tenants (1,000+ MC messages) an Azure Function is the better choice.

Option C – Azure Function (Python) · Recommended

The most robust, scalable, and production-ready solution. Runs serverless, scales automatically, and supports Managed Identity for passwordless authentication.

Complexity
Medium — Python/Azure
Setup Time
~1 day
Scalability
Very high
# function_app.py – Azure Function v2 with Timer Trigger
import azure.functions as func
import logging, requests, pyodbc, json, re
from datetime import datetime, timedelta, timezone
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient

app = func.FunctionApp()

# -- Configuration ------------------------------------------
TENANT_ID  = "YOUR-TENANT-ID"
CLIENT_ID  = "YOUR-CLIENT-ID"
KV_URL     = "https://your-keyvault.vault.azure.net"
SQL_CONN   = "Driver={ODBC Driver 18 for SQL Server};Server=...;Database=M365Monitoring;Authentication=ActiveDirectoryMsi"
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
DAYS_BACK  = 2  # 2-day overlap for safety

def get_access_token() -> str:
    """OAuth2 token via Client Credentials Flow"""
    credential = DefaultAzureCredential()
    kv_client  = SecretClient(vault_url=KV_URL, credential=credential)
    secret     = kv_client.get_secret("graph-client-secret").value
    resp = requests.post(
        f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token",
        data={"grant_type": "client_credentials", "client_id": CLIENT_ID,
              "client_secret": secret, "scope": "https://graph.microsoft.com/.default"}
    )
    resp.raise_for_status()
    return resp.json()["access_token"]

def fetch_all_messages(token: str, since: str) -> list:
    """Fetch all messages with automatic pagination"""
    headers  = {"Authorization": f"Bearer {token}"}
    url      = (f"{GRAPH_BASE}/admin/serviceAnnouncement/messages"
                f"?$filter=startDateTime ge {since}"
                f"&$select=id,title,category,severity,isMajorChange,startDateTime,"
                f"endDateTime,lastModifiedDateTime,actionRequiredByDateTime,"
                f"tags,services,details,body&$top=100")
    messages = []
    while url:
        resp = requests.get(url, headers=headers)
        resp.raise_for_status()
        data = resp.json()
        messages.extend(data.get("value", []))
        url = data.get("@odata.nextLink")
    return messages

def upsert_messages(messages: list) -> int:
    """MERGE/UPSERT all messages into SQL"""
    strip = lambda h: re.sub(r'<[^>]+>', '', h or '').strip()
    with pyodbc.connect(SQL_CONN) as conn:
        cur = conn.cursor(); count = 0
        for m in messages:
            body = m.get("body", {})
            cur.execute("""
                MERGE dbo.MessageCenter AS t USING (VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)) AS s
                  (Id,Title,Category,Severity,IsMajorChange,StartDateTime,EndDateTime,
                   LastModifiedDateTime,ActionRequiredByDateTime,Services,Tags,BodyHtml,BodyText)
                ON t.Id=s.Id
                WHEN MATCHED AND t.LastModifiedDateTime < s.LastModifiedDateTime THEN
                  UPDATE SET Title=s.Title,Severity=s.Severity,
                    LastModifiedDateTime=s.LastModifiedDateTime,BodyHtml=s.BodyHtml,
                    BodyText=s.BodyText,Services=s.Services,Tags=s.Tags,LastSyncedAt=GETUTCDATE()
                WHEN NOT MATCHED THEN INSERT
                  (Id,Title,Category,Severity,IsMajorChange,StartDateTime,EndDateTime,
                   LastModifiedDateTime,ActionRequiredByDateTime,Services,Tags,BodyHtml,BodyText,ImportedAt)
                  VALUES (s.Id,s.Title,s.Category,s.Severity,s.IsMajorChange,s.StartDateTime,
                    s.EndDateTime,s.LastModifiedDateTime,s.ActionRequiredByDateTime,
                    s.Services,s.Tags,s.BodyHtml,s.BodyText,GETUTCDATE());""",
                m["id"], m["title"], m.get("category"), m.get("severity"),
                m.get("isMajorChange"), m.get("startDateTime"), m.get("endDateTime"),
                m.get("lastModifiedDateTime"), m.get("actionRequiredByDateTime"),
                json.dumps(m.get("services",[])), json.dumps(m.get("tags",[])),
                body.get("content",""), strip(body.get("content","")))
            count += 1
        conn.commit()
    return count

# -- Timer Trigger: daily at 06:00 UTC ----------------------
@app.timer_trigger(schedule="0 0 6 * * *", arg_name="timer", run_on_startup=False)
def sync_message_center(timer: func.TimerRequest) -> None:
    logging.info("? Message Center sync started")
    since = (datetime.now(timezone.utc) - timedelta(days=DAYS_BACK)).strftime("%Y-%m-%dT%H:%M:%SZ")
    count = upsert_messages(fetch_all_messages(get_access_token(), since))
    logging.info(f"? {count} messages synchronised")

Recommended Target Architecture

For production use with Azure Function, Managed Identity, and Key Vault:

?? Graph API serviceAnnouncement
HTTPS
? Azure Function Timer Trigger
ODBC
??? Azure SQL dbo.MessageCenter
?? Key Vault Client Secret
Managed Identity
? Azure Function Managed Identity
Alerts
?? App Insights Monitoring

Comparison of Approaches

CriterionPowerShellPower AutomateAzure Function
ComplexityLowVery lowMedium
Setup Time1–2 hrs2–4 hrs~1 day
Serverless? (needs host)??
Pagination? (automatic)? (manual)? (built-in)
Error HandlingMediumLowVery good
Key Vault IntegrationPossiblePossible? Native
ScalabilityMediumLowVery high
CostLow (VM/host)Power Platform licenceVery low (consumption)
MonitoringManualFlow HistoryApp Insights
Best forAdmins, quick-startLow-code teamsProduction, dev teams

Tips & Best Practices

Always implement pagination

Tenants with many subscriptions can have more than 500 Message Center entries. The @odata.nextLink in the response must be followed until it returns null.

UPSERT instead of INSERT

Microsoft can update existing messages (e.g. shifted dates). A MERGE statement keyed on Id prevents duplicates and keeps the data current. Check whether lastModifiedDateTime is newer before updating.

Recommendation: Store both BodyHtml (original) and BodyText (HTML-stripped) in the database. Plain text enables simple full-text search; HTML preserves the original formatting.

Never store secrets in code

Client Secrets belong in Azure Key Vault. The Azure Function retrieves them via Managed Identity without a password — no secrets in environment variables or source code.

Use overlapping time windows

For daily runs, use a lookback of 2–3 days rather than exactly 24 hours, to avoid missing messages that appear with a delay.

Respect rate limits

Throttling: The Service Communications API, like all Graph APIs, enforces throttling limits. On a 429 Too Many Requests response, read the Retry-After header and wait accordingly.

Useful further reading