AWS Builder Center
AWS cost alerts in Slack, right when they matter

AWS cost alerts in Slack, right when they matter

Send AWS Budgets alerts to Slack with SNS, Amazon Q Developer and Terraform: no Lambda, no webhook, and least privilege by design.

Why budget alerts belong in Slack

A budget alert only helps if someone sees it before the bill does. In this article I wire AWS Budgets to a private Slack channel with Terraform, with no Lambda function, no webhook and no custom code running in AWS, and I show what a security-by-design review changed along the way.
Email alerts from AWS Budgets work, but they compete with everything else in an inbox and are easy to filter away. Slack is where my team already talks about deployments and incidents, so a cost alert there gets read and discussed. Amazon Q Developer in chat applications (formerly AWS Chatbot) reads AWS Budgets notifications from an Amazon SNS topic and posts them to Slack, already formatted, so the whole pipeline is managed services.
By the end you will have:
  • A monthly AWS Budget with both FORECASTED and ACTUAL alerts.
  • An SNS topic that only AWS Budgets from your own account can publish to.
  • An Amazon Q Developer Slack channel configuration whose permissions are limited to reading CloudWatch, by both its IAM role and a guardrail policy.
  • A small Python script, run with uv, that creates the private Slack channel and invites the Amazon Q bot and your team.
Everything except one OAuth click is code: Terraform for AWS, Python for Slack.

Architecture

The alert path is three managed services in one AWS account, and Slack. Terraform runs from a laptop and keeps its state in a separate shared services account.
Architecture: AWS Budgets publishes to an SNS topic, Amazon Q Developer posts the alert in a private Slack channel
  1. AWS Budgets detects that actual or forecasted spend crossed a threshold and publishes a notification to the SNS topic.
  2. Amazon SNS delivers it to Amazon Q Developer. The topic policy accepts messages only from budgets.amazonaws.com, and only for budgets in this account.
  3. Amazon Q Developer posts the alert in the private Slack channel. It runs with an IAM role that can only read CloudWatch, and a guardrail policy caps the channel at the same scope.
Two AWS accounts are involved. The workload account holds the budget, topic, role and Amazon Q configuration. A shared services account holds the S3 bucket with the Terraform state, so a mistake in this project cannot touch the state of other projects. The Slack side is set up once by a script, shown as the dashed line.

Prerequisites and the one manual step

You need four tools and one click in the AWS console; everything after that is code.
ToolVersionUsed for
Terraform1.15Budget, SNS, IAM and Amazon Q resources
AWS CLI v2with IAM Identity Center (SSO) profilesOne profile per account: workload and shared services
uvany recentRuns the Slack bootstrap script, installs Python 3.12 for you
Slacka workspace where you can install appsThe alert channel
Terraform cannot do the OAuth handshake between AWS and Slack, so authorize the workspace once by hand. In the console of the workload account, open Amazon Q Developer in chat applications, choose Configure new client, pick Slack and click Allow on the Slack consent page.
This installs the Amazon Q app in your workspace. Note the workspace ID that the console shows (it starts with T); Terraform needs it later.
Amazon Q Developer in chat applications (previously AWS Chatbot) used to configure Slack chat client.

Step 1: bootstrap the Slack channel with Python and uv

A short Python script creates the private channel and invites the Amazon Q bot and the team; it runs once, so its bot token can be revoked right after.
Create a Slack app with a bot token (xoxb-…) and only the scopes the script needs:
ScopeWhy
channels:read, groups:readFind the channel and list its members
groups:writeCreate the private channel and invite people
users:read, users:read.emailFind the Amazon Q bot and look people up by email
The project is a small uv project (pyproject.toml, uv.lock, .python-version) with one dependency, slack-sdk. The token never appears in code or on the command line: it lives in a git-ignored .env file that uv loads at run time.
1
2
# slack/.env (git-ignored)
SLACK_BOT_TOKEN=xoxb-...
1
2
3
4
5
cd slack
uv sync
uv run --env-file .env main.py \
--channel_name aws-budget-alerting \
--channel_description "Channel used to receive AWS Budget Alerts."
Three design choices make the script safe to run again and again.
Lookups never change anything. find_slack_channels returns the channel ID and whether it is archived. It does not unarchive it; the script stops unless you pass --unarchive, because a channel someone archived on purpose should not come back silently with its old members.
1
2
3
4
5
6
def find_slack_channels(client: WebClient, name: str) -> tuple[str, bool] | None:
for page in client.conversations_list(types=["public_channel", "private_channel"], limit=200):
for channel in page["channels"]:
if channel["name"] == name:
return channel["id"], channel.get("is_archived", False)
return None
Iterating over the response, not over response["channels"], makes slack_sdk follow the pagination cursor, so large workspaces work too.
The bot lookup can't be spoofed by a display name. Any member can rename themselves "Amazon Q". Requiring is_bot means the script invites the real Amazon Q app, never a person pretending to be it.
1
2
3
4
if member.get("is_bot") and not member.get("deleted") and (
member.get("real_name") == "Amazon Q" or member.get("name") == "amazonq"
):
return member["id"], member["real_name"]
Failures exit with code 1. Every step that misses its goal goes into a failures list. already_in_channel counts as success, so a second run is a no-op. At the end, the script prints a summary and calls sys.exit(1) if anything failed, so a wrapper or a CI job can tell.
When it finishes, note the channel ID (it starts with C), then revoke the bot token: Amazon Q delivers alerts through its own Slack integration and never needs it.

