FIFA World Cup Heat Risk Alert System
Build a serverless AWS pipeline that monitors heat risk for World Cup cities.
Introduction
30 Second Summary
The 2026 FIFA World Cup is happening right now across North America. With matches scheduled in cities like Houston, Dallas, and Mexico City during peak summer, extreme heat is a real safety concern for millions of fans.
In this project, you will build a serverless heat risk monitoring system that tracks weather conditions across all 16 host cities, calculates heat risk scores, and generates AI-powered safety briefings using AWS Lambda and Amazon Bedrock.
What You'll Build
A serverless pipeline that fetches real-time weather data from the Open-Meteo API, scores heat risk levels for each city, and produces AI-generated safety alerts accessible through a REST API.
By the end of this project, you'll have:
- A Python Lambda function that fetches live weather data for all 16 World Cup host cities and calculates heat risk scores based on apparent temperature, humidity, and UV index.
- An AI briefing generator powered by Amazon Bedrock Claude Haiku 4.5 that writes natural-language safety recommendations for fans and event organizers.
- A complete event-driven pipeline with EventBridge scheduling every 6 hours, JSON storage in S3, and a public endpoint via API Gateway for retrieving alerts.
- Secret Mission: A fan-facing HTML dashboard hosted on S3 that visualizes heat risk levels across all host cities with color-coded risk indicators.
Are there any prerequisites?
You need an active AWS account, the AWS CLI configured on your machine, Python 3 installed, and VS Code. If you do not have these yet, Step 1 will guide you through the setup.
Set Up AWS Resources and Permissions
Before writing any code, you need the cloud infrastructure in place. Your Lambda function will need permission to call Amazon Bedrock, store files in S3, and write logs to CloudWatch.
In this step, you will enable access to the AI model, create a storage bucket, and configure an IAM role that grants your function exactly the permissions it needs.
In this step, get ready to:
- Enable Anthropic Claude Haiku 4.5 access in Amazon Bedrock.
- Create an S3 bucket for storing heat risk alert data.
- Create an IAM execution role with least-privilege permissions for Lambda.
Enable Bedrock model access for Claude Haiku 4.5
Amazon Bedrock provides access to foundation models from multiple providers. Anthropic models are enabled by default, but you must submit a one-time usage form before your first API call.
- Sign in to the AWS Management Console.
- Check that your region is set to US East (N. Virginia) (us-east-1) in the top-right corner of the console.
- Search for Bedrock in the top search bar and open the Amazon Bedrock service.
- In the left navigation pane, under Test, choose Playground.
- Select an Anthropic Claude model (any variant) from the model selector.
- If this is your first time using an Anthropic model, Bedrock will prompt you to complete a one-time usage form. Fill out the form describing your use case and submit it.
- Wait for the form to be processed. This typically takes a few seconds to a few minutes.
Why the usage form?
Amazon Bedrock automatically enables access to serverless foundation models. However, Anthropic requires a one-time usage form submission per AWS account before you can invoke their models. Once approved, you can call any Anthropic model (including Claude Haiku 4.5) without further setup.
- To verify access is working, try sending a simple prompt like "Hello" in the Playground. If you get a response, your Anthropic access is active.
Not seeing a response in the Playground?
Make sure your region is set to us-east-1. Bedrock model access is region-specific.
If the usage form is still processing, wait a few minutes and refresh the page. Approval is typically fast but can occasionally take longer.
Help me troubleshoot Bedrock model access for Anthropic Claude
Create an S3 bucket for alert data
Your Lambda function will store heat risk assessments as JSON files in Amazon S3. Each time the function runs, it writes a timestamped alert file plus a latest.json file that always contains the most recent data.
- Search for S3 in the console search bar and open the Amazon S3 service.
- Click Create bucket.
- For Bucket name, enter worldcup-heat-alerts- followed by your AWS account ID (for example, worldcup-heat-alerts-123456789012). This ensures a globally unique name.
- Leave AWS Region set to US East (N. Virginia) us-east-1.
- Keep all other settings at their defaults (Block all public access enabled, versioning disabled).
- Scroll down and click Create bucket.
Where do I find my 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 and use it in your bucket name.
You should see your new bucket listed on the S3 Buckets page with a green success banner confirming creation.
Seeing a "Bucket name already exists" error?
S3 bucket names must be globally unique across all AWS accounts. Double-check that you included your full 12-digit account ID in the bucket name.
Help me fix my S3 bucket name
Create an IAM execution role for Lambda
A Lambda execution role defines what AWS services your function can access. You will create a role with least-privilege permissions covering only what the heat monitor needs: S3 access, Bedrock model invocation, and CloudWatch logging.
- Search for IAM in the console search bar and open the IAM service.
- In the left navigation pane, choose Roles.
- Click Create role.
- For Trusted entity type, choose AWS service.
- For Service or use case, choose Lambda.
- Click Next.
- On the Add permissions page, do not select any policies. Click Next to skip this step (you will add a custom inline policy after creation).
- For Role name, enter worldcup-heat-lambda-role.
- Click Create role.
You should see a success banner confirming the role was created. Now you need to add an inline policy with the specific permissions.
- On the Roles page, search for worldcup-heat-lambda-role and click on it to open the role details.
- Select the Permissions tab.
- Click Add permissions, then choose Create inline policy.
- Select the JSON tab in the policy editor.
- Delete any existing content in the editor, then paste the following policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "S3Access",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject"
],
"Resource": "arn:aws:s3:::worldcup-heat-alerts-*/alerts/*"
},
{
"Sid": "BedrockAccess",
"Effect": "Allow",
"Action": "bedrock:InvokeModel",
"Resource": "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0"
},
{
"Sid": "CloudWatchLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*"
}
]
}
What does this policy do?
- S3Access grants s3:PutObject and s3:GetObject on the alerts folder within your bucket. The wildcard in the bucket name lets it match regardless of your specific account ID suffix.
- BedrockAccess grants bedrock:InvokeModel scoped to the exact Claude Haiku 4.5 model ID (anthropic.claude-haiku-4-5-20251001-v1:0). The function cannot call any other model.
- CloudWatchLogs allows the function to create log groups and write log events. Lambda requires these permissions to output any runtime logs.
What is least-privilege?
Least-privilege means granting only the minimum permissions a resource needs to do its job. Instead of giving the role full S3 access or full Bedrock access, you scope each permission to the specific resource (your bucket, your model). This limits the blast radius if something goes wrong.
- Click Next.
- For Policy name, enter worldcup-heat-monitor-policy.
- Click Create policy.
You should now see the inline policy listed under the role's Permissions tab.
Seeing a policy validation error?
Make sure you copied the entire JSON including the opening and closing curly braces. Check for missing commas between array items or extra trailing commas after the last item.
If the editor shows a warning about the S3 resource wildcard, this is expected. The wildcard in the bucket name portion is intentional to match your specific bucket name.
Help me fix my IAM inline policy JSON
Your cloud foundation is set. Next up, you will build the Lambda function that fetches live weather data and calculates heat risk scores for all 16 World Cup host cities.
Build the Weather Data and Heat Risk Lambda
You have your AWS resources and permissions ready. Now it's time to build the core of your system.
In this step, you'll create a Lambda function that reaches out to a free weather API, pulls real-time conditions for all 16 World Cup host cities, and scores each one for heat risk. This is the engine that powers everything else in the project.
In this step, get ready to:
- Create the Lambda function with the correct runtime, memory, and timeout settings.
- Deploy the Python code that fetches weather data and calculates heat risk scores.
- Test the function and verify it returns heat risk assessments for all 16 cities.
Create the Lambda function
Your Lambda function needs enough time and memory to call an external weather API and process data for 16 cities. You'll configure it with a 30-second timeout and 256 MB of memory.
- In the AWS Console, search for Lambda in the top search bar and select it.
- Click Create function.
- Select Author from scratch.
- In the Function name field, enter worldcup-heat-monitor.
- For Runtime, select Python 3.13 from the dropdown.
- Expand Change default execution role.
- Select Use an existing role.
- In the Existing role dropdown, select worldcup-heat-lambda-role (the role you created in Step 1).
- Click Create function.
You should see a success banner confirming your function was created. Now configure the timeout and memory.
- Click the Configuration tab.
- Select General configuration from the left menu, then click Edit.
- Set Memory to 256 MB.
- Set Timeout to 0 min 30 sec.
- Click Save.
Next, set the environment variable so your code knows which S3 bucket to write to.
- Still on the Configuration tab, select Environment variables from the left menu.
- Click Edit, then Add environment variable.
- For Key, enter S3_BUCKET.
- For Value, enter your bucket name (worldcup-heat-alerts- followed by your account ID).
- Click Save.
Why set the bucket name as an environment variable?
Hardcoding resource names in your code makes it brittle. If you ever rename the bucket or deploy to a different account, you'd have to edit the code. Environment variables let you change configuration without touching source code.
Don't see your role in the dropdown?
Make sure you created the role with a trust policy that allows lambda.amazonaws.com as a trusted entity. If the trust policy is missing, the role won't appear in the Lambda dropdown.
Also confirm you're working in the same AWS region (us-east-1) where you created the role.
Help me troubleshoot my missing IAM role in the Lambda creation form.
Write the Lambda code for weather data and heat risk scoring
Your function uses the Open-Meteo API to fetch real-time weather conditions for all 16 host cities. It then scores each city's heat risk based on apparent temperature, UV index, and humidity thresholds.
Why Open-Meteo instead of OpenWeatherMap?
Open-Meteo requires no API key and no signup. It supports batch coordinate queries (all 16 cities in a single request), provides apparent temperature natively, and allows 10,000 daily API calls free for non-commercial use.
This eliminates the complexity of managing API key secrets in your Lambda environment.
- Switch to the Code tab in your Lambda function.
- In the code editor, select all the existing code in lambda_function.py and delete it.
- Paste the following code into the editor, starting with the imports and city configuration:
import json
import os
import boto3
from urllib.request import urlopen
from urllib.error import URLError
from datetime import datetime, timezone
# Read the S3 bucket name from the environment variable
S3_BUCKET = os.environ["S3_BUCKET"]
# Bedrock model ID for AI briefing generation
BEDROCK_MODEL_ID = "anthropic.claude-haiku-4-5-20251001-v1:0"
# All 16 FIFA World Cup 2026 host cities with coordinates
HOST_CITIES = [
{"city": "Mexico City", "country": "Mexico", "lat": 19.4326, "lon": -99.1332},
{"city": "Guadalajara", "country": "Mexico", "lat": 20.6597, "lon": -103.3496},
{"city": "Monterrey", "country": "Mexico", "lat": 25.6866, "lon": -100.3161},
{"city": "Atlanta", "country": "USA", "lat": 33.7490, "lon": -84.3880},
{"city": "Boston", "country": "USA", "lat": 42.3601, "lon": -71.0589},
{"city": "Dallas", "country": "USA", "lat": 32.7767, "lon": -96.7970},
{"city": "Houston", "country": "USA", "lat": 29.7604, "lon": -95.3698},
{"city": "Kansas City", "country": "USA", "lat": 39.0997, "lon": -94.5786},
{"city": "Los Angeles", "country": "USA", "lat": 34.0522, "lon": -118.2437},
{"city": "Miami", "country": "USA", "lat": 25.7617, "lon": -80.1918},
{"city": "New York/New Jersey", "country": "USA", "lat": 40.8128, "lon": -74.0742},
{"city": "Philadelphia", "country": "USA", "lat": 39.9526, "lon": -75.1652},
{"city": "San Francisco", "country": "USA", "lat": 37.5485, "lon": -122.0628},
{"city": "Seattle", "country": "USA", "lat": 47.6062, "lon": -122.3321},
{"city": "Toronto", "country": "Canada", "lat": 43.6532, "lon": -79.3832},
{"city": "Vancouver", "country": "Canada", "lat": 49.2827, "lon": -123.1207},
]
What does this code do?
This section sets up the foundation. S3_BUCKET reads from the environment variable you just configured. BEDROCK_MODEL_ID stores the model ID for AI briefing generation (used later in Step 3).
The HOST_CITIES list contains all 16 World Cup host cities with their GPS coordinates. Open-Meteo uses these latitude/longitude pairs to return weather data for each location.
- Below the HOST_CITIES list, paste the weather data fetching function:
def get_weather_data(cities):
# Build comma-separated coordinate strings for batch API request
latitudes = ",".join(str(c["lat"]) for c in cities)
longitudes = ",".join(str(c["lon"]) for c in cities)
# Construct the Open-Meteo API URL with all parameters
url = (
f"https://api.open-meteo.com/v1/forecast?"
f"latitude={latitudes}&longitude={longitudes}"
f"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,"
f"wind_speed_10m,weather_code"
f"&daily=apparent_temperature_max,uv_index_max"
f"&timezone=auto&forecast_days=1"
)
try:
with urlopen(url, timeout=10) as response:
data = json.loads(response.read().decode())
except URLError as e:
raise RuntimeError(f"Failed to fetch weather data: {e}")
return data
What does this function do?
The get_weather_data() function sends a single HTTP request to the Open-Meteo API with all 16 cities' coordinates as comma-separated values. This batch approach is much more efficient than making 16 individual requests.
It requests current conditions (temperature_2m, apparent_temperature, relative_humidity_2m, wind_speed_10m, weather_code) and daily forecasts (apparent_temperature_max, uv_index_max). The timezone=auto parameter resolves each city to its local time zone automatically.
- Below get_weather_data(), paste the heat risk scoring function:
def calculate_heat_risk(apparent_temp_max, uv_index_max, humidity):
# Start with a score of 0 and add points for each risk factor
score = 0
# Temperature scoring: higher apparent temp = more points
if apparent_temp_max >= 45:
score += 4
elif apparent_temp_max >= 40:
score += 3
elif apparent_temp_max >= 35:
score += 2
elif apparent_temp_max >= 30:
score += 1
# UV index scoring: higher UV = more points
if uv_index_max >= 11:
score += 3
elif uv_index_max >= 8:
score += 2
elif uv_index_max >= 6:
score += 1
# Humidity scoring: high humidity traps heat
if humidity >= 80:
score += 2
elif humidity >= 60:
score += 1
# Convert total score to risk level
if score >= 7:
return "Extreme"
elif score >= 5:
return "High"
elif score >= 3:
return "Moderate"
else:
return "Low"
How does the scoring algorithm work?
The function assigns points across three dimensions: apparent temperature (0-4 points), UV index (0-3 points), and humidity (0-2 points). The maximum possible score is 9.
Apparent temperature is weighted most heavily because it represents "feels like" temperature, which accounts for wind chill and solar radiation. A combined score of 7+ means Extreme risk, 5-6 means High, 3-4 means Moderate, and below 3 means Low.
- Below calculate_heat_risk(), paste the AI briefing generator function:
def generate_ai_briefing(bedrock_client, city_risks):
# Identify cities at High or Extreme risk for the briefing
high_risk_cities = [
c for c in city_risks if c["risk_level"] in ("High", "Extreme")
]
if not high_risk_cities:
summary = "All cities are currently at Low or Moderate heat risk."
cities_context = json.dumps(city_risks[:5], indent=2)
else:
summary = f"{len(high_risk_cities)} cities at High or Extreme heat risk."
cities_context = json.dumps(high_risk_cities, indent=2)
# Construct the prompt for Claude Haiku 4.5
prompt = f"""You are a public safety advisor for the 2026 FIFA World Cup. Generate a concise heat risk safety briefing based on the following weather data.
Current Status: {summary}
City Data:
{cities_context}
Generate a briefing with:
1. A one-sentence overall status summary
2. For each high-risk city: specific fan safety recommendations (hydration, shade, timing of outdoor activities)
3. Organizer actions: what event staff should prepare (cooling stations, medical readiness, schedule adjustments)
4. General tips for all attendees
Keep the briefing under 300 words. Be direct and actionable."""
# Call Bedrock with the Messages API format
body = json.dumps({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 1024,
"messages": [{"role": "user", "content": prompt}],
})
response = bedrock_client.invoke_model(
modelId=BEDROCK_MODEL_ID,
body=body,
)
response_body = json.loads(response["body"].read())
return response_body["content"][0]["text"]
What does generate_ai_briefing do?
This function calls Amazon Bedrock with Claude Haiku 4.5 to generate a natural-language safety briefing. It passes the heat risk data as context and asks the model for actionable recommendations.
You'll explore how this works in detail in Step 3. For now, it's included so the function is complete and ready to test end-to-end.
- Finally, paste the main handler function that orchestrates everything:
def lambda_handler(event, context):
# Initialize AWS service clients
s3_client = boto3.client("s3")
bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1")
# Fetch weather data for all host cities
weather_data = get_weather_data(HOST_CITIES)
# Process each city's weather into risk assessments
city_risks = []
for i, city_info in enumerate(HOST_CITIES):
if isinstance(weather_data, list):
city_weather = weather_data[i]
else:
city_weather = weather_data
current = city_weather.get("current", {})
daily = city_weather.get("daily", {})
# Extract key metrics with fallbacks
apparent_temp_max = (
daily.get("apparent_temperature_max", [0])[0]
if daily.get("apparent_temperature_max")
else current.get("apparent_temperature", 0)
)
uv_index_max = (
daily.get("uv_index_max", [0])[0]
if daily.get("uv_index_max")
else 0
)
humidity = current.get("relative_humidity_2m", 0)
# Calculate the risk level for this city
risk_level = calculate_heat_risk(apparent_temp_max, uv_index_max, humidity)
city_risks.append({
"city": city_info["city"],
"country": city_info["country"],
"apparent_temperature_max_c": apparent_temp_max,
"current_temperature_c": current.get("temperature_2m", 0),
"current_apparent_temperature_c": current.get("apparent_temperature", 0),
"humidity_percent": humidity,
"uv_index_max": uv_index_max,
"wind_speed_kmh": current.get("wind_speed_10m", 0),
"weather_code": current.get("weather_code", 0),
"risk_level": risk_level,
})
# Generate AI safety briefing from the risk data
ai_briefing = generate_ai_briefing(bedrock_client, city_risks)
# Build the complete alert payload
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
alert_payload = {
"generated_at": timestamp,
"event": "FIFA World Cup 2026",
"total_cities": len(city_risks),
"high_risk_count": len(
[c for c in city_risks if c["risk_level"] in ("High", "Extreme")]
),
"city_risks": city_risks,
"ai_briefing": ai_briefing,
}
# Save timestamped alert to S3
s3_key = f"alerts/{timestamp.replace(':', '-')}.json"
s3_client.put_object(
Bucket=S3_BUCKET,
Key=s3_key,
Body=json.dumps(alert_payload, indent=2),
ContentType="application/json",
)
# Also save as latest.json for easy retrieval
s3_client.put_object(
Bucket=S3_BUCKET,
Key="alerts/latest.json",
Body=json.dumps(alert_payload, indent=2),
ContentType="application/json",
)
return {
"statusCode": 200,
"body": json.dumps({
"message": "Heat risk assessment complete",
"timestamp": timestamp,
"high_risk_count": alert_payload["high_risk_count"],
"s3_key": s3_key,
}),
}
What does lambda_handler do?
This is the entry point that AWS Lambda calls. It orchestrates the full pipeline: fetch weather data, score each city, generate an AI briefing, and store the results.
The handler writes two copies of the alert to S3. A timestamped version (alerts/2026-07-06T12-00-00Z.json) creates a historical record. A alerts/latest.json copy makes it simple for the API to always return the most recent data.
- Click the Deploy button above the code editor to save your function.
You should see a green banner confirming the function was updated successfully.
✔️ Awesome, I've got everything!
Great. Make sure you clicked Deploy and saw the green success banner before moving on.
ⓧ I'd like to double check the full code
Here is the complete lambda_function.py file. Make sure your code matches this exactly:
import json
import os
import boto3
from urllib.request import urlopen
from urllib.error import URLError
from datetime import datetime, timezone
S3_BUCKET = os.environ["S3_BUCKET"]
BEDROCK_MODEL_ID = "anthropic.claude-haiku-4-5-20251001-v1:0"
HOST_CITIES = [
{"city": "Mexico City", "country": "Mexico", "lat": 19.4326, "lon": -99.1332},
{"city": "Guadalajara", "country": "Mexico", "lat": 20.6597, "lon": -103.3496},
{"city": "Monterrey", "country": "Mexico", "lat": 25.6866, "lon": -100.3161},
{"city": "Atlanta", "country": "USA", "lat": 33.7490, "lon": -84.3880},
{"city": "Boston", "country": "USA", "lat": 42.3601, "lon": -71.0589},
{"city": "Dallas", "country": "USA", "lat": 32.7767, "lon": -96.7970},
{"city": "Houston", "country": "USA", "lat": 29.7604, "lon": -95.3698},
{"city": "Kansas City", "country": "USA", "lat": 39.0997, "lon": -94.5786},
{"city": "Los Angeles", "country": "USA", "lat": 34.0522, "lon": -118.2437},
{"city": "Miami", "country": "USA", "lat": 25.7617, "lon": -80.1918},
{"city": "New York/New Jersey", "country": "USA", "lat": 40.8128, "lon": -74.0742},
{"city": "Philadelphia", "country": "USA", "lat": 39.9526, "lon": -75.1652},
{"city": "San Francisco", "country": "USA", "lat": 37.5485, "lon": -122.0628},
{"city": "Seattle", "country": "USA", "lat": 47.6062, "lon": -122.3321},
{"city": "Toronto", "country": "Canada", "lat": 43.6532, "lon": -79.3832},
{"city": "Vancouver", "country": "Canada", "lat": 49.2827, "lon": -123.1207},
]
def get_weather_data(cities):
latitudes = ",".join(str(c["lat"]) for c in cities)
longitudes = ",".join(str(c["lon"]) for c in cities)
url = (
f"https://api.open-meteo.com/v1/forecast?"
f"latitude={latitudes}&longitude={longitudes}"
f"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,"
f"wind_speed_10m,weather_code"
f"&daily=apparent_temperature_max,uv_index_max"
f"&timezone=auto&forecast_days=1"
)
try:
with urlopen(url, timeout=10) as response:
data = json.loads(response.read().decode())
except URLError as e:
raise RuntimeError(f"Failed to fetch weather data: {e}")
return data
def calculate_heat_risk(apparent_temp_max, uv_index_max, humidity):
score = 0
if apparent_temp_max >= 45:
score += 4
elif apparent_temp_max >= 40:
score += 3
elif apparent_temp_max >= 35:
score += 2
elif apparent_temp_max >= 30:
score += 1
if uv_index_max >= 11:
score += 3
elif uv_index_max >= 8:
score += 2
elif uv_index_max >= 6:
score += 1
if humidity >= 80:
score += 2
elif humidity >= 60:
score += 1
if score >= 7:
return "Extreme"
elif score >= 5:
return "High"
elif score >= 3:
return "Moderate"
else:
return "Low"
def generate_ai_briefing(bedrock_client, city_risks):
high_risk_cities = [
c for c in city_risks if c["risk_level"] in ("High", "Extreme")
]
if not high_risk_cities:
summary = "All cities are currently at Low or Moderate heat risk."
cities_context = json.dumps(city_risks[:5], indent=2)
else:
summary = f"{len(high_risk_cities)} cities at High or Extreme heat risk."
cities_context = json.dumps(high_risk_cities, indent=2)
prompt = f"""You are a public safety advisor for the 2026 FIFA World Cup. Generate a concise heat risk safety briefing based on the following weather data.
Current Status: {summary}
City Data:
{cities_context}
Generate a briefing with:
1. A one-sentence overall status summary
2. For each high-risk city: specific fan safety recommendations (hydration, shade, timing of outdoor activities)
3. Organizer actions: what event staff should prepare (cooling stations, medical readiness, schedule adjustments)
4. General tips for all attendees
Keep the briefing under 300 words. Be direct and actionable."""
body = json.dumps({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 1024,
"messages": [{"role": "user", "content": prompt}],
})
response = bedrock_client.invoke_model(
modelId=BEDROCK_MODEL_ID,
body=body,
)
response_body = json.loads(response["body"].read())
return response_body["content"][0]["text"]
def lambda_handler(event, context):
s3_client = boto3.client("s3")
bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1")
weather_data = get_weather_data(HOST_CITIES)
city_risks = []
for i, city_info in enumerate(HOST_CITIES):
if isinstance(weather_data, list):
city_weather = weather_data[i]
else:
city_weather = weather_data
current = city_weather.get("current", {})
daily = city_weather.get("daily", {})
apparent_temp_max = (
daily.get("apparent_temperature_max", [0])[0]
if daily.get("apparent_temperature_max")
else current.get("apparent_temperature", 0)
)
uv_index_max = (
daily.get("uv_index_max", [0])[0]
if daily.get("uv_index_max")
else 0
)
humidity = current.get("relative_humidity_2m", 0)
risk_level = calculate_heat_risk(apparent_temp_max, uv_index_max, humidity)
city_risks.append({
"city": city_info["city"],
"country": city_info["country"],
"apparent_temperature_max_c": apparent_temp_max,
"current_temperature_c": current.get("temperature_2m", 0),
"current_apparent_temperature_c": current.get("apparent_temperature", 0),
"humidity_percent": humidity,
"uv_index_max": uv_index_max,
"wind_speed_kmh": current.get("wind_speed_10m", 0),
"weather_code": current.get("weather_code", 0),
"risk_level": risk_level,
})
ai_briefing = generate_ai_briefing(bedrock_client, city_risks)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
alert_payload = {
"generated_at": timestamp,
"event": "FIFA World Cup 2026",
"total_cities": len(city_risks),
"high_risk_count": len(
[c for c in city_risks if c["risk_level"] in ("High", "Extreme")]
),
"city_risks": city_risks,
"ai_briefing": ai_briefing,
}
s3_key = f"alerts/{timestamp.replace(':', '-')}.json"
s3_client.put_object(
Bucket=S3_BUCKET,
Key=s3_key,
Body=json.dumps(alert_payload, indent=2),
ContentType="application/json",
)
s3_client.put_object(
Bucket=S3_BUCKET,
Key="alerts/latest.json",
Body=json.dumps(alert_payload, indent=2),
ContentType="application/json",
)
return {
"statusCode": 200,
"body": json.dumps({
"message": "Heat risk assessment complete",
"timestamp": timestamp,
"high_risk_count": alert_payload["high_risk_count"],
"s3_key": s3_key,
}),
}
Test the function and verify results
Time to see your function in action. You'll create a test event and invoke the function to confirm it fetches live weather data and produces heat risk scores for all 16 cities.
- Click the Test tab in the code editor area.
- For Event name, enter test-heat-monitor.
- Leave the event JSON as the default ({}). This function doesn't read anything from the event payload.
- Click Test.
How long will the test take?
The function calls two external services (Open-Meteo and Amazon Bedrock), so it typically takes 5-15 seconds to complete. If you see a spinner, that's normal.
After the test completes, you should see a green "Execution result: succeeded" banner with a JSON response containing "message": "Heat risk assessment complete" and a high_risk_count value.
- To verify the full execution, click the Monitor tab, then click View CloudWatch logs.
- Click on the most recent log stream.
- Look for log entries confirming the function ran without errors. You should see the START, END, and REPORT lines showing the execution duration.
Seeing a timeout error?
If the function times out, make sure you set the timeout to 30 seconds (not the default 3 seconds). Go to Configuration > General configuration > Edit and verify the timeout value.
If you see an access denied error for Bedrock, confirm that your IAM role's inline policy includes bedrock:InvokeModel permission and that you enabled model access for Claude Haiku 4.5 in the Bedrock console.
Help me debug my Lambda function execution error.
Your Lambda function is live and producing real-time heat risk data. Next up, you'll take a closer look at the AI briefing generator and fine-tune how it produces safety recommendations.
Add the AI Briefing Generator
Your Lambda function can already fetch weather data and calculate heat risk scores for all 16 host cities. The next layer is intelligence.
In this step, you will connect your function to Amazon Bedrock so it can generate natural-language safety briefings from the raw weather data. Instead of a table of numbers, fans and organizers will receive actionable recommendations written by Claude Haiku 4.5.
In this step, get ready to:
- Add an AI briefing generator function that calls Amazon Bedrock.
- Store the complete alert payload (weather data + AI briefing) in S3.
- Test the full pipeline end-to-end and confirm costs are minimal.
Write the generate_ai_briefing function
Your function needs a way to take the structured heat risk data and ask an AI model to produce a human-readable briefing. You will add a new function called generate_ai_briefing that calls Bedrock's invoke_model API with the city risk data as context.
- Return to the worldcup-heat-monitor function in the Lambda console and select the Code tab.
- In lambda_function.py, find the end of the calculate_heat_risk function (after the last return "Low" line).
- Add the following generate_ai_briefing function directly below it:
def generate_ai_briefing(bedrock_client, city_risks):
high_risk_cities = [
c for c in city_risks if c["risk_level"] in ("High", "Extreme")
]
if not high_risk_cities:
summary = "All cities are currently at Low or Moderate heat risk."
cities_context = json.dumps(city_risks[:5], indent=2)
else:
summary = f"{len(high_risk_cities)} cities at High or Extreme heat risk."
cities_context = json.dumps(high_risk_cities, indent=2)
prompt = f"""You are a public safety advisor for the 2026 FIFA World Cup. Generate a concise heat risk safety briefing based on the following weather data.
Current Status: {summary}
City Data:
{cities_context}
Generate a briefing with:
1. A one-sentence overall status summary
2. For each high-risk city: specific fan safety recommendations (hydration, shade, timing of outdoor activities)
3. Organizer actions: what event staff should prepare (cooling stations, medical readiness, schedule adjustments)
4. General tips for all attendees
Keep the briefing under 300 words. Be direct and actionable."""
body = json.dumps({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 1024,
"messages": [{"role": "user", "content": prompt}],
})
response = bedrock_client.invoke_model(
modelId=BEDROCK_MODEL_ID,
body=body,
)
response_body = json.loads(response["body"].read())
return response_body["content"][0]["text"]
What does this code do?
This function filters the city risk data to find any cities at High or Extreme risk, then builds a detailed prompt asking the model for a safety briefing.
The anthropic_version field is required by Bedrock when calling Anthropic models. The max_tokens value of 1024 gives Claude enough room for a 300-word briefing without overspending on output tokens.
The response comes back as a JSON body with a content array. The function extracts the text from the first content block and returns it as a plain string.
Why Claude Haiku 4.5 for this task?
Claude Haiku 4.5 is the cheapest active model on Bedrock at $0.80 per million input tokens and $4.00 per million output tokens. For short safety briefings (around 500 output tokens each), this keeps costs under $0.01 per invocation.
The model is fast and accurate enough for generating concise, actionable text from structured data. Larger models would cost more without meaningful quality improvement for this use case.
Update the Lambda handler to call Bedrock and store results
Now you need to wire the new function into your handler so the Lambda creates a Bedrock client, generates the briefing, and includes it in the alert payload written to S3.
- In lambda_function.py, find the lambda_handler function.
- Find the line that says:
s3_client = boto3.client("s3")
- Add this line directly below it to create a Bedrock runtime client:
bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1")
- Next, find the end of the for i, city_info in enumerate(HOST_CITIES): loop (after the last city_risks.append(...) call).
- Add this line below the loop to generate the AI briefing:
ai_briefing = generate_ai_briefing(bedrock_client, city_risks)
- Now find the alert_payload dictionary. Replace it with the full version that includes the ai_briefing field:
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
alert_payload = {
"generated_at": timestamp,
"event": "FIFA World Cup 2026",
"total_cities": len(city_risks),
"high_risk_count": len(
[c for c in city_risks if c["risk_level"] in ("High", "Extreme")]
),
"city_risks": city_risks,
"ai_briefing": ai_briefing,
}
- Ensure the S3 writes below the payload use the same alert_payload variable and write to both a timestamped key and alerts/latest.json:
s3_key = f"alerts/{timestamp.replace(':', '-')}.json"
s3_client.put_object(
Bucket=S3_BUCKET,
Key=s3_key,
Body=json.dumps(alert_payload, indent=2),
ContentType="application/json",
)
s3_client.put_object(
Bucket=S3_BUCKET,
Key="alerts/latest.json",
Body=json.dumps(alert_payload, indent=2),
ContentType="application/json",
)
return {
"statusCode": 200,
"body": json.dumps({
"message": "Heat risk assessment complete",
"timestamp": timestamp,
"high_risk_count": alert_payload["high_risk_count"],
"s3_key": s3_key,
}),
}
What does the updated handler do?
The handler now creates two AWS clients: one for S3 and one for Bedrock Runtime. After building the city_risks list from weather data, it passes the list to generate_ai_briefing which returns a plain-text safety briefing.
The alert payload bundles everything together: metadata, per-city risk data, and the AI briefing. Two copies are written to S3. The timestamped file (alerts/{timestamp}.json) creates a historical record. The alerts/latest.json file always holds the most recent alert so consumers can fetch it without listing the bucket.
- Click Deploy in the code editor to save and deploy the updated function.
Deploy button greyed out?
Make sure you have clicked inside the code editor after pasting. The Deploy button only activates when Lambda detects unsaved changes.
help me deploy my updated Lambda function code
✔️ Awesome, I've got everything!
Great. Double check you see the green "Successfully updated" banner after clicking Deploy.
ⓧ I'd like to double check the full code
Here is the complete lambda_function.py at this point:
import json
import os
import boto3
from urllib.request import urlopen
from urllib.error import URLError
from datetime import datetime, timezone
S3_BUCKET = os.environ["S3_BUCKET"]
BEDROCK_MODEL_ID = "anthropic.claude-haiku-4-5-20251001-v1:0"
HOST_CITIES = [
{"city": "Mexico City", "country": "Mexico", "lat": 19.4326, "lon": -99.1332},
{"city": "Guadalajara", "country": "Mexico", "lat": 20.6597, "lon": -103.3496},
{"city": "Monterrey", "country": "Mexico", "lat": 25.6866, "lon": -100.3161},
{"city": "Atlanta", "country": "USA", "lat": 33.7490, "lon": -84.3880},
{"city": "Boston", "country": "USA", "lat": 42.3601, "lon": -71.0589},
{"city": "Dallas", "country": "USA", "lat": 32.7767, "lon": -96.7970},
{"city": "Houston", "country": "USA", "lat": 29.7604, "lon": -95.3698},
{"city": "Kansas City", "country": "USA", "lat": 39.0997, "lon": -94.5786},
{"city": "Los Angeles", "country": "USA", "lat": 34.0522, "lon": -118.2437},
{"city": "Miami", "country": "USA", "lat": 25.7617, "lon": -80.1918},
{"city": "New York/New Jersey", "country": "USA", "lat": 40.8128, "lon": -74.0742},
{"city": "Philadelphia", "country": "USA", "lat": 39.9526, "lon": -75.1652},
{"city": "San Francisco", "country": "USA", "lat": 37.5485, "lon": -122.0628},
{"city": "Seattle", "country": "USA", "lat": 47.6062, "lon": -122.3321},
{"city": "Toronto", "country": "Canada", "lat": 43.6532, "lon": -79.3832},
{"city": "Vancouver", "country": "Canada", "lat": 49.2827, "lon": -123.1207},
]
def get_weather_data(cities):
latitudes = ",".join(str(c["lat"]) for c in cities)
longitudes = ",".join(str(c["lon"]) for c in cities)
url = (
f"https://api.open-meteo.com/v1/forecast?"
f"latitude={latitudes}&longitude={longitudes}"
f"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,"
f"wind_speed_10m,weather_code"
f"&daily=apparent_temperature_max,uv_index_max"
f"&timezone=auto&forecast_days=1"
)
try:
with urlopen(url, timeout=10) as response:
data = json.loads(response.read().decode())
except URLError as e:
raise RuntimeError(f"Failed to fetch weather data: {e}")
return data
def calculate_heat_risk(apparent_temp_max, uv_index_max, humidity):
score = 0
if apparent_temp_max >= 45:
score += 4
elif apparent_temp_max >= 40:
score += 3
elif apparent_temp_max >= 35:
score += 2
elif apparent_temp_max >= 30:
score += 1
if uv_index_max >= 11:
score += 3
elif uv_index_max >= 8:
score += 2
elif uv_index_max >= 6:
score += 1
if humidity >= 80:
score += 2
elif humidity >= 60:
score += 1
if score >= 7:
return "Extreme"
elif score >= 5:
return "High"
elif score >= 3:
return "Moderate"
else:
return "Low"
def generate_ai_briefing(bedrock_client, city_risks):
high_risk_cities = [
c for c in city_risks if c["risk_level"] in ("High", "Extreme")
]
if not high_risk_cities:
summary = "All cities are currently at Low or Moderate heat risk."
cities_context = json.dumps(city_risks[:5], indent=2)
else:
summary = f"{len(high_risk_cities)} cities at High or Extreme heat risk."
cities_context = json.dumps(high_risk_cities, indent=2)
prompt = f"""You are a public safety advisor for the 2026 FIFA World Cup. Generate a concise heat risk safety briefing based on the following weather data.
Current Status: {summary}
City Data:
{cities_context}
Generate a briefing with:
1. A one-sentence overall status summary
2. For each high-risk city: specific fan safety recommendations (hydration, shade, timing of outdoor activities)
3. Organizer actions: what event staff should prepare (cooling stations, medical readiness, schedule adjustments)
4. General tips for all attendees
Keep the briefing under 300 words. Be direct and actionable."""
body = json.dumps({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 1024,
"messages": [{"role": "user", "content": prompt}],
})
response = bedrock_client.invoke_model(
modelId=BEDROCK_MODEL_ID,
body=body,
)
response_body = json.loads(response["body"].read())
return response_body["content"][0]["text"]
def lambda_handler(event, context):
s3_client = boto3.client("s3")
bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1")
weather_data = get_weather_data(HOST_CITIES)
city_risks = []
for i, city_info in enumerate(HOST_CITIES):
if isinstance(weather_data, list):
city_weather = weather_data[i]
else:
city_weather = weather_data
current = city_weather.get("current", {})
daily = city_weather.get("daily", {})
apparent_temp_max = (
daily.get("apparent_temperature_max", [0])[0]
if daily.get("apparent_temperature_max")
else current.get("apparent_temperature", 0)
)
uv_index_max = (
daily.get("uv_index_max", [0])[0]
if daily.get("uv_index_max")
else 0
)
humidity = current.get("relative_humidity_2m", 0)
risk_level = calculate_heat_risk(apparent_temp_max, uv_index_max, humidity)
city_risks.append({
"city": city_info["city"],
"country": city_info["country"],
"apparent_temperature_max_c": apparent_temp_max,
"current_temperature_c": current.get("temperature_2m", 0),
"current_apparent_temperature_c": current.get("apparent_temperature", 0),
"humidity_percent": humidity,
"uv_index_max": uv_index_max,
"wind_speed_kmh": current.get("wind_speed_10m", 0),
"weather_code": current.get("weather_code", 0),
"risk_level": risk_level,
})
ai_briefing = generate_ai_briefing(bedrock_client, city_risks)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
alert_payload = {
"generated_at": timestamp,
"event": "FIFA World Cup 2026",
"total_cities": len(city_risks),
"high_risk_count": len(
[c for c in city_risks if c["risk_level"] in ("High", "Extreme")]
),
"city_risks": city_risks,
"ai_briefing": ai_briefing,
}
s3_key = f"alerts/{timestamp.replace(':', '-')}.json"
s3_client.put_object(
Bucket=S3_BUCKET,
Key=s3_key,
Body=json.dumps(alert_payload, indent=2),
ContentType="application/json",
)
s3_client.put_object(
Bucket=S3_BUCKET,
Key="alerts/latest.json",
Body=json.dumps(alert_payload, indent=2),
ContentType="application/json",
)
return {
"statusCode": 200,
"body": json.dumps({
"message": "Heat risk assessment complete",
"timestamp": timestamp,
"high_risk_count": alert_payload["high_risk_count"],
"s3_key": s3_key,
}),
}
Test the full pipeline end-to-end
With the code deployed, you can now invoke the function and verify it fetches weather data, generates an AI briefing, and stores everything in S3.
- In the Lambda console, select the Test tab.
- Use the existing test event (or create a new one with an empty JSON body {}).
- Click Test to invoke the function.
What should I see?
The execution result should show statusCode: 200 with a message saying "Heat risk assessment complete". The response also includes the high_risk_count and the s3_key where the alert was stored.
Execution time will be longer than before (10-15 seconds) because the function now calls both the Open-Meteo API and Bedrock. This is expected.
- Open the S3 console in a new tab.
- Navigate to your worldcup-heat-alerts- bucket.
- Open the alerts/ folder. You should see latest.json and a timestamped file.
- Click latest.json, then click Download or use the Object actions menu to view the file contents.
- Confirm the JSON includes a city_risks array with 16 entries and an ai_briefing field containing a natural-language safety briefing.
What does the AI briefing look like?
The briefing will be a few paragraphs of plain text. It summarizes which cities are at elevated risk and provides specific recommendations for fans (stay hydrated, seek shade during peak hours) and organizers (prepare cooling stations, have medical staff on standby).
The exact content varies based on real-time weather conditions. On cooler days, the briefing will note that all cities are at low risk and still offer general heat safety tips.
- Return to the Lambda console and select the Monitor tab.
- Click View CloudWatch logs to open the CloudWatch console.
- Select the most recent log stream.
- Look for the REPORT line at the end of the invocation. It shows the billed duration and memory used. Confirm the function completed successfully without errors.
How much did this cost?
Each invocation sends approximately 1,500 input tokens and receives around 500 output tokens from Bedrock. At $0.80 per million input tokens and $4.00 per million output tokens, that works out to about $0.003 per call.
The Lambda execution itself and the S3 writes are covered by the AWS free tier. Your total cost for testing is well under $0.01.
Seeing an AccessDeniedException from Bedrock?
This means either your IAM role is missing the bedrock:InvokeModel permission, or model access was not enabled for Claude Haiku 4.5 in us-east-1.
- Check that the worldcup-heat-lambda-role IAM role has an inline policy granting bedrock:InvokeModel on the Claude Haiku 4.5 model ARN.
- In the Bedrock console, go to Model access and confirm the status for Anthropic Claude Haiku 4.5 shows Access granted.
help me fix a Bedrock AccessDeniedException in my Lambda function
Function timing out after 30 seconds?
The function now makes two external API calls (Open-Meteo and Bedrock), which can take longer than your 30-second timeout on slow networks.
- In the Lambda function Configuration tab, select General configuration and click Edit.
- Increase the timeout to 60 seconds, then click Save.
- Re-run the test.
help me fix a Lambda timeout when calling Bedrock
Your serverless system now fetches real-time weather data, scores heat risk, and generates AI-powered safety briefings. Next up, you will automate this pipeline with EventBridge scheduling and expose the alerts through a REST API.
Schedule with EventBridge and Expose via API Gateway
Your Lambda function fetches weather data, scores heat risk, and generates AI briefings. Right now, you have to invoke it manually each time.
In this step, you will automate the pipeline so it runs every 6 hours using EventBridge. You will also create a public REST API so any application can retrieve the latest heat risk alerts on demand.
In this step, get ready to:
- Create an EventBridge schedule that triggers your monitor Lambda every 6 hours.
- Build a second Lambda function that serves alert data from S3.
- Expose that function through an API Gateway REST API endpoint.
Create an EventBridge scheduled rule
EventBridge Scheduler lets you trigger AWS services on a recurring schedule without managing any infrastructure. You will create a schedule that invokes your worldcup-heat-monitor Lambda every 6 hours.
- In the AWS Console search bar, type EventBridge and open the Amazon EventBridge service.
- In the left navigation pane, under Scheduler, select Schedules.
- Click Create schedule.
- For Schedule name, enter worldcup-heat-monitor-schedule.
- Leave Schedule group set to default.
- Under Schedule pattern, select Recurring schedule.
- For Schedule type, select Rate-based schedule.
- Set the Value to 6 and the Unit to Hours. This creates a rate(6 hours) expression.
- For Flexible time window, select Off.
- Click Next.
- On the Select target page, choose Templated targets.
- Select AWS Lambda as the target.
- Under Invoke, select your worldcup-heat-monitor function from the dropdown.
- Click Next.
- On the Settings page, confirm that Enable schedule is toggled on.
- Under Permissions, select Create new role for this schedule. EventBridge will automatically attach the permissions needed to invoke your Lambda.
- Click Next.
- Review your settings.
- Click Create schedule.
You should see your new schedule listed with Status showing Enabled.
What does rate(6 hours) mean?
A rate expression tells EventBridge how often to fire. With rate(6 hours), your Lambda runs four times per day. That is approximately 120 invocations per month, well within the EventBridge free tier.
Schedule not showing as Enabled?
Make sure you toggled Enable schedule on the Settings page before creating. If it shows Disabled, select the schedule and choose Enable from the Actions dropdown.
Help me fix my EventBridge schedule
Create the API Lambda and API Gateway REST API
Your monitor Lambda writes alert data to S3. Now you need a way for external consumers to retrieve that data.
You will create a lightweight Lambda that reads from S3 and wire it to an API Gateway endpoint.
- In the AWS Console search bar, type Lambda and open the Lambda service.
- Click Create function.
- Select Author from scratch.
- For Function name, enter worldcup-alerts-api.
- For Runtime, select Python 3.13.
- Expand Change default execution role.
- Select Use an existing role and choose worldcup-heat-lambda-role from the dropdown. This role already has the s3:GetObject and CloudWatch Logs permissions this function needs.
- Click Create function.
- Once the function is created, select the Configuration tab.
- Select Environment variables from the left sidebar.
- Click Edit.
- Click Add environment variable.
- Set Key to S3_BUCKET and Value to your bucket name (worldcup-heat-alerts-{account-id}).
- Click Save.
- Switch to the Code tab in your worldcup-alerts-api function.
- Replace the contents of lambda_function.py with the following code:
import json
import os
import boto3
S3_BUCKET = os.environ["S3_BUCKET"]
def lambda_handler(event, context):
s3_client = boto3.client("s3")
try:
# Read the latest alert file from S3
response = s3_client.get_object(
Bucket=S3_BUCKET,
Key="alerts/latest.json",
)
alert_data = json.loads(response["Body"].read().decode())
return {
"statusCode": 200,
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
"body": json.dumps(alert_data),
}
except s3_client.exceptions.NoSuchKey:
# No alert file exists yet
return {
"statusCode": 404,
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
"body": json.dumps({"error": "No alerts generated yet. Trigger the monitor function first."}),
}
except Exception as e:
# Catch-all for unexpected errors
return {
"statusCode": 500,
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
"body": json.dumps({"error": str(e)}),
}
What does this code do?
- It reads alerts/latest.json from S3, which your monitor Lambda writes on every run.
- It returns the JSON with CORS headers (Access-Control-Allow-Origin: *) so browsers and external apps can fetch it directly.
- If no alert file exists yet, it returns a helpful 404 message instead of crashing.
- Click Deploy to save your function code.
Now create the API Gateway REST API to expose this function over HTTP.
- In the AWS Console search bar, type API Gateway and open the service.
- Under REST API, click Build.
- For API name, enter WorldCupHeatAlerts.
- Keep API endpoint type set to Regional.
- Click Create API.
- In the Resources pane, click Create Resource.
- For Resource name, enter alerts. The resource path will auto-fill as /alerts.
- Click Create Resource.
- Select the /alerts resource.
- Click Create Method.
- For Method type, select GET.
- For Integration type, select Lambda function.
- Turn on Lambda proxy integration.
- For Lambda function, enter worldcup-alerts-api and select it from the dropdown.
- Click Create method.
Why Lambda proxy integration?
With proxy integration enabled, API Gateway passes the entire HTTP request directly to your Lambda function. Your function controls the status code, headers, and body in its response.
This keeps the API Gateway configuration simple while giving your code full control over responses.
Permission prompt
When you create the method, the console may ask to add a resource-based permission so API Gateway can invoke your Lambda. Click OK to grant it.
Deploy the API and test with curl
Your API is configured but not yet accessible. You need to deploy it to a stage to get a live URL.
- In the API Gateway console for your WorldCupHeatAlerts API, click Deploy API.
- For Stage, select *New Stage*.
- For Stage name, enter prod.
- Click Deploy.
- After deployment, you will see the Invoke URL at the top of the stage page. Copy this URL.
- Test your API by running this command in your terminal:
curl [[API_URL="your API Gateway invoke URL"]]/alerts
You should see a JSON response containing generated_at, city_risks, and ai_briefing fields.
Getting a 404 or Internal Server Error?
If you see a 404 response with the message about no alerts generated, your worldcup-heat-monitor function has not run since you created the S3 bucket. Go to the Lambda console, select worldcup-heat-monitor, and invoke it with a test event to populate alerts/latest.json.
If you see a 500 error, check that the S3_BUCKET environment variable on worldcup-alerts-api matches your actual bucket name exactly.
Help me debug my API Gateway endpoint
- Finally, document your endpoint. Open VS Code and create a README.md in your project folder (or update it if you already have one).
- Add a section noting your API endpoint URL so you can reference it later.
✔️ Awesome, I've got everything!
Great. Double check you have saved your worldcup-alerts-api function code (clicked Deploy in Lambda) and your README is updated.
ⓧ I'd like to double check the full code
Here is the complete api_function.py code for your worldcup-alerts-api Lambda:
import json
import os
import boto3
S3_BUCKET = os.environ["S3_BUCKET"]
def lambda_handler(event, context):
s3_client = boto3.client("s3")
try:
response = s3_client.get_object(
Bucket=S3_BUCKET,
Key="alerts/latest.json",
)
alert_data = json.loads(response["Body"].read().decode())
return {
"statusCode": 200,
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
"body": json.dumps(alert_data),
}
except s3_client.exceptions.NoSuchKey:
return {
"statusCode": 404,
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
"body": json.dumps({"error": "No alerts generated yet. Trigger the monitor function first."}),
}
except Exception as e:
return {
"statusCode": 500,
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
"body": json.dumps({"error": str(e)}),
}
Your system is now fully automated. EventBridge triggers data collection every 6 hours, and anyone with the API URL can retrieve the latest heat risk briefings instantly. Next up, the Secret Mission challenges you to build a fan-facing dashboard that visualizes this data.
Secret mission
Build a Fan-Facing Heat Risk Dashboard
Your serverless backend is crunching weather data and generating AI briefings every 6 hours. But right now, that data is locked behind a raw JSON API endpoint. What if fans traveling to World Cup matches could see heat risk levels at a glance, with color-coded city cards and live safety recommendations? In this secret mission, you'll build an HTML dashboard hosted directly on S3 that pulls live data from your API and visualizes all 16 host cities.
Clean Up Your Resources
Clean Up Your Resources
This project uses AWS free-tier services for Lambda, S3, EventBridge, and API Gateway. However, the EventBridge rule triggers a Bedrock API call every 6 hours, which costs approximately $0.003 per invocation.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Cost warning
The EventBridge rule fires every 6 hours, invoking your Lambda function and making a Bedrock API call each time. Each call costs approximately $0.003 in Bedrock fees.
If you leave the system running for a full month, expect roughly $0.36 in Bedrock charges. Disable the EventBridge rule to stop scheduled costs immediately.
Resources you used:
- Lambda function worldcup-heat-monitor (free tier: 1M requests/month).
- Lambda function worldcup-alerts-api (free tier: 1M requests/month).
- S3 bucket worldcup-heat-alerts-{account-id} (free tier: 5 GB storage).
- EventBridge rule worldcup-heat-monitor-schedule (free tier).
- API Gateway REST API with GET /alerts endpoint (free tier: 1M calls/month).
- IAM role worldcup-heat-lambda-role (no cost).
- Amazon Bedrock Claude Haiku 4.5 model access (pay per use: ~$0.003 per invocation).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep the heat risk monitoring system running through the remainder of the World Cup.
- The EventBridge rule will continue to trigger every 6 hours, invoking your Lambda and calling Bedrock.
- Lambda, S3, EventBridge, and API Gateway remain within free tier for typical usage.
- Bedrock charges will accumulate at approximately $0.003 per invocation (about $0.36/month).
- Your S3 static website dashboard will remain publicly accessible.
Pause - I'll come back to this later
Disable the EventBridge rule to stop scheduled executions. All other resources remain intact but incur no cost when idle.
- Open the Amazon EventBridge console at https://console.aws.amazon.com/events/.
- In the navigation pane, choose Rules.
- Select the button next to worldcup-heat-monitor-schedule and choose Disable, then Disable again to confirm.
With the rule disabled, no Lambda invocations or Bedrock calls occur. Your API Gateway endpoint and S3 dashboard remain accessible but serve the last-generated alert data.
Delete - I don't want to use this again
Remove all project resources to return your AWS account to its prior state. Follow each step in order.
Delete the EventBridge rule:
- Open the Rules page of the EventBridge console at https://console.aws.amazon.com/events/.
- Select worldcup-heat-monitor-schedule.
- Choose Delete, then choose Delete again to confirm.
Delete the Lambda functions:
- Open the Functions page of the Lambda console.
- Select worldcup-heat-monitor.
- Choose Actions, then Delete.
- Choose Delete to confirm.
- Repeat for worldcup-alerts-api.
Delete the API Gateway:
- Open the API Gateway console.
- Select your REST API.
- Choose Actions, then Delete.
- Confirm the deletion.
Empty and delete the S3 bucket:
- Open the S3 console.
- Select the bucket worldcup-heat-alerts-{account-id}.
- Choose Empty, type permanently delete to confirm, then choose Empty.
- After emptying, select the bucket again and choose Delete.
- Type the bucket name to confirm, then choose Delete bucket.
Delete the IAM role:
- Open the IAM console and choose Roles from the navigation pane.
- Search for worldcup-heat-lambda-role.
- Select the role and choose Delete.
- Type the role name to confirm, then choose Delete.
Revoke Bedrock model access (optional):
- Open the Amazon Bedrock console and choose Model access from the left navigation.
- If you no longer need Bedrock, choose Modify model access and deselect Claude Haiku 4.5.
- Model access itself has no ongoing cost. This is optional.
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-style serverless heat risk monitoring system for the 2026 FIFA World Cup.
You've learned how to:
- Build a Python Lambda function that fetches real-time weather data from the Open-Meteo API and calculates heat risk scores for all 16 host cities.
- Integrate Amazon Bedrock Claude Haiku 4.5 to generate AI-powered safety briefings from structured weather data.
- Create an event-driven pipeline combining EventBridge scheduling, S3 storage, and a REST API via API Gateway with IAM least-privilege permissions.
- Secret Mission: Built a fan-facing HTML dashboard hosted on S3 with color-coded risk visualization across all 16 host cities.
Ready to quiz yourself?