Table of Contents
Introduction
Managing AWS costs effectively requires continuous monitoring and timely alerts. Unexpected cost spikes can impact budgets significantly, making automated cost notifications essential for proactive cost management. This guide shows you how to build an automated cost notification system using AWS Lambda that sends daily reports via email, Slack, or SNS.
The solution leverages AWS Cost Explorer API to retrieve cost data, processes it to calculate trends and identify top spending services, and delivers comprehensive reports through your preferred notification channel. This approach provides visibility into AWS spending patterns without manual intervention, enabling faster response to cost anomalies.
Prerequisites
Before starting, ensure you have:
- AWS CLI configured with appropriate permissions
- Python 3.9+ installed locally
- AWS account with Cost Explorer enabled (enable via AWS Cost Management Console)
- Basic understanding of AWS Lambda and IAM
Architecture Overview
The automated cost notification system follows this architecture:
CloudWatch Events triggers the Lambda function daily, which retrieves cost data from Cost Explorer API, processes it to calculate trends and top services, and sends notifications through your chosen channel.
Step-by-Step Implementation
1. Enable Cost Explorer and Configure IAM Permissions
First, enable Cost Explorer in your AWS account through the Cost Management Console. Note that data may take up to 24 hours to become available.
Create an IAM role for your Lambda function with permissions for Cost Explorer, SES (for email), and CloudWatch Logs:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ce:GetCostAndUsage",
"ce:GetDimensionValues"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"ses:SendEmail",
"ses:SendRawEmail"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*"
}
]
}2. Implement Lambda Function
The Lambda function retrieves month-to-date and daily cost data, compares it with the previous day, logs both a human-readable summary and structured JSON, then sends the HTML email. The core logic we run in production looks like this:
import os
import json
import datetime as dt
from zoneinfo import ZoneInfo
import boto3
ce = boto3.client("ce")
ses = boto3.client("ses")
def handler(event, context):
tz = ZoneInfo(os.getenv("TIMEZONE", "UTC"))
currency = os.getenv("CURRENCY", "USD")
today_utc = dt.datetime.now(dt.timezone.utc).date()
month_start = today_utc.replace(day=1)
daily_start = today_utc - dt.timedelta(days=1)
prev_day_start = today_utc - dt.timedelta(days=2)
# Fetch month-to-date, yesterday, and day-before-yesterday costs (grouped by service)
month_resp = ce.get_cost_and_usage(
TimePeriod={"Start": month_start.isoformat(), "End": today_utc.isoformat()},
Granularity="MONTHLY",
Metrics=["UnblendedCost"],
GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
)
daily_resp = ce.get_cost_and_usage(
TimePeriod={"Start": daily_start.isoformat(), "End": today_utc.isoformat()},
Granularity="DAILY",
Metrics=["UnblendedCost"],
GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
)
prev_day_resp = ce.get_cost_and_usage(
TimePeriod={"Start": prev_day_start.isoformat(), "End": daily_start.isoformat()},
Granularity="DAILY",
Metrics=["UnblendedCost"],
GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
)
month_groups = month_resp["ResultsByTime"][0]["Groups"] if month_resp["ResultsByTime"] else []
month_service_costs = {g["Keys"][0]: float(g["Metrics"]["UnblendedCost"]["Amount"]) for g in month_groups}
month_total = sum(month_service_costs.values())
daily_groups = daily_resp["ResultsByTime"][0]["Groups"] if daily_resp["ResultsByTime"] else []
daily_service_costs = {g["Keys"][0]: float(g["Metrics"]["UnblendedCost"]["Amount"]) for g in daily_groups}
daily_total = sum(daily_service_costs.values())
prev_groups = prev_day_resp["ResultsByTime"][0]["Groups"] if prev_day_resp["ResultsByTime"] else []
prev_total = sum(float(g["Metrics"]["UnblendedCost"]["Amount"]) for g in prev_groups)
daily_diff = daily_total - prev_total
daily_diff_pct = (daily_diff / prev_total * 100) if prev_total > 0 else 0
header_date = (dt.datetime.now(tz) - dt.timedelta(days=1)).date()
lines = [
"AWS Cost Report",
f"📊 MONTHLY COST (This Month): {currency} {month_total:.2f}",
f"📈 DAILY COST (Yesterday): {currency} {daily_total:.2f}",
f"📊 DAILY CHANGE: {currency} {daily_diff:+.2f} ({daily_diff_pct:+.1f}%)",
f"Report Date: {header_date}",
"",
"Daily Cost Breakdown by Service:",
]
for svc, amt in sorted(daily_service_costs.items(), key=lambda kv: kv[1], reverse=True):
lines.append(f"- {svc}: {currency} {amt:.2f}")
pretty = "\n".join(lines)
structured = {
"title": "AWS Cost Report",
"currency": currency,
"monthly_total": round(month_total, 6),
"daily_total": round(daily_total, 6),
"daily_change": round(daily_diff, 6),
"daily_change_pct": round(daily_diff_pct, 2),
"daily_window": {"start": daily_start.isoformat(), "end": today_utc.isoformat()},
"monthly_window": {"start": month_start.isoformat(), "end": today_utc.isoformat()},
"report_date": header_date.isoformat(),
"by_service": {k: round(v, 6) for k, v in daily_service_costs.items()},
}
print(pretty)
print("JSON:" + json.dumps(structured))
try:
send_email_notification(
pretty,
structured,
currency,
header_date,
month_total,
daily_total,
daily_diff,
daily_diff_pct,
)
print("Email notification sent successfully")
except Exception as exc:
print(f"Failed to send email: {exc}")
return {
"ok": True,
"monthly_total": month_total,
"daily_total": daily_total,
"services": len(daily_service_costs),
}
send_email_notification formats the HTML report inline, highlighting totals and the day-over-day delta. The HTML and plain-text bodies re-use the strings generated in the handler so that recipients see the same data we log to CloudWatch.
def send_email_notification(
pretty_text,
structured_data,
currency,
report_date,
month_total,
daily_total,
daily_diff,
daily_diff_pct,
):
recipients = os.getenv(
"EMAIL_RECIPIENTS", ""
).split(",")
from_address = os.getenv("EMAIL_FROM_ADDRESS", "")
subject = (
f"AWS Cost Report - {report_date} | Monthly: {currency} {month_total:.2f} | "
f"Daily: {currency} {daily_total:.2f}"
)
trend_color = "#e74c3c" if daily_diff < 0 else "#27ae60"
change_row = (
f"↗️ +{currency} {daily_diff:.2f} ({daily_diff_pct:+.1f}%)"
if daily_diff >= 0
else f"↘️ {currency} {daily_diff:.2f} ({daily_diff_pct:+.1f}%)"
)
html_body = f"""
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; background-color: #f8f9fa; }}
.header {{ background-color: #ffffff; padding: 25px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); border-left: 5px solid #ff9900; }}
.service-item {{ margin: 8px 0; padding: 10px; background-color: #f8f9fa; border-left: 4px solid #ff9900; border-radius: 4px; }}
.total {{ font-size: 24px; font-weight: bold; color: #ff9900; }}
</style>
</head>
<body>
<div class="header">
<h1>🏗️ AWS Cost Report</h1>
<p>Report Date: {report_date}</p>
<div>📊 Total cost this month: <span class="total">{currency} {month_total:.2f}</span></div>
<div style="margin-top: 15px;">
<div>📈 Yesterday's cost: <strong>{currency} {daily_total:.2f}</strong></div>
<div style="color: {trend_color}; margin-top: 5px;">{change_row}</div>
</div>
</div>
"""
for svc, amt in sorted(structured_data["by_service"].items(), key=lambda kv: kv[1], reverse=True):
html_body += f'<div class="service-item">{svc}: {currency} {amt:.2f}</div>'
html_body += """
<p style="margin-top: 30px; font-size: 12px; color: #666;">
This is an automated report from your AWS Cost Notifications system.
</p>
</body>
</html>
"""
ses.send_email(
Source=f"AWS Cost Notifications <{from_address}>",
Destination={"ToAddresses": recipients},
Message={
"Subject": {"Data": subject, "Charset": "UTF-8"},
"Body": {
"Html": {"Data": html_body, "Charset": "UTF-8"},
"Text": {"Data": pretty_text, "Charset": "UTF-8"},
},
},
)The remainder of the function builds the full HTML body inline, enumerating service-level costs, the reporting windows, and a generated-at timestamp so the email mirrors exactly what is logged.
3. Configure Email Notifications
SES handles delivery. Make sure the sender and recipient addresses are verified in SES (if your account is still in the sandbox). Configure the following environment variables on the Lambda function:
EMAIL_RECIPIENTS: comma-separated list of recipients.EMAIL_FROM_ADDRESS: sender address.TIMEZONE: optional, IANA timezone identifier for formatting the report date (defaultUTC).CURRENCY: optional, used purely for display (defaultUSD).
These settings ensure the email subject and HTML body show the correct currency, report date, and recipient list. If you duplicate this Lambda in another account, remember to update the default values so they match that environment.
4. Set Up CloudWatch Events Trigger
Create a CloudWatch Events rule to trigger the Lambda function daily:
aws events put-rule \
--name "daily-cost-notifications" \
--schedule-expression "cron(0 9 * * ? *)"This schedule triggers at 9 AM UTC daily. Adjust the cron expression to match your preferred time.
5. Deploy and Test
Package your Lambda function code and deploy it:
zip -r lambda-deployment.zip lambda_function.py
aws lambda create-function \
--function-name aws-cost-notifications \
--runtime python3.9 \
--role <your-iam-role-arn> \
--handler lambda_function.lambda_handler \
--zip-file fileb://lambda-deployment.zipTest the function manually using aws lambda invoke before relying on the scheduled trigger.
Monitoring and Troubleshooting
Monitor your Lambda function through CloudWatch Logs. Common issues include:
- Cost Explorer API Limits: Implement exponential backoff for retries
- SES Sending Limits: Request quota increase or use SNS for distribution
- Missing Cost Data: Verify Cost Explorer is enabled and data is available
- Timezone Issues: Always use UTC for API calls, convert for display only
Set up CloudWatch alarms to monitor function errors and execution duration.
Advanced Features
For enterprise environments, consider adding:
- Multi-Account Support: Use AWS STS to assume roles across multiple accounts
- Cost Forecasting: Leverage Cost Explorer's forecast API for predictive insights
- Threshold-Based Alerts: Send immediate notifications when costs exceed predefined thresholds
- Cost Anomaly Detection: Implement ML-based anomaly detection for unusual spending patterns
Conclusion
This automated cost notification system provides continuous visibility into AWS spending without manual intervention. The solution is scalable, cost-effective (Lambda free tier covers most use cases), and can be customized to match your organization's specific needs.
Start with the email workflow outlined here and extend the same Lambda with additional channels, alert thresholds, or multi-account aggregation as your requirements evolve. Regular monitoring and optimization ensure the system remains reliable and provides actionable cost insights for your team.