Step 2: the AWS side in Terraform

Five building blocks carry the alert path: a budget, an SNS topic and its policy, an IAM role with a guardrail, and the Amazon Q channel configuration.

The budget: forecasts to warn, actuals as the backstop

FORECASTED alerts fire when AWS predicts you will exceed a threshold by month end. They warn early, but AWS needs about five weeks of billing history before it can forecast, and a sudden spike can outrun the forecast. ACTUAL alerts fire once the money is really spent, so they are the backstop. I use both, defined as a map so adding one is a one-line change:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
variable "budget_notifications" {
type = map(object({
type = string
threshold = number
}))

default = {
actual_80 = { type = "ACTUAL", threshold = 80 }
actual_100 = { type = "ACTUAL", threshold = 100 }
forecast_50 = { type = "FORECASTED", threshold = 50 }
forecast_75 = { type = "FORECASTED", threshold = 75 }
forecast_100 = { type = "FORECASTED", threshold = 100 }
}

validation {
condition = alltrue([for n in values(var.budget_notifications) : contains(["FORECASTED", "ACTUAL"], n.type)])
error_message = "Each notification type must be FORECASTED or ACTUAL."
}

validation {
condition = length(var.budget_notifications) <= 10 && alltrue([for n in values(var.budget_notifications) : n.threshold > 0])
error_message = "At most 10 notifications, each with a positive threshold."
}
}
The second validation encodes an AWS limit: a budget accepts at most 10 notifications. The budget itself loops over the map with a dynamic block:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
resource "aws_budgets_budget" "slack" {
name = local.budget_name

budget_type = "COST"
limit_amount = var.budget_limit_amount
limit_unit = "USD"
time_unit = "MONTHLY"

time_period_start = formatdate("YYYY-MM-DD_hh:mm", time_static.budget.rfc3339)

dynamic "notification" {
for_each = var.budget_notifications

content {
comparison_operator = "GREATER_THAN"
notification_type = notification.value.type
threshold = notification.value.threshold
threshold_type = "PERCENTAGE"
subscriber_sns_topic_arns = [aws_sns_topic.slack.arn]
}
}
}
There is deliberately no time_period_end. My first version set it to one year ahead, which means the budget silently stops alerting after twelve months. Without it, AWS defaults the end date to 2087.

The SNS topic: only AWS Budgets may publish

The topic policy has a single statement. It allows SNS:Publish for the budgets.amazonaws.com service principal, and two conditions pin it to budgets in this account, which prevents the confused deputy problem:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
data "aws_iam_policy_document" "slack_sns_topic" {
statement {
sid = "AWS-SNS-Publish-AWSBudgets-notification"
effect = "Allow"
actions = ["SNS:Publish"]

principals {
type = "Service"
identifiers = ["budgets.amazonaws.com"]
}

condition {
test = "ArnLike"
variable = "aws:SourceArn"
values = ["arn:aws:budgets::${data.aws_caller_identity.current.account_id}:*"]
}

condition {
test = "StringEquals"
variable = "aws:SourceAccount"
values = [data.aws_caller_identity.current.account_id]
}

resources = [aws_sns_topic.slack.arn]
}
}

Amazon Q Developer: notifications only, capped twice

