~/2026/02/18/node-js-azure-a-slack-bot-for-resource-group-budget-alerts.md

Node.js: Azure – A Slack Bot for Resource Group Budget Alerts

---
author: 
date: 
read: 5 min
in:   [scripts, engineering]
tags: [nodejs, azure]
---

$ grep -n '^#' post.md

Azure's built-in budget alerts are fine, but out of the box they email a distribution list, which means they get filtered into a folder nobody checks (wiring them to Slack takes more plumbing, covered below). What actually gets attention on my teams is a Slack message in the channel where the engineers who provisioned the resources already live. This script queries Azure Cost Management for month-to-date spend on a list of resource groups, projects where each one will land at month end, compares both against a configured budget, and posts a single Slack message covering everything over the line. I run it as a scheduled task, but it's plain enough to drop into an Azure Function on a timer trigger. It is the budget half of Azure: Cost Governance Without Killing Developer Velocity; the anomaly half is Python: Azure – Cost Anomaly Alerts from the Cost Management API.

Requirements

  • A Node.js LTS release (20 or later) for the built-in fetch.
  • @azure/identity (npm install @azure/identity). The script uses DefaultAzureCredential, so the same code works with a managed identity in Azure, a service principal from AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET, or an az login session while testing.
  • Cost Management Reader on each resource group being queried (or once at the subscription or management group above them). Resource group scope is a supported Cost Management scope, so the identity doesn't need anything wider.
  • A Slack incoming webhook URL for the channel that should receive alerts.
  • A small JSON config file listing the resource groups to watch and their monthly budgets.

Parameters

NameTypeRequiredDescription
--configStringYesPath to the JSON config (array of subscriptionId, resourceGroup, monthlyBudget, optional owner).
--threshold-percentNumberNoAlert when actual or projected spend reaches this percentage of budget. Default 80.
--dry-runSwitchNoPrint the Slack payload instead of posting it.
SLACK_WEBHOOK_URLEnv varYesIncoming webhook URL. Treat it as a secret.

Usage

Create a budgets.json config next to the script. owner is optional text that ends up in the message, such as a Slack handle or team name:

json
[
  { "subscriptionId": "<subscription-id>", "resourceGroup": "rg-prod-app", "monthlyBudget": 4000, "owner": "@app-team" },
  { "subscriptionId": "<subscription-id>", "resourceGroup": "rg-prod-data", "monthlyBudget": 2500, "owner": "@data-team" },
  { "subscriptionId": "<subscription-id>", "resourceGroup": "rg-shared-network", "monthlyBudget": 800 }
]

Set the environment and run it. With a service principal:

bash
export AZURE_TENANT_ID="<tenant-id>"
export AZURE_CLIENT_ID="<client-id>"
export AZURE_CLIENT_SECRET="<client-secret>"
export SLACK_WEBHOOK_URL="<slack-webhook-url>"

node budget-alert-bot.js --config budgets.json --threshold-percent 80
text
rg-prod-app: 2,961.40 of 4,000.00 USD (74.0%), projected 4,877.60 (121.9%) ALERT
rg-prod-data: 1,104.77 of 2,500.00 USD (44.2%), projected 1,819.62 (72.8%)
rg-shared-network: 702.15 of 800.00 USD (87.8%), projected 1,156.48 (144.6%) ALERT
2 of 3 resource group(s) at or over 80%. Posted 1 Slack message(s).

Schedule it daily. Budgets and cost data only refresh a few times a day, and Microsoft recommends calling the cost APIs no more than once per day:

bash
0 8 * * * cd /opt/budget-bot && node budget-alert-bot.js --config budgets.json --threshold-percent 80 >> /var/log/budget-bot.log 2>&1

Script

