
M365 Message Center Automated Export

Drago Petrovic
Microsoft MVP
Complete technical documentation for retrieving and persisting Microsoft 365 Message Center notifications via the Microsoft Graph API into a SQL database.
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:
Returns all Message Center entries for the tenant. Supports OData filtering, pagination, and field selection.
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 |
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
$filterge, le, eq, any().$select$top & @odata.nextLinknextLink for the next page.$orderbystartDateTime, 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_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.
#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.
- Trigger: Scheduled (Recurrence)
Run the flow daily (e.g. 06:00 UTC).
- HTTP Action: Fetch OAuth2 Token
POST to
https://login.microsoftonline.com/{tenant}/oauth2/v2.0/tokenwith Client ID and Secret from a Key Vault reference. - 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. - Parse JSON
Parse the response body to access the
valuearray. - 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).
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.
# 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:
Comparison of Approaches
| Criterion | PowerShell | Power Automate | Azure Function |
|---|---|---|---|
| Complexity | Low | Very low | Medium |
| Setup Time | 1–2 hrs | 2–4 hrs | ~1 day |
| Serverless | ? (needs host) | ? | ? |
| Pagination | ? (automatic) | ? (manual) | ? (built-in) |
| Error Handling | Medium | Low | Very good |
| Key Vault Integration | Possible | Possible | ? Native |
| Scalability | Medium | Low | Very high |
| Cost | Low (VM/host) | Power Platform licence | Very low (consumption) |
| Monitoring | Manual | Flow History | App Insights |
| Best for | Admins, quick-start | Low-code teams | Production, 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.
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
429 Too Many Requests response, read the Retry-After header and wait accordingly.