Amazon Q assumes an IAM role to act in your account. For a notifications channel, that role only needs to read CloudWatch, the same scope as AWS's "Notification permissions" template. The guardrail is a second, independent cap: whatever any role allows, nothing run through this channel can exceed it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
resource "aws_iam_policy" "chatbot_guardrail" {
name = local.chatbot_guardrail_policy_name
description = "Upper bound for anything run through the budget alerts Slack channel."

policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["cloudwatch:Describe*", "cloudwatch:Get*", "cloudwatch:List*"]
Resource = "*"
}]
})
}

resource "aws_chatbot_slack_channel_configuration" "budget" {
configuration_name = local.chatbot_name
iam_role_arn = aws_iam_role.chatbot.arn
slack_team_id = var.slack_team_id
slack_channel_id = var.slack_channel_id

logging_level = "ERROR"
guardrail_policy_arns = [aws_iam_policy.chatbot_guardrail.arn]
user_authorization_required = true
sns_topic_arns = [aws_sns_topic.slack.arn]
}
I first used the AWS managed ReadOnlyAccess as the guardrail. It reads like a safe choice, but it covers hundreds of read actions, including s3:GetObject. Even CloudWatchReadOnlyAccess allows reading CloudWatch Logs content. A customer-managed policy with exactly the three CloudWatch actions is the real least privilege. user_authorization_required = true adds that nobody can run AWS commands from Slack under the channel's shared role; alerts are not affected.

Naming, tags and state

All names are built in locals.tf from the environment and region (for example p-sns-eun1-budget-to-slack-topic), so two projects in one account never collide on a generic name like AWS-ChatBot. Common tags (Environment, Project, ManagedBy) are set once with the provider's default_tags.
The S3 backend is a partial configuration: backend.tf only sets use_lockfile = true, and the profile, bucket and key come from a git-ignored backend.hcl passed at init. S3 native locking replaces the DynamoDB lock table.

Step 3: deploy, test and what it costs

Deployment is four commands, a test message proves the path end to end, and the running cost is close to zero.
Put your values in a git-ignored terraform.auto.tfvars (SSO profile, environment, the Slack workspace and channel IDs, the budget limit), then:
1
2
3
4
5
6
7
aws sso login --profile <workload-account-profile>

terraform init -backend-config=backend.hcl
terraform fmt -check -recursive
terraform validate
terraform plan
terraform apply
The outputs include the SNS topic ARN and, for each alert, the dollar amount at which it fires. With a $100 limit, actual_80 fires at $80.
You don't have to wait for real spend to test the channel. Amazon Q Developer accepts custom notifications  on any standard SNS topic it subscribes to. Save this as test.json:
1
2
3
4
5
6
7
8
{
"version": "1.0",
"source": "custom",
"content": {
"title": ":white_check_mark: Budget alerts pipeline test",
"description": "If you can read this, SNS to Amazon Q to Slack works."
}
}
1
2
3
4
aws sns publish \
--topic-arn "$(terraform output -raw sns_topic_arn)" \
--message file://test.json \
--profile <workload-account-profile>
The message should appear in the channel within seconds. This works with your own admin identity because, within one account, an IAM policy that allows sns:Publish is enough; the topic policy only governs which AWS services may publish.
ComponentCost
AWS Budgets, without budget actionsFree: "You can monitor and receive notifications on your budgets free of charge" (pricing )
Amazon Q Developer in chat applicationsNo additional charge; you pay only for the resources it uses (FAQ )
Amazon SNSBilled per publish request and delivery (pricing ); a few alerts a month round to zero
Terraform state in S3A few kilobytes in an existing bucket
Only budgets with actions (for example, applying an IAM policy automatically when a threshold is hit) are charged beyond the first two.

Wrapping up

Under 300 lines of Terraform, one short script and one OAuth click give you budget alerts in Slack with no code to run, patch or pay for in AWS.
The pipeline is simple; most of the work went into making each piece do only what it must. If you take one thing away, review the defaults you copy: console policies, managed policies and convenient script arguments are where most of my findings came from.
Where to go next:
  • Point AWS Cost Anomaly Detection at the same SNS topic to catch unusual spend between thresholds.
  • Replace the administrator profiles with a scoped deploy role and a state role limited to one S3 key.
  • Add pre-commit hooks (gitleaks, terraform fmt, tflint, checkov) so these checks run on every commit.
  • Reproduce the project for a Microsoft Teams environment.
The full code, including the README and the Slack scripts, is on GitHub: jimmy-ao/aws_sending-budget-alerts-to-slack . 

Sources

Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article