Create an AWS Budget Alert With AWS SDK v3

A calculator lying on a desk next to a stack of paper receipts

Photo by Jakub Żerdzicki on Unsplash

To create an AWS budget alert with AWS SDK v3, call Budgets CreateBudget with a COST budget, a monthly BudgetLimit, and NotificationsWithSubscribers: for example ACTUAL alerts at 80% and 100% and a FORECASTED alert at 100%, sent to email addresses or an SNS topic. Budgets has one global endpoint, signed for us-east-1.

A budget alert is the cheapest insurance on an AWS account: it won’t stop spending, but it tells you in days instead of at the end of the month. Clicking one together in the console is easy. Getting the same alerts into every account, and knowing they’re still there, is where a script helps. This example is for engineers and leads who want to create an AWS budget alert from code and re-run it safely.

You get a TypeScript script for the AWS SDK for JavaScript v3 that plans by default and only writes with --apply. It’s one of our AWS SDK v3 cost and cleanup examples. When an alert fires, the next question is why, and the guide to ask AI why your AWS bill increased picks up from there.

What do AWS budget alerts cost?

Monitoring is free. As of September 2026, the AWS Budgets pricing page lists these charges:

Feature Price Used by this script
Budgets and their notifications Free Yes
Action-enabled budgets First 2 free per month, then $0.10 per budget per day No
Budgets Reports (emailed) $0.01 per report delivered No

Budget actions, which apply an IAM policy or SCP or stop EC2 and RDS instances when a threshold is crossed, are the paid part. A third action-enabled budget running all month would cost about 30 × $0.10 = $3.00. Plain alerts cost nothing, so there’s no reason not to have them.

Actual or forecasted: which alerts should you set?

Both, because they fail in different ways. AWS documents the behavior like this:

  • ACTUAL fires once per budget period, the first time accrued spend passes the threshold. It’s certain but late: by the time 100% fires, the money is spent.
  • FORECASTED fires when AWS predicts you’ll pass the threshold by the end of the period, and can fire more than once if the forecast dips and rises again. It needs roughly 5 weeks of usage data, so on a new account it stays silent at first.

Budget data refreshes up to three times a day, typically 8 to 12 hours apart, and billing data itself at least once a day. An alert is an early warning measured in hours, not a real-time circuit breaker. The FinOps Foundation’s budgeting capability frames it well: agree acceptable variance thresholds for budget to forecast and budget to actual, then alert on those, rather than picking round numbers.

Each notification can have up to 10 email subscribers and one SNS topic, and a budget can have up to 5 notifications. The script’s defaults, ACTUAL at 80% and 100% plus FORECASTED at 100%, use three of them.

What does the script do?

  1. Finds your account IDSTS GetCallerIdentity, which needs no IAM permission.
  2. Checks for the budget by nameDescribeBudget; a NotFoundException means it doesn’t exist yet.
  3. Creates it in one callIf missing, CreateBudget with a MONTHLY COST budget in USD and all notifications with their subscribers. With no TimePeriod, the budget starts at the current month and renews monthly.
  4. Adds only what’s missing on re-runsIf the budget exists, DescribeNotificationsForBudget lists its alerts and CreateNotification adds any threshold you asked for that isn’t there. It never deletes or changes existing alerts, and it only reports a different limit.
  5. Plans by defaultWithout --apply it makes only read calls and prints what it would create.

Prerequisites

  • Node.js 18 or later, npm and tsx, plus @aws-sdk/client-budgets and @aws-sdk/client-sts.
  • Credentials for the account the budget belongs to. Budgets can’t be created across accounts; in an organization, the management account’s budgets can track member accounts. The guide to the AWS SDK v3 credentials provider chain covers switching profiles.
  • For --sns: a topic in the same account whose access policy lets AWS Budgets publish (below).

Which IAM permissions does it need?

budgets:ViewBudget covers DescribeBudget and DescribeNotificationsForBudget; budgets:ModifyBudget covers CreateBudget and CreateNotification. Both can be scoped to the budget’s ARN, which has no Region. Replace the account ID and budget name.

