Automate Monthly AWS Cost Emails
Build a serverless pipeline that emails your team monthly AWS cost reports.
Introduction
30 Second Summary
Every team that uses cloud services eventually asks the same question: how much did we spend last month? Checking manually means logging into the console, navigating dashboards, and remembering to do it every single month.
In this project, you will build an automated system that emails your team a professional cost comparison report on the 1st of every month. The report compares two months of AWS spending with color-coded indicators and currency conversion.
What You'll Build
A serverless cost reporting pipeline using AWS Lambda, Amazon SES, EventBridge Scheduler, and Cost Explorer.
By the end of this project, you'll have:
- A Python Lambda function that queries Cost Explorer for two months of spending data and sends a formatted HTML email via SES.
- An IAM role with least-privilege permissions scoped to Cost Explorer and SES only.
- An EventBridge Scheduler cron schedule that triggers monthly delivery on the 1st without any manual intervention.
- Secret Mission: A per-service cost breakdown table showing the top 5 AWS services by spend.
Are there any prerequisites?
You need an AWS account with billing access enabled. You should also be comfortable reading Python code, though all code is provided for you to paste.
Before We Start
Before We Start...
Set Up SES Email Identity and Request Production Access
Before your Lambda function can send cost report emails, it needs a verified sender address. Amazon SES requires you to prove ownership of every email address you send from.
In this step, you will create a verified email identity in SES and submit a request for production access so your reports can reach your full team.
In this step, get ready to:
- Verify your email address in Amazon SES.
- Request production access to unlock team-wide email delivery.
Create and verify your email identity
SES uses verified identities to confirm you own the email address you want to send from. You will create an email address identity and click a confirmation link to complete verification.
- Sign in to the AWS Management Console.
- Use the Region selector in the top-right corner and choose US East (N. Virginia) us-east-1.
- Search for SES in the search bar and select Amazon Simple Email Service.
- In the left navigation pane, under Configuration, choose Identities.
- Click Create identity.
- Under Identity details, select Email address as the identity type.
- In the Email address field, enter the email address you want to send cost reports from.
- Click Create identity.
What just happened?
SES sent a verification email to the address you entered. You need to prove you own this email before SES lets you send from it.
- Open the inbox for the email address you just entered.
- Look for an email from no-reply-aws@amazon.com.
- Open the email and click the verification link inside it.
- Switch back to the SES console. Refresh the page and confirm that your email identity now shows a status of Verified.
Don't see the verification email?
- Check your spam or junk mail folder.
- Confirm that the email address can actually receive mail by sending a test email to it from a different account.
- Double-check the SES Identities list for typos in the address you entered.
Help me troubleshoot my SES email verification.
Request production access
New SES accounts start in sandbox mode, which only allows sending to verified email addresses. To send cost reports to your entire team without verifying each recipient individually, you need production access.
What is the SES sandbox?
The sandbox is a restricted mode that prevents new accounts from sending unsolicited emails. While in the sandbox, you can only send to addresses you have manually verified.
Production access removes this restriction and lets you send to any recipient. AWS reviews your request manually, and typically provides an initial response within 24 hours.
- In the SES console left navigation, choose Account dashboard.
- Look for the warning banner at the top that says your account is in the sandbox.
- On the right side of the banner, click View Get set up page, then click Request production access.
- Select the Transactional radio button for the mail type.
- In the Website URL field, enter a URL that represents you or your organization.
- In Additional contacts, add an email where AWS can reach you about the request.
- For Preferred contact language, choose your language.
- In the Acknowledgement section, check the box confirming you will only send to opted-in recipients and have a process for handling bounces and complaints.
- Click Submit request.
What should I write in the use case description?
Describe your use case as sending monthly internal cost reports to team members. Mention that emails are transactional, low-volume (one email per month per recipient), and sent only to internal team members who have opted in.
A banner confirms your request was submitted and is currently under review. AWS typically provides an initial response within 24 hours.
You can continue while waiting
You do not need to wait for production access approval to proceed with this project. While in the sandbox, you can still test by sending emails to your own verified address.
Don't see the sandbox warning banner?
Some accounts may receive automatic production access. If you do not see the warning banner, your account may already have production access. Check the Account dashboard for your current sending limits to confirm.
Help me check if my SES account already has production access.
Your email identity is verified and your production access request is on its way. Next up, you will create the IAM role that gives your Lambda function permission to use Cost Explorer and SES.
Create the IAM Role for Lambda
Your SES email identity is verified and ready to send. Now you need to give your Lambda function permission to access the AWS services it depends on.
In AWS, a function cannot call other services unless it has an IAM role that explicitly grants those permissions. You will create a dedicated role that allows your function to read cost data from Cost Explorer and send emails through SES.
In this step, get ready to:
- Create an IAM role that Lambda can assume.
- Attach a managed policy for CloudWatch Logs.
- Add an inline policy granting Cost Explorer and SES permissions.
Create a new IAM role with Lambda as the trusted entity
An IAM role defines who can use it (the trusted entity) and what they can do (the permissions policies). By selecting Lambda as the trusted entity, you allow any Lambda function in your account to assume this role at runtime.
- In the AWS Console, type IAM into the top search bar and select IAM from the results.
- In the left navigation pane, click Roles.
- Click Create role.
- For Trusted entity type, select AWS service.
- For Service or use case, choose Lambda.
- Click Next.
What is an IAM role?
An IAM role is an identity you create in your AWS account that has specific permissions. Instead of being tied to one person, a role can be assumed by anyone or anything you trust.
In this case, the Lambda service itself assumes the role every time your function runs.
Attach the managed policy and create an inline policy
Your role needs two sets of permissions. The first is a managed policy that lets Lambda write logs to CloudWatch. The second is a custom inline policy granting access to Cost Explorer and SES specifically.
- On the Add permissions page, search for AWSLambdaBasicExecutionRole in the search box.
- Select the checkbox next to AWSLambdaBasicExecutionRole.
- Click Next.
- For Role name, enter lambda-cost-report-role.
- Click Create role.
What does AWSLambdaBasicExecutionRole do?
This AWS-managed policy grants permission to write logs to CloudWatch Logs. Without it, your Lambda function cannot output any log data, making it impossible to debug issues.
Now you will add the inline policy for Cost Explorer and SES access.
- In the Roles list, search for and click lambda-cost-report-role to open it.
- Select the Permissions tab.
- Click Add permissions, then select Create inline policy.
- In the Policy editor section, select the JSON tab.
- Delete any existing content in the editor and paste the following JSON policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "ce:GetCostAndUsage",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "ses:SendEmail",
"Resource": "arn:aws:ses:us-east-1:[[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]]:identity/[[SES_EMAIL="your verified SES email address"]]"
}
]
}
What does this policy do?
This inline policy grants two specific permissions following the principle of least privilege:
- ce:GetCostAndUsage allows the function to query AWS Cost Explorer for spending data. The resource is * because Cost Explorer does not support resource-level permissions.
- ses:SendEmail allows the function to send emails, but only from your specific verified identity. This prevents the function from sending as any other address.
Find your AWS account ID
Click your account name in the top-right corner of the AWS Console. Your 12-digit account ID is displayed in the dropdown menu. Copy it without the hyphens.
- Click Next.
- For Policy name, enter cost-report-permissions.
- Click Create policy.
Verify the role is configured correctly
- On the lambda-cost-report-role summary page, check the Permissions tab.
- You should see two policies listed: AWSLambdaBasicExecutionRole (managed) and cost-report-permissions (inline).
Only seeing one policy?
If cost-report-permissions is missing, make sure you clicked Create policy after pasting the JSON. If you navigated away before saving, return to the role and click Add permissions. Then select Create inline policy and repeat the JSON step.
Help me troubleshoot my inline policy
Your IAM role is locked down with exactly the permissions your function needs. Next up, you will create the Lambda function itself and wire it to this role.
Create and Deploy the Lambda Function
You have an IAM role with the right permissions. Now you need the actual function that does the work.
In this step, you will create a Lambda function that pulls cost data from Cost Explorer, converts USD to INR using a live exchange rate, builds a color-coded HTML email, and sends it through SES.
In this step, get ready to:
- Create a Lambda function with the Python 3.13 runtime and your custom IAM role.
- Paste the cost report Python code into the inline editor.
- Configure environment variables and deploy the function.
Create a new Lambda function
Lambda needs a function definition before you can give it any code. You will create one with the Python 3.13 runtime and attach the role you built in the previous step.
- In the AWS Console, search for Lambda and open the Lambda service.
- Click Create function.
- Select Author from scratch.
- In the Function name field, enter monthly-cost-report.
- For Runtime, select Python 3.13 from the dropdown.
- Expand the Change default execution role section.
- Select Use an existing role.
- In the Existing role dropdown, choose lambda-cost-report-role.
- Click Create function.
You should see a success banner confirming your function was created. The console opens the function's Code tab with a default lambda_function.py file in the editor.
Why Python 3.13?
Python 3.13 runs on Amazon Linux 2023 and includes boto3 pre-installed. This means your function can call AWS services like Cost Explorer and SES without uploading any extra packages.
Next, increase the function timeout so it has enough time to make all its API calls.
- Click the Configuration tab.
- Select General configuration in the left sidebar.
- Click Edit.
- Change the Timeout value to 0 min 15 sec.
- Click Save.
Why increase the timeout?
The default Lambda timeout is 3 seconds. Your function makes two Cost Explorer API calls, one HTTP request to an exchange rate API, and one SES call. 15 seconds gives enough headroom for all four network requests to complete.
Don't see your role in the dropdown?
Make sure you created the role with Lambda as the trusted entity in Step 2. If it was created for a different service, it will not appear here.
Also confirm you are in the same region (us-east-1) where you created the role.
help me find my IAM role for my Lambda function
Paste the Python code into the inline editor
The code editor already has a default lambda_function.py file open. You will replace its contents with the cost report logic.
- Click the Code tab to return to the editor.
- Select all the existing code in lambda_function.py and delete it.
- Copy and paste the following code into the editor:
import boto3
import json
import os
import urllib.request
from datetime import datetime, timedelta
def get_exchange_rate():
"""Fetch live USD to INR exchange rate from free API."""
url = "https://open.er-api.com/v6/latest/USD"
try:
with urllib.request.urlopen(url, timeout=5) as response:
data = json.loads(response.read().decode())
if data["result"] == "success":
return data["rates"]["INR"]
except Exception as e:
print(f"Exchange rate fetch failed: {e}")
return 83.0 # Fallback rate if API fails
def get_monthly_costs(ce_client, start_date, end_date):
"""Get total cost for a date range from Cost Explorer."""
response = ce_client.get_cost_and_usage(
TimePeriod={
'Start': start_date,
'End': end_date
},
Granularity='MONTHLY',
Metrics=['UnblendedCost']
)
results = response['ResultsByTime']
if results:
return float(results[0]['Total']['UnblendedCost']['Amount'])
return 0.0
def build_html_email(month1_name, month2_name, month1_cost, month2_cost, inr_rate):
"""Build the HTML email with color-coded comparison."""
difference = month2_cost - month1_cost
if month1_cost > 0:
pct_change = (difference / month1_cost) * 100
else:
pct_change = 0.0
month1_inr = month1_cost * inr_rate
month2_inr = month2_cost * inr_rate
diff_inr = difference * inr_rate
if difference > 0:
color = "#e74c3c"
arrow = "▲"
sign = "+"
elif difference < 0:
color = "#27ae60"
arrow = "▼"
sign = ""
else:
color = "#7f8c8d"
arrow = ""
sign = ""
html = f"""
<html>
<body style="font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<div style="background-color: #1a73e8; padding: 20px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 22px;">Monthly AWS Cost Report</h1>
</div>
<div style="padding: 30px;">
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr style="background-color: #f8f9fa;">
<th style="padding: 12px; text-align: left; border-bottom: 2px solid #dee2e6;"></th>
<th style="padding: 12px; text-align: right; border-bottom: 2px solid #dee2e6;">{month1_name}</th>
<th style="padding: 12px; text-align: right; border-bottom: 2px solid #dee2e6;">{month2_name}</th>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #dee2e6; font-weight: bold;">Total Cost</td>
<td style="padding: 12px; text-align: right; border-bottom: 1px solid #dee2e6;">${month1_cost:.2f} (₹{month1_inr:,.0f})</td>
<td style="padding: 12px; text-align: right; border-bottom: 1px solid #dee2e6;">${month2_cost:.2f} (₹{month2_inr:,.0f})</td>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #dee2e6; font-weight: bold;">Difference</td>
<td colspan="2" style="padding: 12px; text-align: right; border-bottom: 1px solid #dee2e6; color: {color}; font-weight: bold;">
{arrow} {sign}${abs(difference):.2f} (₹{abs(diff_inr):,.0f})
</td>
</tr>
<tr>
<td style="padding: 12px; font-weight: bold;">Change</td>
<td colspan="2" style="padding: 12px; text-align: right; color: {color}; font-weight: bold; font-size: 18px;">
{arrow} {sign}{pct_change:.1f}%
</td>
</tr>
</table>
</div>
<div style="background-color: #f8f9fa; padding: 15px; text-align: center; font-size: 12px; color: #6c757d;">
This report is generated automatically by AWS Lambda.
</div>
</div>
</body>
</html>
"""
return html
def lambda_handler(event, context):
"""Main Lambda handler."""
sender = os.environ['SENDER_EMAIL']
recipients = os.environ['RECIPIENT_EMAILS'].split(',')
today = datetime.utcnow().replace(day=1)
month2_end = today.strftime('%Y-%m-%d')
month2_start = (today - timedelta(days=1)).replace(day=1).strftime('%Y-%m-%d')
month1_start = (datetime.strptime(month2_start, '%Y-%m-%d') - timedelta(days=1)).replace(day=1).strftime('%Y-%m-%d')
month1_name = datetime.strptime(month1_start, '%Y-%m-%d').strftime('%B %Y')
month2_name = datetime.strptime(month2_start, '%Y-%m-%d').strftime('%B %Y')
ce_client = boto3.client('ce', region_name='us-east-1')
ses_client = boto3.client('ses', region_name='us-east-1')
month1_cost = get_monthly_costs(ce_client, month1_start, month2_start)
month2_cost = get_monthly_costs(ce_client, month2_start, month2_end)
inr_rate = get_exchange_rate()
html_body = build_html_email(month1_name, month2_name, month1_cost, month2_cost, inr_rate)
subject = f"AWS Cost Report: {month2_name} vs {month1_name}"
ses_client.send_email(
Source=sender,
Destination={
'ToAddresses': recipients
},
Message={
'Subject': {'Data': subject, 'Charset': 'UTF-8'},
'Body': {
'Html': {'Data': html_body, 'Charset': 'UTF-8'}
}
}
)
return {
'statusCode': 200,
'body': json.dumps({
'message': 'Cost report sent successfully',
'month1': month1_name,
'month2': month2_name,
'month1_cost_usd': round(month1_cost, 2),
'month2_cost_usd': round(month2_cost, 2),
'inr_rate': inr_rate
})
}
What does this code do?
- get_exchange_rate() calls a free API to get the current USD-to-INR rate. If the request fails, it falls back to a hardcoded rate of 83.0.
- get_monthly_costs() queries Cost Explorer's get_cost_and_usage method for a specific date range and returns the total unblended cost as a float.
- build_html_email() calculates the difference between two months, picks a color (red for increase, green for decrease), and assembles a styled HTML table.
- lambda_handler() is the entry point Lambda calls. It reads environment variables, calculates date ranges for the previous two months, fetches costs, builds the email, and sends it via SES.
✔️ Awesome, I've got everything!
Great. Make sure all the code is pasted and you have not accidentally left any of the original default code in the file.
ⓧ I'd like to double check the full code
Here is the complete lambda_function.py file for comparison:
import boto3
import json
import os
import urllib.request
from datetime import datetime, timedelta
def get_exchange_rate():
"""Fetch live USD to INR exchange rate from free API."""
url = "https://open.er-api.com/v6/latest/USD"
try:
with urllib.request.urlopen(url, timeout=5) as response:
data = json.loads(response.read().decode())
if data["result"] == "success":
return data["rates"]["INR"]
except Exception as e:
print(f"Exchange rate fetch failed: {e}")
return 83.0 # Fallback rate if API fails
def get_monthly_costs(ce_client, start_date, end_date):
"""Get total cost for a date range from Cost Explorer."""
response = ce_client.get_cost_and_usage(
TimePeriod={
'Start': start_date,
'End': end_date
},
Granularity='MONTHLY',
Metrics=['UnblendedCost']
)
results = response['ResultsByTime']
if results:
return float(results[0]['Total']['UnblendedCost']['Amount'])
return 0.0
def build_html_email(month1_name, month2_name, month1_cost, month2_cost, inr_rate):
"""Build the HTML email with color-coded comparison."""
difference = month2_cost - month1_cost
if month1_cost > 0:
pct_change = (difference / month1_cost) * 100
else:
pct_change = 0.0
month1_inr = month1_cost * inr_rate
month2_inr = month2_cost * inr_rate
diff_inr = difference * inr_rate
if difference > 0:
color = "#e74c3c"
arrow = "▲"
sign = "+"
elif difference < 0:
color = "#27ae60"
arrow = "▼"
sign = ""
else:
color = "#7f8c8d"
arrow = ""
sign = ""
html = f"""
<html>
<body style="font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<div style="background-color: #1a73e8; padding: 20px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 22px;">Monthly AWS Cost Report</h1>
</div>
<div style="padding: 30px;">
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr style="background-color: #f8f9fa;">
<th style="padding: 12px; text-align: left; border-bottom: 2px solid #dee2e6;"></th>
<th style="padding: 12px; text-align: right; border-bottom: 2px solid #dee2e6;">{month1_name}</th>
<th style="padding: 12px; text-align: right; border-bottom: 2px solid #dee2e6;">{month2_name}</th>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #dee2e6; font-weight: bold;">Total Cost</td>
<td style="padding: 12px; text-align: right; border-bottom: 1px solid #dee2e6;">${month1_cost:.2f} (₹{month1_inr:,.0f})</td>
<td style="padding: 12px; text-align: right; border-bottom: 1px solid #dee2e6;">${month2_cost:.2f} (₹{month2_inr:,.0f})</td>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #dee2e6; font-weight: bold;">Difference</td>
<td colspan="2" style="padding: 12px; text-align: right; border-bottom: 1px solid #dee2e6; color: {color}; font-weight: bold;">
{arrow} {sign}${abs(difference):.2f} (₹{abs(diff_inr):,.0f})
</td>
</tr>
<tr>
<td style="padding: 12px; font-weight: bold;">Change</td>
<td colspan="2" style="padding: 12px; text-align: right; color: {color}; font-weight: bold; font-size: 18px;">
{arrow} {sign}{pct_change:.1f}%
</td>
</tr>
</table>
</div>
<div style="background-color: #f8f9fa; padding: 15px; text-align: center; font-size: 12px; color: #6c757d;">
This report is generated automatically by AWS Lambda.
</div>
</div>
</body>
</html>
"""
return html
def lambda_handler(event, context):
"""Main Lambda handler."""
sender = os.environ['SENDER_EMAIL']
recipients = os.environ['RECIPIENT_EMAILS'].split(',')
today = datetime.utcnow().replace(day=1)
month2_end = today.strftime('%Y-%m-%d')
month2_start = (today - timedelta(days=1)).replace(day=1).strftime('%Y-%m-%d')
month1_start = (datetime.strptime(month2_start, '%Y-%m-%d') - timedelta(days=1)).replace(day=1).strftime('%Y-%m-%d')
month1_name = datetime.strptime(month1_start, '%Y-%m-%d').strftime('%B %Y')
month2_name = datetime.strptime(month2_start, '%Y-%m-%d').strftime('%B %Y')
ce_client = boto3.client('ce', region_name='us-east-1')
ses_client = boto3.client('ses', region_name='us-east-1')
month1_cost = get_monthly_costs(ce_client, month1_start, month2_start)
month2_cost = get_monthly_costs(ce_client, month2_start, month2_end)
inr_rate = get_exchange_rate()
html_body = build_html_email(month1_name, month2_name, month1_cost, month2_cost, inr_rate)
subject = f"AWS Cost Report: {month2_name} vs {month1_name}"
ses_client.send_email(
Source=sender,
Destination={
'ToAddresses': recipients
},
Message={
'Subject': {'Data': subject, 'Charset': 'UTF-8'},
'Body': {
'Html': {'Data': html_body, 'Charset': 'UTF-8'}
}
}
)
return {
'statusCode': 200,
'body': json.dumps({
'message': 'Cost report sent successfully',
'month1': month1_name,
'month2': month2_name,
'month1_cost_usd': round(month1_cost, 2),
'month2_cost_usd': round(month2_cost, 2),
'inr_rate': inr_rate
})
}
Configure environment variables and deploy
The code reads two environment variables at runtime: SENDER_EMAIL (who the email comes from) and RECIPIENT_EMAILS (who receives it). You need to set these in the Lambda console before deploying.
- Click the Configuration tab.
- Select Environment variables in the left sidebar.
- Click Edit.
- Click Add environment variable.
- For Key, enter SENDER_EMAIL. For Value, enter the email address you verified in SES (Step 1).
- Click Add environment variable again.
- For Key, enter RECIPIENT_EMAILS. For Value, enter a comma-separated list of the email addresses you want to receive the report.
- Click Save.
Still in SES sandbox?
If your production access request has not been approved yet, both the sender and every recipient must be individually verified in SES. For now, set RECIPIENT_EMAILS to the same email you verified as the sender.
Now deploy the function code.
- Click the Code tab to return to the editor.
- Click Deploy in the editor toolbar.
You should see a banner confirming the function was updated successfully.
Seeing a deployment error?
Check that you did not accidentally leave a stray character or incomplete paste in the editor. The most common issue is a missing closing quote or parenthesis from a partial paste.
Also make sure the file is still named lambda_function.py and the handler function is named lambda_handler. Lambda uses these names by default to find the entry point.
help me fix my Lambda deployment error
Your function is deployed and configured. Next, you will test it to confirm it actually sends you a cost report email.
Test the Lambda Function
Your function is deployed, but you have not yet confirmed it can successfully pull cost data, format the HTML, and deliver the email.
In this step, you will trigger the function manually with a test event and verify the complete end-to-end flow.
In this step, get ready to:
- Create a test event and execute the function.
- Verify you receive the HTML cost report email in your inbox.
- Troubleshoot errors using CloudWatch Logs if needed.
Create a test event and run the function
Lambda lets you simulate an invocation by sending a test event directly from the console. Your function expects no specific input data, so an empty JSON object is all you need.
- Return to your Lambda function page in the AWS Console.
- Click the Test tab near the top of the code editor.
- Under Test event, select Create new event.
- Enter a name for your test event (e.g. test-cost-report).
- In the Event JSON editor, make sure the payload is the default empty JSON:
{}
- Click Test.
Why an empty JSON payload?
Your lambda_handler function calculates dates and pulls cost data entirely from the current date and environment variables. It does not read anything from the incoming event payload, so an empty {} is sufficient.
After a few seconds, the Execution results panel appears at the top of the page. You should see a green Succeeded badge and a response body containing a 200 status code.
Verify the cost report email in your inbox
A successful execution means the function ran without errors. Now confirm the email actually arrived.
- Open the inbox for the email address you set in the RECIPIENT_EMAILS environment variable.
- Look for an email with the subject line AWS Cost Report: [Month] vs [Month] (the two months will match your account's billing history).
- Open the email and confirm you see the formatted HTML table with USD amounts, INR conversions, and color-coded difference indicators (green for a decrease, red for an increase).
What if my account is still in SES sandbox?
While your production access request is pending, SES sandbox mode only delivers emails to verified addresses. The email will arrive at your own verified address, but not to any unverified team members yet.
Once your production access is approved (typically within 24 hours), emails will reach all recipients without individual verification.
Troubleshoot with CloudWatch Logs if needed
If the test execution failed or the email did not arrive, CloudWatch Logs captures the detailed output from your function. This is where you can pinpoint exactly what went wrong.
- In the Lambda function page, click the Monitor tab.
- Click View CloudWatch logs to open the log group for your function.
- Click the most recent log stream (the one with the latest timestamp).
- Scan the log entries for error messages.
Seeing an error in the execution results?
Here are the most common issues and their fixes:
- AccessDeniedException for Cost Explorer: Your inline policy is missing the ce:GetCostAndUsage permission. Go back to IAM and verify the inline policy on lambda-cost-report-role.
- MessageRejected for SES: Your sender email does not match the verified identity. Confirm the SENDER_EMAIL environment variable matches the email you verified in SES exactly.
- Task timed out: The function ran longer than the timeout allows. Confirm the timeout is set to 15 seconds under General configuration in the Configuration tab.
Still stuck? Help me debug the error I'm seeing in my Lambda execution results.
Your function works end-to-end. Next up, you will schedule it to fire automatically on the 1st of every month so your team never has to check costs manually again.
Schedule Monthly Execution with EventBridge Scheduler
Your Lambda function delivers a professional cost report on demand. The next challenge is removing the human from the loop entirely.
EventBridge Scheduler is AWS's fully managed scheduling service that triggers your function on a predictable cadence. You'll configure a cron expression that fires on the 1st of every month, so your team receives fresh cost data without anyone clicking a button.
In this step, get ready to:
- Create a recurring cron-based schedule in EventBridge Scheduler.
- Connect the schedule to your Lambda function as its target.
- Enable the schedule and verify it shows as active.
Create the schedule and define the cron expression
EventBridge Scheduler uses cron expressions to define exactly when your function runs. The six-field format specifies minutes, hours, day of month, month, day of week, and year.
- Navigate to the EventBridge Scheduler console at https://console.aws.amazon.com/scheduler/home.
- On the Schedules page, click Create schedule.
- On the Specify schedule detail page, in the Schedule name and description section, enter monthly-cost-report for Schedule name.
- For Schedule group, leave the dropdown set to default.
- In the Schedule pattern section, for Occurrence, select Recurring schedule.
- Select Cron-based schedule and enter the following expression:
cron(0 8 1 * ? *)
What does this cron expression mean?
The six fields in cron(0 8 1 * ? *) map to: minutes (0), hours (8), day of month (1), month (every), day of week (any), year (every).
This fires at 8:00 AM UTC on the 1st of every month. Your team starts the month with a fresh spending comparison in their inbox.
- For Flexible time window, select Off from the dropdown.
- In the Timeframe section, for Timezone, choose your local timezone from the dropdown.
- Click Next.
Why turn off the flexible time window?
A flexible time window lets EventBridge run your schedule within a range (for example, within 15 minutes of the target time). Turning it off ensures the function fires at exactly 8:00 AM UTC on the 1st. For a cost report, precision matters more than load distribution.
Select the Lambda function as the target
- On the Select target page, choose Templated targets.
- Select AWS Lambda Invoke.
- In the Invoke section, select your cost report Lambda function from the dropdown.
- In the payload input field, enter the following JSON:
{}
Why an empty JSON payload?
Your Lambda function does not need any input data from the event. It calculates dates and pulls cost data on its own. The empty {} satisfies EventBridge's requirement for a valid JSON payload without passing unnecessary data.
- Click Next.
Enable the schedule and verify
- On the Settings page, under Schedule state, confirm the Enable schedule toggle is on.
- Under Permissions, select Create new role for this schedule.
- Click Next.
- On the Review and create schedule page, review your settings.
- Click Create schedule.
What about the execution role?
EventBridge Scheduler automatically creates an execution role with the permissions needed to invoke your Lambda function. You do not need to configure this role manually. It is separate from the lambda-cost-report-role you created earlier (which Lambda itself uses to call Cost Explorer and SES).
You should now be back on the Schedules page. Under the Status column, verify that monthly-cost-report shows as Enabled.
Schedule not showing as Enabled?
- Make sure you toggled Enable schedule on in the Settings step. Click the schedule name, then choose Edit to go back and enable it.
- Check that you selected Create new role for this schedule under Permissions. Without an execution role, EventBridge cannot invoke the function.
Help me troubleshoot my EventBridge Scheduler schedule
Secret mission
Add Per-Service Cost Breakdown
Your cost report shows the total spending, but which services are driving that number? Add a per-service breakdown table that shows the top 5 AWS services by spend, complete with USD and INR columns, right below your monthly comparison summary.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project has an ongoing cost of approximately $0.02/month from Cost Explorer API calls.
Cost warning
Each time the Lambda function runs, it makes 2-3 Cost Explorer API calls at $0.01 each. If you leave the schedule enabled, expect about $0.02/month in charges. All other resources (Lambda, SES, EventBridge Scheduler) fall well within their free tiers.
Resources you used:
- 1 Lambda function (free tier covers 1M requests/month).
- 1 IAM role (lambda-cost-report-role) with an inline policy.
- 1 SES verified email identity.
- 1 EventBridge Scheduler schedule (monthly-cost-report) with an auto-created execution role.
Keep everything running
No action needed. Choose this if you want to keep receiving monthly cost reports automatically.
- Your schedule fires on the 1st of every month at 8:00 AM UTC and costs about $0.02/month in Cost Explorer API fees.
- All other resources stay comfortably within their free tiers.
- If your SES production access request is approved, the function can send to any email address without individual verification.
Pause - I'll come back to this later
Disable the schedule to stop automatic execution. Your function and configuration stay intact so you can re-enable anytime.
- Navigate to Amazon EventBridge in the AWS Console.
- Select Schedules in the left sidebar.
- Click on monthly-cost-report.
- Click Edit, then toggle Enable schedule to off.
- Click Save. The status changes to Disabled.
To resume, return to the schedule and toggle it back on.
Delete - I don't want to use this again
Remove all project resources completely. Follow these steps in order.
Delete the EventBridge Scheduler schedule:
- Navigate to Amazon EventBridge in the AWS Console, then select Schedules.
- Select the checkbox next to monthly-cost-report.
- Click Delete and confirm.
Delete the Lambda function:
- Navigate to Lambda in the AWS Console, then select Functions.
- Select the checkbox next to your cost report function.
- Click Actions, then Delete. Type delete to confirm.
Delete the IAM roles:
- Navigate to IAM in the AWS Console, then select Roles.
- Search for lambda-cost-report-role. Select it and click Delete. Type the role name to confirm.
- Search for the auto-created EventBridge Scheduler execution role (its name starts with Amazon_EventBridge_Scheduler). Select it and click Delete. Type the role name to confirm.
Delete the SES verified identity:
- Navigate to Amazon SES in the AWS Console, then select Verified identities.
- Select the checkbox next to your verified email address.
- Click Delete and confirm.
That's a Wrap!
That's a Wrap!
Nice work! You have built a fully automated monthly cost reporting system using AWS serverless services. Every month, your team receives a professional HTML email comparing AWS spending without anyone lifting a finger.
You've learned how to:
- Built a Python Lambda function that queries the Cost Explorer API for two months of cost data and sends formatted HTML emails via SES.
- Integrated a free exchange rate API for dual-currency reporting with color-coded indicators showing cost increases in red and decreases in green.
- Configured EventBridge Scheduler with a cron expression to automate monthly delivery on the 1st, creating a zero-maintenance reporting pipeline.
- Secret Mission: Added a per-service cost breakdown table showing the top 5 AWS services by spend.
Ready to quiz yourself?