DEV Community

Abhishek Vasisht
Abhishek Vasisht

Posted on

Building an Automated AWS Billing Report System with SAM and Microsoft Teams

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.

AWS SAMMicrosoft TeamsPython

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) β”‚
                       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
                }
            ]
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The fix was simple - remove the condition:

# This WORKS
- Effect: Allow
  Action:
    - organizations:DescribeAccount
  Resource: '*'
Enter fullscreen mode Exit fullscreen mode

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']
)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

4. Create Environment Configuration

Create env.json.example:

{
  "BillingFunction": {
    "TEAMS_WEBHOOK_URL": "https://your-power-automate-webhook-url-here",
    "LOG_LEVEL": "INFO"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!")
Enter fullscreen mode Exit fullscreen mode

7. Test Locally

# Set your AWS profile
export AWS_PROFILE=your-profile-name

# Test the solution
python3 test_local.py
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

10. Set Up Microsoft Teams Integration

  1. 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
  2. Update Your Deployment:

   sam deploy --parameter-overrides TeamsWebhookUrl="your-power-automate-webhook-url"
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

Make it executable:

chmod +x cleanup.sh
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
        }]
    }
Enter fullscreen mode Exit fullscreen mode

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", ".", "."]
              ]
            }
          }
        ]
      }
Enter fullscreen mode Exit fullscreen mode

Cleanup Script

Easy cleanup when needed:

# Remove all resources
./cleanup.sh
Enter fullscreen mode Exit fullscreen mode

Future Enhancements

We're considering these improvements:

  1. Cost Anomaly Detection using AWS Cost Anomaly Detection service
  2. Budget Integration with AWS Budgets API
  3. Slack Integration for teams using Slack
  4. Custom Metrics for business-specific KPIs
  5. 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


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)