budget-alert-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadBudget",
      "Effect": "Allow",
      "Action": "budgets:ViewBudget",
      "Resource": "arn:aws:budgets::123456789012:budget/monthly-cost-budget"
    },
    {
      "Sid": "CreateBudgetAndAlerts",
      "Effect": "Allow",
      "Action": "budgets:ModifyBudget",
      "Resource": "arn:aws:budgets::123456789012:budget/monthly-cost-budget"
    }
  ]
}

Give the dry run only the first statement. The IAM policy generator for TypeScript code drafts a policy from any variation of the script.

For SNS delivery, add this statement to the topic’s access policy. It’s the one AWS documents, limited to budgets in your own account:

sns-topic-policy-statement.json

{
  "Sid": "AWSBudgetsSNSPublishingPermissions",
  "Effect": "Allow",
  "Principal": { "Service": "budgets.amazonaws.com" },
  "Action": "SNS:Publish",
  "Resource": "arn:aws:sns:us-east-1:123456789012:budget-alerts",
  "Condition": {
    "StringEquals": { "aws:SourceAccount": "123456789012" },
    "ArnLike": { "aws:SourceArn": "arn:aws:budgets::123456789012:*" }
  }
}

From the topic you can fan alerts out to chat, a ticket queue or a Lambda function; the guide to publish an SNS message with AWS SDK v3 covers subscriptions and filter policies. A Lambda function subscribed to the topic can also forward each alert to an event bus as a custom event, as shown in the guide to send events to EventBridge with AWS SDK v3 (PutEvents).

The full script to create an AWS budget alert

create-budget-alert.ts

// create-budget-alert.ts
// Creates a monthly AWS cost budget with ACTUAL and FORECASTED alerts to email and/or SNS.
// Safe to re-run: an existing budget is left alone and only missing notifications are added.
// Dry run by default (reads only). Pass --apply to create anything.
// Usage: npx tsx create-budget-alert.ts --amount 500 --email [email protected][,[email protected]]
//          [--sns arn:aws:sns:us-east-1:123456789012:budget-alerts] [--name monthly-cost-budget]
//          [--actual 80,100] [--forecast 100] [--apply]
import {
  BudgetsClient,
  CreateBudgetCommand,
  CreateNotificationCommand,
  DescribeBudgetCommand,
  NotFoundException,
  paginateDescribeNotificationsForBudget,
  type Budget,
  type Notification,
  type NotificationWithSubscribers,
  type Subscriber,
} from "@aws-sdk/client-budgets";
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const list = (v: string | undefined): string[] => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);

const APPLY = args.includes("--apply");
const budgetName = flag("--name") ?? "monthly-cost-budget";
const amount = Number(flag("--amount"));
const emails = list(flag("--email"));
const snsTopicArn = flag("--sns");
const actual = list(flag("--actual") ?? "80,100").map(Number);
const forecast = list(flag("--forecast") ?? "100").map(Number);

if (!Number.isFinite(amount) || amount <= 0) throw new Error("--amount must be a positive number of USD, e.g. --amount 500");
if (!emails.length && !snsTopicArn) throw new Error("Give at least one --email or an --sns topic ARN");
if (emails.length > 10) throw new Error("A notification can have at most 10 email subscribers");
if (actual.length + forecast.length > 5) throw new Error("A budget can have at most 5 notifications");
if ([...actual, ...forecast].some((t) => !Number.isFinite(t) || t <= 0)) throw new Error("Thresholds must be positive percentages");

// AWS Budgets has one global endpoint in the aws partition, signed for us-east-1.
const budgets = new BudgetsClient({ region: "us-east-1" });

const subscribers: Subscriber[] = [
  ...emails.map((Address): Subscriber => ({ SubscriptionType: "EMAIL", Address })),
  ...(snsTopicArn ? [{ SubscriptionType: "SNS", Address: snsTopicArn } satisfies Subscriber] : []),
];

const wanted: Notification[] = [
  ...actual.map((Threshold): Notification => ({ NotificationType: "ACTUAL", ComparisonOperator: "GREATER_THAN", Threshold, ThresholdType: "PERCENTAGE" })),
  ...forecast.map((Threshold): Notification => ({ NotificationType: "FORECASTED", ComparisonOperator: "GREATER_THAN", Threshold, ThresholdType: "PERCENTAGE" })),
];

