Building a Weekly Azure Cost Report with Logic App Standard
A step-by-step guide to building an automated weekly cost report using Azure Logic App Standard. The workflow discovers subscriptions dynamically, queries the Cost Management API, builds professional HTML email tables, and emails the report to subscription owners. No Power BI. No third-party tools. Just a Logic App and some clever API calls.
Table of Contents
- The Problem
- The Solution
- Email Report Preview
- Workflow Architecture
- Prerequisites
- Step 1: Trigger & Credentials
- Step 2: Discover Subscriptions
- Step 3: Query the Cost Management API
- Step 4: Build the Email Tables
- Step 5: Compose & Send the Email
- Complete Workflow JSON
- Deployment Guide
- Customization Ideas
The Problem
If you manage Azure subscriptions for development teams, sandbox environments, or lab workloads, you've probably had this conversation: "Why is our Azure bill so high this month?"
The Azure portal has great cost analysis tools, but they require someone to actively log in and look. What teams really need is a cost report that lands in their inbox every week, showing exactly what they spent, which services are driving cost, and which resources are the most expensive.
Most organizations reach for Power BI, third-party SaaS tools, or custom applications. But there's a simpler way: a single Logic App Standard workflow that does everything natively inside Azure.
The Solution
Here's what the workflow does every week:
- Auto-discovers all target subscriptions matching a naming pattern (e.g., sandbox-*)
- Pulls 7-day cost data from the Azure Cost Management Query API
- Builds professional HTML tables showing daily breakdown, top 5 most expensive resources, and cost by service category
- Emails the report to the subscription owner via an email tag on the subscription
The email renders perfectly in Outlook, Gmail, and Apple Mail because it's built entirely with HTML tables and inline CSS. No embedded images, no external dependencies.
Email Report Preview
Here's exactly what lands in the subscription owner's inbox every week:
Actual email report generated from live Cost Management API data.
Workflow Architecture
Trigger: Every Tuesday 8:00 PM (configurable)
|
v
[Key Vault] Get service principal credentials
|-- YOUR-TENANT-ID
|-- YOUR-APP-ID
|-- YOUR-CLIENT-SECRET
|
v
Calculate 7-day date range (UTC)
|
v
[ARM API] List all subscriptions
|
v
Filter: displayName starts with "sandbox-" AND state = Enabled
|
v
For each subscription (SEQUENTIAL):
|
|-- Reset all variables (HTML builders, totals, colors)
|-- [ARM API] Get subscription tags
|-- Extract owner-email tag
|
+-- If owner-email exists:
| |
| |-- [4x PARALLEL HTTP calls]:
| | |-- Daily cost (no grouping) --> daily table + totals
| | |-- Top 5 resources by ResourceId --> resource table
| | |-- Total by MeterCategory --> category table
| |
| +-- [Scope: Build Chart]
| | |-- Assign colors to each meter category
| | |-- Compute daily totals + find max for scaling
| | |-- Build stacked bar segments per day
| | |-- Build legend HTML
| |
| |-- Build top 5 resource table rows
| |-- Build meter category table rows
| |-- Compose full HTML email body
| |-- Send via Office 365
|
+-- If no owner-email: skip
Prerequisites
1. Service Principal
Create an App Registration in Entra ID (Azure AD) and generate a client secret. This service principal needs two RBAC roles on the target subscriptions (or at the management group level):
| Role | Purpose |
|---|---|
| Reader | List subscriptions, read subscription tags |
| Cost Management Reader | Query the Cost Management API |
2. Key Vault Secrets
Store the service principal credentials in an Azure Key Vault. The Logic App will use a Key Vault Service Provider connection (built-in connector) to retrieve them at runtime.
| Secret Name | Value |
|---|---|
| sp-tenant-id | Your Azure AD Tenant ID |
| sp-app-id | Service Principal Application (Client) ID |
| sp-client-secret | Service Principal Client Secret value |
3. Subscription Tags
Each target subscription needs a tag that tells the workflow who to email the report to:
| Tag Name | Example Value |
|---|---|
| owner-email |
To send the report to multiple people, use a semicolon-separated list: ;;. The Office 365 connector handles multiple recipients natively.
Subscriptions without this tag are silently skipped.
4. Office 365 Connection
The workflow sends emails via the Office 365 Outlook managed API connection. Configure this in the Logic App's Connections blade using a service account or shared mailbox.
5. Logic App Standard
Create a Logic App Standard (not Consumption) resource. Standard gives you stateful workflows, Key Vault built-in connector, and the ability to run multiple workflows in a single app.
Step 1: Trigger & Credentials
The workflow fires on a weekly recurrence trigger. First, it retrieves the service principal credentials from Key Vault:
{
"Recurrence_-_Weekly_Cost_Report": {
"type": "Recurrence",
"recurrence": {
"frequency": "Week",
"interval": 1,
"schedule": {
"weekDays": ["Tuesday"],
"hours": [20],
"minutes": [0]
},
"timeZone": "Eastern Standard Time"
}
}
}
The three Key Vault actions run sequentially, each secured with runtimeConfiguration.secureData so credentials never appear in run history:
{
"Get_secret_-_tenantId": {
"type": "ServiceProvider",
"inputs": {
"parameters": {
"secretName": "sp-tenant-id"
},
"serviceProviderConfiguration": {
"connectionName": "keyVault-1",
"operationId": "getSecret",
"serviceProviderId": "/serviceProviders/keyVault"
}
},
"runtimeConfiguration": {
"secureData": {
"properties": ["inputs", "outputs"]
}
}
}
}
Always use secureData on Key Vault actions. Without it, anyone with access to the Logic App run history can see your secrets in plain text.
Step 2: Discover Subscriptions
Instead of hardcoding subscription IDs, the workflow dynamically discovers all matching subscriptions using the ARM API:
GET https://management.azure.com/subscriptions?api-version=2022-12-01
Authentication: ActiveDirectoryOAuth
Tenant: @{body('Get_secret_-_tenantId')?['value']}
ClientId: @{body('Get_secret_-_appId')?['value']}
Secret: @{body('Get_secret_-_secret')?['value']}
Audience: https://management.azure.com
Then a Filter Array action narrows it down:
@and(
startsWith(item()?['displayName'], 'sandbox-'),
equals(item()?['state'], 'Enabled')
)
Change sandbox- to whatever prefix your subscriptions use. You can also filter by management group, resource tags, or any other property returned by the ARM API.
Step 3: Query the Cost Management API
For each subscription, the workflow makes 3 parallel API calls to the Cost Management Query API. Running them in parallel cuts execution time significantly:
Query 1: Daily Cost (for the table)
POST https://management.azure.com/subscriptions/{id}/providers/
Microsoft.CostManagement/query?api-version=2023-11-01
{
"type": "ActualCost",
"timeframe": "Custom",
"timePeriod": {
"from": "@{variables('StartDate')}",
"to": "@{variables('EndDate')}"
},
"dataset": {
"granularity": "Daily",
"aggregation": {
"totalCost": {
"name": "Cost",
"function": "Sum"
}
}
}
}
Query 2: Top 5 Resources by Cost
{
"type": "ActualCost",
"timeframe": "Custom",
"timePeriod": {
"from": "@{variables('StartDate')}",
"to": "@{variables('EndDate')}"
},
"dataset": {
"granularity": "None",
"aggregation": {
"totalCost": { "name": "Cost", "function": "Sum" }
},
"grouping": [
{ "type": "Dimension", "name": "ResourceId" }
],
"sorting": [
{ "direction": "descending", "name": "Cost" }
],
"top": 5
}
}
Query 3: Total by Meter Category (for category table)
{
"type": "ActualCost",
"timeframe": "Custom",
"timePeriod": {
"from": "@{variables('StartDate')}",
"to": "@{variables('EndDate')}"
},
"dataset": {
"granularity": "None",
"aggregation": {
"totalCost": { "name": "Cost", "function": "Sum" }
},
"grouping": [
{ "type": "Dimension", "name": "MeterCategory" }
],
"sorting": [
{ "direction": "descending", "name": "Cost" }
]
}
}
The Cost Management API returns dates as numbers like 20260301, not ISO strings. You'll need a Compose action that uses substring() and concat() to format them into 2026-03-01 before passing to formatDateTime().
Step 4: Build the Email Tables
The workflow loops through the API response rows to build HTML table strings using AppendToStringVariable actions. Each loop runs sequentially to maintain row order:
- Daily cost loop: Formats each date (the API returns numbers like 20260301), appends a table row, and increments the running total
- Top 5 resources loop: Parses the resource name and resource group from the full ResourceId using last(split())
- Meter category loop: Builds the service category breakdown table with color dots
Outlook uses the Word rendering engine, which doesn't support flexbox, grid, or most modern CSS. The email is built entirely with <table> elements and inline styles. No CSS charts, no background images, no external stylesheets. This ensures it renders identically across Outlook, Gmail, and Apple Mail.
Step 5: Compose & Send the Email
The final Compose action assembles all the HTML fragments into a single email body:
- Header banner with gradient background showing total 7-day cost
- Summary bar with subscription name and date range
- Daily cost breakdown table
- Top 5 resources table
- Meter category table with color dots
- Footer with your team's signature
The email is sent using the Office 365 connector's SendEmailV2 action with isHtml: true.
Complete Workflow JSON
The full workflow.json with detailed README. Clone the repo, customize the placeholders, and deploy.
View on GitHub →Here's a summary of the key variables the workflow initializes:
| Variable | Type | Purpose |
|---|---|---|
| StartDate | String | 7 days ago (yyyy-MM-dd) |
| EndDate | String | Today (yyyy-MM-dd) |
| CurrentEmail | String | Recipient email per subscription |
| CurrentSubName | String | Current subscription display name |
| DailyCostHTML | String | HTML table rows for daily cost |
| TotalCost | Float | Running total of 7-day cost |
| Top5ResourceHTML | String | HTML rows for top 5 resources |
| MeterCategoryHTML | String | HTML rows for service categories |
Deployment Guide
- Create a Logic App Standard resource in the Azure Portal
- Create a new Stateful workflow named weekly-cost-report
- Switch to Code View
- Paste the contents of workflow.json
- Save
- Configure the keyVault-1 and office365 connections in the Connections blade
- Test: Run Trigger > Recurrence from the workflow Overview
For testing, change the subscription filter to match a single test subscription by name instead of the wildcard prefix. Once verified, switch back to the startsWith() filter for production.
Customization Ideas
This workflow is a foundation you can build on:
- Change frequency to daily or monthly by adjusting the recurrence trigger
- Add budget thresholds with conditional formatting (red highlight when cost exceeds a limit)
- Include month-over-month comparison by adding a second set of API calls for the previous period
- Post to Teams instead of (or in addition to) email using the Teams connector
- Filter by resource group using the Cost Management API's filter parameter
- Add anomaly detection by comparing current week's cost to a rolling average
- Send to Slack using a webhook action instead of Office 365
The Cost Management API also supports grouping by ResourceGroup, ServiceName, or any other dimension, so you can customize the tables to show exactly what your teams need.