javascript
#!/usr/bin/env node
/**
 * budget-alert-bot.js
 *
 * Queries Azure Cost Management for month-to-date actual cost on a list of
 * resource groups, projects month-end spend with a straight-line run rate,
 * and posts Slack Block Kit messages (20 groups each) listing every resource group whose
 * actual or projected spend reached the threshold percentage of its budget.
 *
 * Reads:  a JSON config file (--config) of { subscriptionId, resourceGroup,
 *         monthlyBudget, owner? } entries, and the Cost Management Query API.
 * Writes: nothing locally; posts to the Slack incoming webhook in
 *         SLACK_WEBHOOK_URL (or prints the payload with --dry-run).
 *
 * Auth:   DefaultAzureCredential (managed identity, AZURE_* environment
 *         variables for a service principal, or an Azure CLI login).
 */

const fs = require('fs');
const { DefaultAzureCredential } = require('@azure/identity');

const ARM = 'https://management.azure.com';
const API_VERSION = '2025-03-01';
const TOKEN_SCOPE = 'https://management.azure.com/.default';
const QPU_RETRY_HEADER = 'x-ms-ratelimit-microsoft.costmanagement-qpu-retry-after';
const MAX_ATTEMPTS = 4;
const SECTIONS_PER_MESSAGE = 20;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

function parseArgs(argv) {
    const args = { configPath: null, thresholdPercent: 80, dryRun: false };

    for (let i = 0; i < argv.length; i++) {
        if (argv[i] === '--config') {
            args.configPath = argv[++i];
        } else if (argv[i] === '--threshold-percent') {
            args.thresholdPercent = Number(argv[++i]);
        } else if (argv[i] === '--dry-run') {
            args.dryRun = true;
        }
    }

    if (!args.configPath || !Number.isFinite(args.thresholdPercent)) {
        throw new Error('Usage: node budget-alert-bot.js --config <path> [--threshold-percent <n>] [--dry-run]');
    }
    return args;
}

function loadConfig(path) {
    const entries = JSON.parse(fs.readFileSync(path, 'utf8'));
    if (!Array.isArray(entries) || entries.length === 0) {
        throw new Error(`${path} must be a non-empty JSON array.`);
    }
    for (const entry of entries) {
        if (!entry.subscriptionId || !entry.resourceGroup || !(Number(entry.monthlyBudget) > 0)) {
            throw new Error(`Invalid config entry: ${JSON.stringify(entry)}`);
        }
    }
    return entries;
}

function monthProgress(now = new Date()) {
    const daysInMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)).getUTCDate();
    // Cost data lags, so count only completed UTC days (minimum 1) toward the run rate.
    const daysElapsed = Math.max(now.getUTCDate() - 1, 1);
    return { daysElapsed, daysInMonth };
}