const budget: Budget = {
  BudgetName: budgetName,
  BudgetType: "COST",
  TimeUnit: "MONTHLY", // no TimePeriod: starts at the current month and renews every month
  BudgetLimit: { Amount: amount.toFixed(2), Unit: "USD" },
};

const same = (a: Notification, b: Notification): boolean =>
  a.NotificationType === b.NotificationType &&
  a.ComparisonOperator === b.ComparisonOperator &&
  a.Threshold === b.Threshold &&
  (a.ThresholdType ?? "PERCENTAGE") === (b.ThresholdType ?? "PERCENTAGE");
const label = (n: Notification): string => `${n.NotificationType} > ${n.Threshold}%`;

async function existingBudget(accountId: string): Promise<Budget | undefined> {
  try {
    const res = await budgets.send(new DescribeBudgetCommand({ AccountId: accountId, BudgetName: budgetName }));
    return res.Budget;
  } catch (err) {
    if (err instanceof NotFoundException) return undefined;
    throw err;
  }
}

async function existingNotifications(accountId: string): Promise<Notification[]> {
  const found: Notification[] = [];
  const pages = paginateDescribeNotificationsForBudget({ client: budgets }, { AccountId: accountId, BudgetName: budgetName });
  for await (const page of pages) found.push(...(page.Notifications ?? []));
  return found;
}

async function main(): Promise<void> {
  const { Account: accountId } = await new STSClient({ region: "us-east-1" }).send(new GetCallerIdentityCommand({}));
  if (!accountId) throw new Error("Could not resolve the AWS account ID");
  console.log(`Account ${accountId}, budget "${budgetName}": ${budget.BudgetLimit?.Amount} USD per month`);
  console.log(`Alerts: ${wanted.map(label).join(", ")} -> ${subscribers.map((s) => s.Address).join(", ")}`);

  const current = await existingBudget(accountId);
  if (!current) {
    console.log(APPLY ? "Creating budget with notifications..." : "Would create the budget with these notifications.");
    if (APPLY) {
      const notifications: NotificationWithSubscribers[] = wanted.map((Notification) => ({ Notification, Subscribers: subscribers }));
      await budgets.send(new CreateBudgetCommand({ AccountId: accountId, Budget: budget, NotificationsWithSubscribers: notifications }));
      console.log("Created. Email subscribers must confirm; SNS topics need a policy that lets budgets.amazonaws.com publish.");
    }
  } else {
    const limit = current.BudgetLimit;
    console.log(`Budget exists: ${limit?.Amount} ${limit?.Unit}, ${current.TimeUnit}.`);
    if (Number(limit?.Amount) !== amount) console.log(`Note: limit differs from --amount ${amount}; change it in the console or with UpdateBudget.`);
    const have = await existingNotifications(accountId);
    const missing = wanted.filter((w) => !have.some((h) => same(h, w)));
    if (!missing.length) console.log("All requested notifications already exist. Nothing to do.");
    for (const n of missing) {
      console.log(`${APPLY ? "Adding" : "Would add"} notification ${label(n)}`);
      if (APPLY) await budgets.send(new CreateNotificationCommand({ AccountId: accountId, BudgetName: budgetName, Notification: n, Subscribers: subscribers }));
    }
  }
  if (!APPLY) console.log("Dry run: nothing was created. Re-run with --apply.");
}

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

How do you run it?

Terminal

npm install @aws-sdk/client-budgets @aws-sdk/client-sts
npm install --save-dev tsx typescript

# Plan: shows what would be created (read-only)
AWS_PROFILE=billing npx tsx create-budget-alert.ts --amount 500 --email [email protected],[email protected] \
  --sns arn:aws:sns:us-east-1:123456789012:budget-alerts

# Create it
AWS_PROFILE=billing npx tsx create-budget-alert.ts --amount 500 --email [email protected],[email protected] \
  --sns arn:aws:sns:us-east-1:123456789012:budget-alerts --apply

# Later: add a 50% early warning to the same budget
AWS_PROFILE=billing npx tsx create-budget-alert.ts --amount 500 --email [email protected],[email protected] \
  --actual 50,80,100 --apply

Sample output

Output

