How we built a serverless solution that sends daily AWS cost reports to Microsoft Teams, featuring account names, cost analysis, and smart alerts - all for less than $0.50/month.
A serverless, automated AWS cost reporting system that posts daily insights directly to Microsoft Teams using AWS SAM and Python.
The Challenge
As our AWS infrastructure grew across multiple accounts and services, keeping track of daily spending became increasingly difficult. We needed a solution that would:
- Provide daily visibility into AWS costs across all accounts
- Send alerts for significant cost changes
- Display meaningful account names instead of cryptic account IDs
- Integrate seamlessly with our existing Microsoft Teams workflow
- Cost almost nothing to operate
- Be easy to deploy and maintain
Traditional solutions like AWS Budgets or third-party tools either lacked the customization we needed or came with hefty price tags. So we decided to build our own serverless solution using AWS SAM.
The Solution Architecture
Our final architecture is elegantly simple yet powerful:
βββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
β EventBridge βββββΆβ Lambda Function βββββΆβ Microsoft Teams β
β (Schedule) β β (Billing Bot) β β (Power Automate)β
βββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
β
βΌ
ββββββββββββββββββββ βββββββββββββββββββ
β AWS Cost Explorerβ β AWS Organizationsβ
β (Billing Data) β β (Account Names) β
ββββββββββββββββββββ βββββββββββββββββββ
Key Components:
- AWS Lambda: Processes billing data and formats reports
- EventBridge: Triggers daily reports at 9 AM NZT
- Cost Explorer API: Provides detailed billing data
- Organizations API: Resolves account names
- Microsoft Teams: Receives rich Adaptive Card notifications
Why We Chose AWS SAM Over CDK
Initially, we started with AWS CDK but quickly ran into deployment complexity:
- Docker dependency for Python packaging
- Complex bundling configuration
- Deployment failures due to missing dependencies
- Harder local testing
AWS SAM proved to be the better choice:
# Simple deployment with SAM
sam build
sam deploy --guided
SAM Benefits:
- β No Docker required
- β Built-in Python dependency management
- β
Excellent local testing with
sam local invoke - β Cleaner template syntax
- β Better error messages
- β Faster development cycle
Implementation Journey
Phase 1: Basic Cost Reporting
We started with a simple Lambda function that fetches yesterday's costs:
def get_billing_data(self):
"""Fetch billing data using AWS Cost Explorer API"""
today = datetime.now().date()
yesterday = today - timedelta(days=1)
response = self.ce_client.get_cost_and_usage(
TimePeriod={
'Start': yesterday.strftime('%Y-%m-%d'),
'End': today.strftime('%Y-%m-%d')
},
Granularity='DAILY',
Metrics=['UnblendedCost'],
GroupBy=[
{'Type': 'DIMENSION', 'Key': 'LINKED_ACCOUNT'},
{'Type': 'DIMENSION', 'Key': 'SERVICE'}
]
)
return response
Phase 2: Enhanced Analytics
We added comparative analysis and projections:
# Calculate daily and weekly changes
daily_change = yesterday_total - previous_total
daily_change_pct = (daily_change / previous_total * 100) if previous_total > 0 else 0
# Project monthly costs
daily_average = mtd_total / days_elapsed if days_elapsed > 0 else yesterday_total
projected_monthly = daily_average * days_in_month
Phase 3: Teams Integration Challenge
The biggest challenge was getting Microsoft Teams integration right. Native Teams webhooks are being deprecated, so we had to use Power Automate:
Key Learning: Power Automate expects Adaptive Cards with "type": "AdaptiveCard", not the legacy MessageCard format.
adaptive_card = {
"type": "AdaptiveCard",
"version": "1.2",
"body": [
{
"type": "Container",
"style": color,
"items": [
{
"type": "TextBlock",
"text": "π° AWS Daily Billing Report",
"weight": "Bolder",
"size": "Large"
}
]
}
]
}
Phase 4: Account Names Feature
Business users wanted meaningful account names instead of masked IDs like "****7737". We integrated AWS Organizations API:
def get_account_name(self, account_id):
"""Get account name from Organizations API with caching"""
if account_id in self.account_names_cache:
return self.account_names_cache[account_id]
try:
response = self.org_client.describe_account(AccountId=account_id)
account_name = response['Account']['Name']
self.account_names_cache[account_id] = account_name
return account_name
except Exception as e:
# Graceful fallback to masked ID
masked_id = f"****{account_id[-4:]}"
self.account_names_cache[account_id] = f"Account {masked_id}"
return self.account_names_cache[account_id]
Critical IAM Lesson: We initially added a restrictive condition to the Organizations permissions:
# This DIDN'T work
- Effect: Allow
Action:
- organizations:DescribeAccount
Resource: '*'
Condition:
StringEquals:
'organizations:ActionType': 'READ' # This condition caused access denied
The fix was simple - remove the condition:
# This WORKS
- Effect: Allow
Action:
- organizations:DescribeAccount
Resource: '*'
Phase 5: Historical Context
Users wanted better context for cost projections, so we added last month's total:
# Fetch last month's data for context
last_month_response = self.ce_client.get_cost_and_usage(
TimePeriod={
'Start': last_month_start.strftime('%Y-%m-%d'),
'End': last_month_end.strftime('%Y-%m-%d')
},
Granularity='MONTHLY',
Metrics=['UnblendedCost']
)
Deployment Best Practices
1. Secure Parameter Management
Never hardcode webhook URLs in your code. Use parameter overrides:
# Secure deployment
sam deploy --parameter-overrides TeamsWebhookUrl="https://your-webhook-url"
2. Multi-Environment Support
Our SAM template supports multiple environments:
Parameters:
Environment:
Type: String
Default: prod
AllowedValues: [dev, staging, prod]
Description: Environment name for resource naming
3. Proper Git Workflow
We used feature branches for development:
# Create feature branch
git checkout -b feature/account-names-and-last-month-context
# Make changes, test, commit
git add .
git commit -m "feat: Add account names and last month context"
# Push and create pull request
git push -u origin feature/account-names-and-last-month-context
4. Local Testing Strategy
Always test locally before deploying:
# test_local.py - Load environment from env.json
def load_env_from_json():
if os.path.exists('env.json'):
with open('env.json', 'r') as f:
env_config = json.load(f)
if 'BillingFunction' in env_config:
for key, value in env_config['BillingFunction'].items():
os.environ[key] = value
The Results
Our solution now delivers rich, actionable billing reports:
π° AWS Daily Billing Report
Report for January 7, 2026
π° Key Metrics
π΅ Yesterday's Total: $1,795.29
π Daily Change: π -$1,356.35 (-43.0%)
π Weekly Change: π -$1,121.95 (-38.5%)
π
Month-to-Date: $23,367.06
π― Projected Monthly: $103,482.70 (Day 7/31)
π Last Month: $145,230.75
π’ Top Contributing Accounts
Production Account: $1,234.56 (68.8%)
Development Account: $345.67 (19.2%)
Staging Account: $215.06 (12.0%)
π§ Top Services by Cost
Elastic Compute Cloud - Compute: $1,234.56
Simple Storage Service: $234.56
Relational Database Service: $156.78
Cost Analysis
Our solution is incredibly cost-effective:
| Service | Monthly Usage | Cost |
|---|---|---|
| Lambda | 30 executions, 256MB, 30s avg | ~$0.01 |
| EventBridge | 30 rule executions | ~$0.00 |
| CloudWatch Logs | 10MB logs, 30-day retention | ~$0.01 |
| Cost Explorer API | 120 API calls | Free |
| Organizations API | 150 API calls | Free |
| Data Transfer | Minimal outbound | ~$0.00 |
| S3 | SAM deployment artifacts | ~$0.01 |
| Total | ~$0.03/month |
Lessons Learned
1. Choose the Right Tool
SAM was significantly better than CDK for this serverless use case. Don't assume the "newer" tool is always better.
2. Test IAM Permissions Thoroughly
IAM conditions can be tricky. When in doubt, start with basic permissions and add conditions only when necessary.
3. Plan for Graceful Degradation
Our account name feature falls back to masked IDs if Organizations access fails. Always plan for failure scenarios.
4. Security First
Use parameter overrides for sensitive data. Never commit webhook URLs or API keys to version control.
5. Documentation Matters
Good documentation saved us hours during troubleshooting and made the solution maintainable.
Getting Started
Want to implement this solution? Here's a complete step-by-step guide:
1. Create the Project Structure
# Create a new SAM project
mkdir aws-billing-automation
cd aws-billing-automation
# Create the directory structure
mkdir src events
2. Create the SAM Template
Create template.yaml:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: AWS Billing Automation using SAM
Globals:
Function:
Timeout: 300
MemorySize: 256
Runtime: python3.9
Parameters:
TeamsWebhookUrl:
Type: String
Description: Microsoft Teams webhook URL for Power Automate
NoEcho: true
Environment:
Type: String
Default: prod
AllowedValues: [dev, staging, prod]
Description: Environment name for resource naming
Resources:
BillingFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub "${Environment}-aws-billing-automation"
CodeUri: src/
Handler: lambda_function.lambda_handler
Environment:
Variables:
TEAMS_WEBHOOK_URL: !Ref TeamsWebhookUrl
Policies:
- Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- ce:GetCostAndUsage
- ce:GetUsageReport
Resource: '*'
- Effect: Allow
Action:
- organizations:ListAccounts
- organizations:DescribeAccount
Resource: '*'
Events:
BillingSchedule:
Type: Schedule
Properties:
Schedule: "cron(0 21 * * ? *)" # 9 AM NZT daily
Name: !Sub "${Environment}-billing-schedule"
3. Create the Lambda Function
Create src/lambda_function.py with the core billing logic (see the implementation examples above).
Create src/requirements.txt:
requests==2.31.0
boto3>=1.26.0
4. Create Environment Configuration
Create env.json.example:
{
"BillingFunction": {
"TEAMS_WEBHOOK_URL": "https://your-power-automate-webhook-url-here",
"LOG_LEVEL": "INFO"
}
}
5. Set Up Your Environment
# Copy environment template
cp env.json.example env.json
# Edit env.json with your actual webhook URL
# Set AWS region to us-east-1 (required for Cost Explorer)
export AWS_DEFAULT_REGION=us-east-1
6. Create Local Test Script
Create test_local.py for local testing:
#!/usr/bin/env python3
import os
import sys
import json
# Load environment from env.json
def load_env_from_json():
if os.path.exists('env.json'):
with open('env.json', 'r') as f:
env_config = json.load(f)
if 'BillingFunction' in env_config:
for key, value in env_config['BillingFunction'].items():
os.environ[key] = value
load_env_from_json()
sys.path.insert(0, 'src')
from lambda_function import AWSBillingBot
def test_billing_bot():
webhook_url = os.environ.get('TEAMS_WEBHOOK_URL')
if not webhook_url or 'your-webhook-url-here' in webhook_url:
print("β Please set your webhook URL in env.json")
return False
bot = AWSBillingBot(webhook_url)
return bot.generate_report()
if __name__ == "__main__":
print("π§ͺ Testing AWS Billing Bot locally...")
success = test_billing_bot()
print("β
Test passed!" if success else "β Test failed!")
7. Test Locally
# Set your AWS profile
export AWS_PROFILE=your-profile-name
# Test the solution
python3 test_local.py
8. Deploy to AWS
# Build the application
sam build
# Deploy with guided setup (first time)
sam deploy --guided
# For subsequent deployments
sam deploy --parameter-overrides TeamsWebhookUrl="https://your-webhook-url"
9. Verify Deployment
# Test the deployed function
aws lambda invoke --function-name prod-aws-billing-automation response.json --region us-east-1
# Check the response
cat response.json
# View logs
sam logs -n BillingFunction --stack-name your-stack-name --tail
10. Set Up Microsoft Teams Integration
-
Create Power Automate Flow:
- Go to Power Automate (flow.microsoft.com)
- Create a new "Instant cloud flow"
- Choose "When an HTTP request is received" trigger
- Add "Post adaptive card in a chat or channel" action
- Connect to your Teams channel
- Save and copy the webhook URL
Update Your Deployment:
sam deploy --parameter-overrides TeamsWebhookUrl="your-power-automate-webhook-url"
11. Create Cleanup Script
Create cleanup.sh for easy resource removal:
#!/bin/bash
# AWS SAM Billing Automation - Complete Cleanup Script
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
STACK_NAME=${STACK_NAME:-"aws-billing-sam-prod"}
REGION=${AWS_DEFAULT_REGION:-"us-east-1"}
echo -e "${BLUE}ποΈ AWS SAM Billing Automation - Complete Cleanup${NC}"
echo "Stack: ${STACK_NAME}, Region: ${REGION}"
# Check AWS CLI
if ! aws sts get-caller-identity > /dev/null 2>&1; then
echo -e "${RED}β AWS CLI not configured${NC}"
exit 1
fi
# Check if stack exists
if aws cloudformation describe-stacks --stack-name $STACK_NAME --region $REGION > /dev/null 2>&1; then
echo -e "${YELLOW}π Resources to be deleted:${NC}"
aws cloudformation list-stack-resources --stack-name $STACK_NAME --region $REGION \
--query 'StackResourceSummaries[*].[ResourceType,LogicalResourceId]' --output table
echo -e "${RED}β οΈ WARNING: This will permanently delete all resources!${NC}"
read -p "Type 'DELETE' to confirm: " confirmation
if [ "$confirmation" != "DELETE" ]; then
echo "Cleanup cancelled"
exit 0
fi
# Delete stack
if command -v sam &> /dev/null; then
sam delete --stack-name $STACK_NAME --region $REGION --no-prompts
else
aws cloudformation delete-stack --stack-name $STACK_NAME --region $REGION
aws cloudformation wait stack-delete-complete --stack-name $STACK_NAME --region $REGION
fi
echo -e "${GREEN}β
Stack deleted successfully!${NC}"
else
echo -e "${YELLOW}Stack not found or already deleted${NC}"
fi
echo -e "${GREEN}π Cleanup completed!${NC}"
Make it executable:
chmod +x cleanup.sh
Complete File Structure
aws-billing-automation/
βββ src/
β βββ lambda_function.py # Main Lambda code
β βββ requirements.txt # Python dependencies
βββ events/
β βββ test-event.json # Test event for local testing
βββ template.yaml # SAM infrastructure template
βββ env.json.example # Environment variables template
βββ test_local.py # Local testing script
βββ cleanup.sh # Cleanup script
βββ README.md # Documentation
Advanced Features
Smart Alerts
The system automatically highlights significant cost changes:
if abs(analysis['daily_change_pct']) > 15:
alert_container = {
"type": "Container",
"style": "Attention",
"items": [{
"type": "TextBlock",
"text": "β οΈ Cost Alert",
"weight": "Bolder",
"color": "Attention"
}]
}
Monitoring Dashboard
Includes a CloudWatch dashboard for monitoring:
BillingDashboard:
Type: AWS::CloudWatch::Dashboard
Properties:
DashboardName: !Sub "${Environment}-aws-billing-automation"
DashboardBody: !Sub |
{
"widgets": [
{
"type": "metric",
"properties": {
"metrics": [
["AWS/Lambda", "Invocations", "FunctionName", "${BillingFunction}"],
[".", "Errors", ".", "."],
[".", "Duration", ".", "."]
]
}
}
]
}
Cleanup Script
Easy cleanup when needed:
# Remove all resources
./cleanup.sh
Future Enhancements
We're considering these improvements:
- Cost Anomaly Detection using AWS Cost Anomaly Detection service
- Budget Integration with AWS Budgets API
- Slack Integration for teams using Slack
- Custom Metrics for business-specific KPIs
- Multi-Region Support for global deployments
Conclusion
Building this AWS billing automation solution taught us valuable lessons about:
- Tool Selection: SAM vs CDK for different use cases
- IAM Best Practices: Avoiding overly restrictive conditions
- Integration Challenges: Working with evolving APIs like Teams webhooks
- Cost Optimization: Building solutions that cost pennies to operate
- Operational Excellence: Proper testing, documentation, and deployment practices
The solution now provides our team with daily visibility into AWS costs, has prevented several cost overruns through early alerts, and has become an essential part of our FinOps toolkit.
Total Development Time: ~8 hours
Monthly Operating Cost: ~$0.03
Value Delivered: Immeasurable cost visibility and control
Resources
- AWS SAM Documentation: docs.aws.amazon.com/serverless-application-model
- Cost Explorer API: docs.aws.amazon.com/aws-cost-management/latest/APIReference
- Microsoft Adaptive Cards: docs.microsoft.com/en-us/adaptive-cards
- AWS Organizations API: docs.aws.amazon.com/organizations/latest/APIReference
Have you built similar cost monitoring solutions? What challenges did you face? Share your experiences in the comments below!
Tags: #AWS #SAM #Serverless #FinOps #CostOptimization #MicrosoftTeams #Lambda #Python #DevOps #CloudCosts
Top comments (0)