async function queryMonthToDate(token, subscriptionId, resourceGroup) {
    const scope = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}`;
    const url = `${ARM}${scope}/providers/Microsoft.CostManagement/query?api-version=${API_VERSION}`;
    const body = JSON.stringify({
        type: 'ActualCost',
        timeframe: 'MonthToDate',
        dataset: {
            granularity: 'None',
            aggregation: { totalCost: { name: 'PreTaxCost', function: 'Sum' } },
        },
    });

    let response;
    for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
        response = await fetch(url, {
            method: 'POST',
            headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
            body,
        });
        if (![429, 503].includes(response.status) || attempt === MAX_ATTEMPTS) {
            break;
        }
        const wait = Number(response.headers.get(QPU_RETRY_HEADER) || response.headers.get('retry-after') || 30);
        console.error(`${resourceGroup}: throttled (${response.status}), retrying in ${wait}s`);
        await sleep(wait * 1000);
    }

    if (!response.ok) {
        throw new Error(`${resourceGroup}: Cost Management query failed ${response.status} ${await response.text()}`);
    }
    if (response.status === 204) {
        // 204 No Content is a documented success: nothing to report yet.
        return { spend: 0, currency: '' };
    }

    const { properties } = await response.json();
    const costIndex = properties.columns.findIndex((col) => col.type === 'Number');
    const currencyIndex = properties.columns.findIndex((col) => col.name === 'Currency');
    const row = properties.rows[0];

    // No rows means no cost yet this month.
    return {
        spend: row ? Number(row[costIndex]) : 0,
        currency: row && currencyIndex >= 0 ? row[currencyIndex] : '',
    };
}

function formatAmount(value) {
    return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}

function buildSlackMessages(alerts, thresholdPercent) {
    const messages = [];
    for (let i = 0; i < alerts.length; i += SECTIONS_PER_MESSAGE) {
        const chunk = alerts.slice(i, i + SECTIONS_PER_MESSAGE);
        const blocks = [
            {
                type: 'header',
                text: { type: 'plain_text', text: `Azure budget alert: ${alerts.length} resource group(s) at or over ${thresholdPercent}%` },
            },
        ];
        for (const a of chunk) {
            blocks.push({
                type: 'section',
                text: { type: 'mrkdwn', text: `*${a.resourceGroup}*${a.owner ? `  ${a.owner}` : ''}` },
                fields: [
                    { type: 'mrkdwn', text: `*Month to date*\n${formatAmount(a.spend)} ${a.currency} (${a.percentUsed.toFixed(1)}%)` },
                    { type: 'mrkdwn', text: `*Budget*\n${formatAmount(a.monthlyBudget)} ${a.currency}` },
                    { type: 'mrkdwn', text: `*Projected month end*\n${formatAmount(a.projected)} ${a.currency} (${a.projectedPercent.toFixed(1)}%)` },
                    { type: 'mrkdwn', text: `*Subscription*\n\`${a.subscriptionId}\`` },
                ],
            });
        }
        blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: 'Actual cost from Azure Cost Management; data can lag by a day or more.' }] });

        messages.push({
            // Top-level text is the notification and fallback when blocks can't be shown.
            text: `Azure budget alert: ${chunk.map((a) => a.resourceGroup).join(', ')}`,
            blocks,
        });
    }
    return messages;
}

async function postToSlack(webhookUrl, message) {
    let response;
    for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
        response = await fetch(webhookUrl, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(message),
        });
        if (response.status !== 429 || attempt === MAX_ATTEMPTS) {
            break;
        }
        const wait = Number(response.headers.get('retry-after') || 1);
        await sleep(wait * 1000);
    }
    if (!response.ok) {
        // Slack returns a short error string such as invalid_payload, no_text or channel_is_archived.
        throw new Error(`Slack webhook post failed: ${response.status} ${await response.text()}`);
    }
}

async function main() {
    const args = parseArgs(process.argv.slice(2));
    const webhookUrl = process.env.SLACK_WEBHOOK_URL;
    if (!webhookUrl && !args.dryRun) {
        throw new Error('SLACK_WEBHOOK_URL is not set.');
    }

    const config = loadConfig(args.configPath);
    const { token } = await new DefaultAzureCredential().getToken(TOKEN_SCOPE);
    const { daysElapsed, daysInMonth } = monthProgress();

    const alerts = [];
    let failures = 0;

    // Serial on purpose: Query API quotas are per tenant.
    for (const entry of config) {
        try {
            const { spend, currency } = await queryMonthToDate(token, entry.subscriptionId, entry.resourceGroup);
            const monthlyBudget = Number(entry.monthlyBudget);
            const projected = (spend / daysElapsed) * daysInMonth;
            const result = {
                ...entry,
                monthlyBudget,
                spend,
                currency,
                projected,
                percentUsed: (spend / monthlyBudget) * 100,
                projectedPercent: (projected / monthlyBudget) * 100,
            };
            const isAlert = result.percentUsed >= args.thresholdPercent || result.projectedPercent >= args.thresholdPercent;

            console.log(
                `${entry.resourceGroup}: ${formatAmount(spend)} of ${formatAmount(monthlyBudget)} ${currency} ` +
                `(${result.percentUsed.toFixed(1)}%), projected ${formatAmount(projected)} ` +
                `(${result.projectedPercent.toFixed(1)}%)${isAlert ? ' ALERT' : ''}`
            );
            if (isAlert) {
                alerts.push(result);
            }
        } catch (error) {
            failures++;
            console.error(error.message);
        }
    }

    const messages = buildSlackMessages(alerts, args.thresholdPercent);
    for (const [index, message] of messages.entries()) {
        if (args.dryRun) {
            console.log(JSON.stringify(message, null, 2));
        } else {
            if (index > 0) {
                // Incoming webhooks allow about one message per second.
                await sleep(1100);
            }
            await postToSlack(webhookUrl, message);
        }
    }

    const posted = args.dryRun ? 0 : messages.length;
    console.log(`${alerts.length} of ${config.length} resource group(s) at or over ${args.thresholdPercent}%. Posted ${posted} Slack message(s).`);
    if (failures > 0) {
        process.exitCode = 1;
    }
}

main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
});

Notes

  • The projection is deliberately crude. Month-to-date spend divided by completed days, times days in the month. It warns early about a resource group trending over budget, but it overshoots after a one-off purchase early in the month and undershoots for workloads that ramp at month end. Azure budgets have a proper forecast-based alert (a threshold on forecasted cost) if you want Microsoft's model instead.
  • Cost data is not real time. For EA and MCA subscriptions it typically arrives within 8 to 24 hours (up to 72 hours for pay-as-you-go), and figures are estimates until the invoice is generated. Treat this as a daily trend alert, not a meter that catches a runaway resource in the hour it happens.
  • MonthToDate follows the calendar month, and monthProgress counts days in UTC. Pay-as-you-go, MSDN and Visual Studio subscriptions can have invoice periods that don't align with calendar months; the Query API also accepts BillingMonthToDate for those, and monthProgress would need the same change.
  • Resource groups are queried one at a time because the Query API limits are per tenant (12 query processing units per 10 seconds, 60 per minute, 600 per hour at the time of writing, one unit per month of data). A 429 response carries the back-off in x-ms-ratelimit-microsoft.costmanagement-qpu-retry-after, which the script waits out.
  • Slack allows roughly one incoming-webhook message per second, with short bursts, and answers a flood with HTTP 429 and a Retry-After header. Batching every alert into one message (split at 20 resource groups) keeps a bad day from turning into a message storm. A section block's text is capped at 3,000 characters and its fields render as two columns.
  • The webhook URL is a credential. Slack actively looks for leaked webhook URLs and revokes them. Keep it in Key Vault or your scheduler's secret store, not in the config file or the repo.
  • A resource group that is renamed or deleted makes its query fail. The script logs it, carries on with the rest, and exits non-zero so the scheduler flags the run.

Native budgets and action groups

If you would rather let Azure do the threshold math, create a budget on each resource group and attach an action group: budgets can call action groups at subscription and resource group scope, and they are evaluated against costs every 24 hours. The catch for Slack is the payload. The action group webhook sends Azure's budget notification JSON, not the text/blocks body a Slack webhook expects (Slack rejects a body without text as no_text), and the action group can't add custom headers. So you still need a small Logic App or Function in between to reshape it. The payload Microsoft documents looks like this:

json
{
  "schemaId": "AIP Budget Notification",
  "data": {
    "SubscriptionName": "<subscription-name>",
    "SubscriptionId": "<subscription-id>",
    "SpendingAmount": "100",
    "BudgetStartDate": "6/1/2018",
    "Budget": "50",
    "Unit": "USD",
    "BudgetCreator": "user@example.com",
    "BudgetName": "BudgetName",
    "BudgetType": "Cost",
    "ResourceGroup": "",
    "NotificationThresholdAmount": "0.8"
  }
}

Every value arrives as a string, so parse SpendingAmount, Budget and NotificationThresholdAmount before doing math on them. Budgets work with action groups whether or not the common alert schema is enabled on the group. I still prefer the script for the channel-per-team case, because one config file is easier to review than forty budgets, but the native route has no server to run.

References