$ npx tsx create-budget-alert.ts --amount 500 --email [email protected],[email protected] --sns arn:aws:sns:us-east-1:123456789012:budget-alerts
Account 123456789012, budget "monthly-cost-budget": 500.00 USD per month
Alerts: ACTUAL > 80%, ACTUAL > 100%, FORECASTED > 100% -> [email protected], [email protected], arn:aws:sns:us-east-1:123456789012:budget-alerts
Would create the budget with these notifications.
Dry run: nothing was created. Re-run with --apply.

$ npx tsx create-budget-alert.ts ... --apply
Creating budget with notifications...
Created. Email subscribers must confirm; SNS topics need a policy that lets budgets.amazonaws.com publish.

$ npx tsx create-budget-alert.ts --amount 500 --email [email protected],[email protected] --actual 50,80,100 --apply
Account 123456789012, budget "monthly-cost-budget": 500.00 USD per month
Alerts: ACTUAL > 50%, ACTUAL > 80%, ACTUAL > 100%, FORECASTED > 100% -> [email protected], [email protected]
Budget exists: 500.0 USD, MONTHLY.
Adding notification ACTUAL > 50%

The third run shows why the script matches notifications by type, operator and threshold: the existing alerts are left alone, and only the new 50% threshold is added. Subscribers are set per notification, so the new one gets only the addresses in that run.

Troubleshooting

  • No emails arrive. Each address has to confirm the subscription first. In the SNS console, filter Subscriptions by “budget” to see PendingConfirmation and resend the request.
  • “Invalid SNS topic”. The topic policy doesn’t allow budgets.amazonaws.com, or the topic is in another account, which isn’t supported. An encrypted topic also needs extra permissions; AWS’s own fix is to turn off encryption on that topic.
  • AccessDeniedException. The steps to troubleshoot AWS IAM access denied errors apply. The Service Authorization Reference also lists the legacy aws-portal:ViewBilling and aws-portal:ModifyBilling actions for these calls, so older accounts may need them.
  • DuplicateRecordException. The budget name exists; names are unique per account. The script checks first, so this only happens if two runs race.
  • CreationLimitExceededException. More than 5 notifications, or more than 10 email subscribers or one SNS topic on a notification.
  • The forecast alert never fires. The account has less than about 5 weeks of usage data.

What should a budget alert watch?

An account-wide budget catches everything but explains nothing. Once it works, add narrower budgets for the services that move most: the script to get last month’s AWS cost broken down by service shows which ones, and find your most expensive AWS service with Cost Explorer ranks them. When EC2 or EBS is the line that grows, the scripts to find previous-generation EC2 instances to upgrade and find orphaned EBS snapshots whose volume is gone cover two common causes. For filters, the API now recommends FilterExpression with Metrics over the older CostFilters and CostTypes, which the SDK marks deprecated. Filtering by a tag only works once resources carry it, so find untagged AWS resources with the Tagging API before you rely on a team budget. Alerts cover money; for hitting service limits instead, see how to monitor AWS service quota usage and get alerts.

Ask ChatWithCloud instead

Once budgets exist, ChatWithCloud can answer “Which budgets are set up in this account, and is any of them over its limit this month?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and explains the result, one profile and Region per session. It also runs generated code without asking for confirmation, so use a profile that can read budgets but not change them, as described in the guide to connect ChatWithCloud to your AWS account with a read-only role. For creating the same alerts in many accounts, keep the script. To follow up on a month that already ran hot, get this month’s CloudWatch cost with Cost Explorer is a common next check.

Frequently asked questions

How do I get an email when my AWS bill exceeds a threshold?

Create a cost budget with an ACTUAL notification at the percentage you care about and an EMAIL subscriber. The recipient must confirm the subscription before alerts arrive.

Are AWS budget alerts free?

Yes. As of September 2026, budgets and their notifications are free. Only action-enabled budgets beyond the first two ($0.10 per day each) and emailed Budgets Reports ($0.01 each) cost money.

Why does the Budgets client use us-east-1?

AWS Budgets has a single global endpoint in the standard partition, and the SDK signs requests for us-east-1. The budget itself covers all Regions.

Can a budget alert stop my resources?

Not on its own. A plain alert only notifies. Budget actions can apply IAM policies or SCPs or stop EC2 and RDS instances, and they’re priced separately.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud