Amazon API Gateway turns 11 this year, and it’s still the default front door for serverless APIs on AWS. Search interest for “api gateway aws” runs north of 3,600 queries a month in the US alone, yet most of what ranks is either dense official documentation or a stale walkthrough built on a console UI that AWS has since redesigned twice. This tutorial closes that gap. It walks through standing up a working REST API backed by AWS Lambda, wiring in three different authorizer types, attaching a custom domain, protecting the endpoint with AWS WAF, and understanding exactly what you’ll pay once the free tier runs out.
By the end you’ll have a deployed, callable HTTPS endpoint, a Lambda function reading and writing to DynamoDB, and a checklist for the mistakes that trip up almost everyone the first time they build this stack. Total build time: about 90 minutes if you follow the steps in order.
What You’ll Build and Why API Gateway Still Matters in 2026
The project in this guide is a small task-tracking API: a REST endpoint that lets a client create, read, update, and delete items in a DynamoDB table through Lambda functions triggered by API Gateway. It’s small enough to finish in an afternoon and structured enough to mirror a real production pattern — proxy integration, per-route authorizers, throttling, custom domains, and a WAF web ACL sitting in front of everything.
Amazon API Gateway backs each method with a Lambda function, so when a client calls the HTTPS endpoint exposed in API Gateway, API Gateway invokes the function and returns whatever it sends back, according to AWS’s serverless architecture whitepaper. That’s the entire mental model you need before touching the console.
AWS actually ships three distinct API Gateway product types, and picking the wrong one is the single most common early mistake. REST APIs carry the most features (usage plans, request validation, private VPC endpoints, WAF integration, API keys) but cost the most per request. HTTP APIs are leaner, cheaper, and cover the majority of proxy-to-Lambda use cases. WebSocket APIs handle persistent, bidirectional connections for chat, live dashboards, and multiplayer backends. This tutorial builds a REST API because it’s the type most tutorials skip over in favor of the simpler HTTP API, and it’s the type you need once a project needs API keys, resource policies, or WAF.
Coming out of AWS re:Invent 2025, API Gateway picked up response streaming for REST APIs, new developer portal capabilities, and additional TLS security policies for REST endpoints, according to AWS’s weekly roundup blog. Separately, AWS added private integration between API Gateway and Application Load Balancers, letting private APIs route straight to an ALB inside a VPC without a NAT gateway in the path, per AWS’s Serverless ICYMI Q4 2025 recap. Neither feature is required for this tutorial, but both matter once you’re scaling past a demo.
Prerequisites and Versions
Get these in place before Step 1. Skipping any of them is the fastest way to hit a wall halfway through.
- An AWS account with billing enabled (the free tier covers this entire tutorial, but AWS requires a card on file)
- AWS CLI v2.27 or later — check with
aws --version - Node.js 20.x or later, since the sample Lambda function is written in Node.js (Python 3.13 works identically if you prefer that runtime)
- An IAM user or role with permissions for API Gateway, Lambda, DynamoDB, IAM, CloudWatch, and WAF (AdministratorAccess is fine for a learning environment; scope it down before production)
- curl or Postman for testing the deployed endpoint
- A registered domain in Route 53 (only needed for the custom domain step — skip that section if you don’t have one)
- About 90 minutes and a terminal
Confirm your CLI is authenticated before moving on:
aws sts get-caller-identity
aws configure get region
If either command errors out, run aws configure and set your access key, secret key, and default region (us-east-1 is used throughout this tutorial for consistency).
Step 1: Create the DynamoDB Table
Every request that hits the API eventually reads or writes to a table called tasks. Create it with a simple partition key so you’re not fighting index design on top of everything else:
aws dynamodb create-table
--table-name tasks
--attribute-definitions AttributeName=taskId,AttributeType=S
--key-schema AttributeName=taskId,KeyType=HASH
--billing-mode PAY_PER_REQUEST
--region us-east-1
PAY_PER_REQUEST billing means you’re not paying for provisioned capacity while the table sits idle between test calls, which matters if this is a throwaway project. Confirm the table is active before continuing:
aws dynamodb describe-table --table-name tasks --query "Table.TableStatus"
Expect "ACTIVE" within a few seconds. If it still says CREATING, wait and re-run the command — DynamoDB table creation is asynchronous.
Step 2: Write and Package the Lambda Function
Create a project directory and a single handler file that does CRUD against the table based on the HTTP method it receives. This uses Lambda proxy integration, which AWS describes as a lightweight, flexible integration type that lets you connect an API method — or an entire API — to a Lambda function, per the API Gateway developer guide. Proxy integration means API Gateway forwards the entire request (headers, body, query string, path parameters) to your function as a single event object, and your function is responsible for shaping the response.
mkdir tasks-api && cd tasks-api
npm init -y
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
Create index.mjs:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
PutCommand,
GetCommand,
ScanCommand,
DeleteCommand,
} from "@aws-sdk/lib-dynamodb";
import { randomUUID } from "crypto";
const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = "tasks";
export const handler = async (event) => {
const method = event.requestContext.http.method;
const taskId = event.pathParameters?.taskId;
try {
if (method === "POST") {
const body = JSON.parse(event.body || "{}");
const item = { taskId: randomUUID(), title: body.title, done: false };
await client.send(new PutCommand({ TableName: TABLE, Item: item }));
return response(201, item);
}
if (method === "GET" && taskId) {
const result = await client.send(
new GetCommand({ TableName: TABLE, Key: { taskId } })
);
return result.Item ? response(200, result.Item) : response(404, { error: "not found" });
}
if (method === "GET") {
const result = await client.send(new ScanCommand({ TableName: TABLE }));
return response(200, result.Items);
}
if (method === "DELETE" && taskId) {
await client.send(new DeleteCommand({ TableName: TABLE, Key: { taskId } }));
return response(204, null);
}
return response(405, { error: "method not allowed" });
} catch (err) {
console.error(err);
return response(500, { error: "internal error" });
}
};
function response(statusCode, body) {
return {
statusCode,
headers: { "Content-Type": "application/json" },
body: body === null ? "" : JSON.stringify(body),
};
}
Zip it up for deployment:
zip -r function.zip index.mjs node_modules package.json
Step 3: Create the Lambda Function and Execution Role
Lambda needs an IAM role that lets it write logs and touch the DynamoDB table. Create the trust policy first:
cat > trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
EOF
aws iam create-role
--role-name tasks-api-lambda-role
--assume-role-policy-document file://trust-policy.json
aws iam attach-role-policy
--role-name tasks-api-lambda-role
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam attach-role-policy
--role-name tasks-api-lambda-role
--policy-arn arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess
AmazonDynamoDBFullAccess is deliberately broad for this tutorial — in production, scope the policy to the specific table ARN and only the actions your function actually calls (GetItem, PutItem, Scan, DeleteItem).
Now create the function itself. Grab the role ARN from the previous step’s output first:
ROLE_ARN=$(aws iam get-role --role-name tasks-api-lambda-role --query "Role.Arn" --output text)
aws lambda create-function
--function-name tasks-api-handler
--runtime nodejs20.x
--handler index.handler
--role "$ROLE_ARN"
--zip-file fileb://function.zip
--timeout 10
--memory-size 256
IAM role propagation is asynchronous — if create-function fails with an assume-role error, wait 10 seconds and retry. This is one of the most common false failures in this entire workflow.
Step 4: Create the REST API in API Gateway
This is where a lot of 2024-era tutorials go stale — the console has been redesigned, but the CLI/API surface is stable, so this guide uses the CLI end to end. Create the REST API resource:
API_ID=$(aws apigateway create-rest-api
--name "tasks-api"
--endpoint-configuration types=REGIONAL
--query "id" --output text)
echo "API ID: $API_ID"
REGIONAL is the right default for most new APIs — it avoids the extra CloudFront hop that the older EDGE endpoint type adds, and it’s what you’d put behind your own CDN or WAF anyway.
Grab the root resource ID, then create a /tasks resource and a /tasks/{taskId} child resource:
ROOT_ID=$(aws apigateway get-resources --rest-api-id $API_ID --query "items[0].id" --output text)
TASKS_ID=$(aws apigateway create-resource
--rest-api-id $API_ID --parent-id $ROOT_ID
--path-part "tasks" --query "id" --output text)
TASK_ID=$(aws apigateway create-resource
--rest-api-id $API_ID --parent-id $TASKS_ID
--path-part "{taskId}" --query "id" --output text)
Step 5: Wire Up Lambda Proxy Integration for Each Method
In Lambda proxy integration, the required setup is genuinely simple, according to the API Gateway developer guide — you point a method at the function and API Gateway handles the request/response envelope. Add a POST and GET method on /tasks, and GET/DELETE on /tasks/{taskId}:
REGION="us-east-1"
ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text)
LAMBDA_ARN="arn:aws:lambda:$REGION:$ACCOUNT_ID:function:tasks-api-handler"
URI="arn:aws:apigateway:$REGION:lambda:path/2015-03-31/functions/$LAMBDA_ARN/invocations"
for METHOD_RESOURCE in "POST:$TASKS_ID" "GET:$TASKS_ID"; do
METHOD="${METHOD_RESOURCE%%:*}"; RESOURCE="${METHOD_RESOURCE##*:}"
aws apigateway put-method --rest-api-id $API_ID --resource-id $RESOURCE
--http-method $METHOD --authorization-type NONE
aws apigateway put-integration --rest-api-id $API_ID --resource-id $RESOURCE
--http-method $METHOD --type AWS_PROXY --integration-http-method POST --uri "$URI"
done
for METHOD_RESOURCE in "GET:$TASK_ID" "DELETE:$TASK_ID"; do
METHOD="${METHOD_RESOURCE%%:*}"; RESOURCE="${METHOD_RESOURCE##*:}"
aws apigateway put-method --rest-api-id $API_ID --resource-id $RESOURCE
--http-method $METHOD --authorization-type NONE
aws apigateway put-integration --rest-api-id $API_ID --resource-id $RESOURCE
--http-method $METHOD --type AWS_PROXY --integration-http-method POST --uri "$URI"
done
Grant API Gateway permission to invoke the function — this is the step that trips up nearly everyone the first time, because the console does it silently but the CLI does not:
aws lambda add-permission
--function-name tasks-api-handler
--statement-id apigateway-invoke
--action lambda:InvokeFunction
--principal apigateway.amazonaws.com
--source-arn "arn:aws:execute-api:$REGION:$ACCOUNT_ID:$API_ID/*/*/*"
Step 6: Deploy the API to a Stage
Nothing you’ve built so far is actually callable until it’s deployed to a stage. Stages are how API Gateway versions environments (dev, staging, prod) behind the same API definition:
aws apigateway create-deployment
--rest-api-id $API_ID
--stage-name dev
INVOKE_URL="https://$API_ID.execute-api.$REGION.amazonaws.com/dev"
echo "$INVOKE_URL"
Test it immediately:
curl -X POST "$INVOKE_URL/tasks" -d '{"title":"Ship the API Gateway tutorial"}'
Expected output:
{"taskId":"3f29a1c4-7b8e-4e1a-9c2d-8a5f6e7d1b2c","title":"Ship the API Gateway tutorial","done":false}
Pull the full list back with a plain GET:
curl "$INVOKE_URL/tasks"
If both calls return JSON instead of a 4xx or 5xx, the core stack is live. Everything from here is hardening.
Step 7: Choose the Right Authorizer — Lambda, JWT, IAM, or Cognito
Right now the API is wide open — anyone with the URL can read and write. API Gateway supports four distinct authorization models, and picking the right one depends on who’s calling the API.
| Authorizer type | Best for | Latency cost | Where the logic lives |
|---|---|---|---|
| IAM (SigV4) | Service-to-service calls from other AWS resources | None — validated natively | AWS-signed requests, no custom code |
| Cognito (JWT) | End-user authentication with a managed user pool | None — validated natively | Cognito user pool issues and signs tokens |
| JWT authorizer | Any OIDC-compliant identity provider (Auth0, Okta, custom) | None — validated natively | Token signature checked by API Gateway directly |
| Lambda authorizer | Custom logic — API keys, IP allowlists, legacy auth systems | One Lambda invocation per request | Your own function returns an IAM policy |
Cognito user pools are effectively a specialized JWT authorizer with built-in token structure and claims handling, which is why AWS’s 2026 API Gateway security architecture guidance lists a Cognito JWT authorizer as the default authentication layer, paired with IAM SigV4 for service principals. The practical rule: use a JWT or Cognito authorizer whenever you can, because API Gateway validates the token signature natively without invoking any extra compute. Reach for a Lambda authorizer only when your auth logic can’t be expressed as a standard token check — custom API key rotation, IP-based rules, or a legacy session store you can’t migrate yet.
To attach a Cognito authorizer, reference an existing user pool ARN:
AUTHORIZER_ID=$(aws apigateway create-authorizer
--rest-api-id $API_ID
--name "cognito-authorizer"
--type COGNITO_USER_POOLS
--provider-arns "arn:aws:cognito-idp:$REGION:$ACCOUNT_ID:userpool/us-east-1_XXXXXXXXX"
--identity-source "method.request.header.Authorization"
--query "id" --output text)
aws apigateway update-method
--rest-api-id $API_ID --resource-id $TASKS_ID --http-method POST
--patch-operations op=replace,path=/authorizationType,value=COGNITO_USER_POOLS
op=replace,path=/authorizerId,value=$AUTHORIZER_ID
If you don’t have a Cognito user pool yet, the AWS Cognito setup guide covers standing one up from scratch, including hosted UI configuration and MAU pricing.
Step 8: Set Throttling and Usage Plans
API Gateway enforces throttling at both the account level and the per-stage/per-method level, and both can be raised through an AWS Support request if your traffic genuinely needs it. Rather than relying on undocumented defaults, set explicit limits on the stage so behavior is predictable:
aws apigateway update-stage
--rest-api-id $API_ID --stage-name dev
--patch-operations
op=replace,path=/*/throttling/rateLimit,value=100
op=replace,path=/*/throttling/burstLimit,value=200
For partner-facing APIs where you want to meter usage per API key, create a usage plan and attach it to the stage:
PLAN_ID=$(aws apigateway create-usage-plan
--name "tasks-api-basic"
--api-stages apiId=$API_ID,stage=dev
--throttle rateLimit=50,burstLimit=100
--quota limit=10000,period=MONTH
--query "id" --output text)
Usage plans are the mechanism behind API keys and metered billing tiers — if you’re planning to sell access to this API, this is the primitive you build on.
Step 9: Attach a Custom Domain
The default execute-api URL works fine for testing but isn’t something you’d ship to customers. Request an ACM certificate for your domain first (in the same region as a regional custom domain), then create the domain mapping:
aws apigateway create-domain-name
--domain-name api.yourdomain.com
--regional-certificate-arn "arn:aws:acm:us-east-1:$ACCOUNT_ID:certificate/YOUR-CERT-ID"
--endpoint-configuration types=REGIONAL
aws apigateway create-base-path-mapping
--domain-name api.yourdomain.com
--rest-api-id $API_ID
--stage dev
Then point a Route 53 alias record at the regional domain name API Gateway returns from the create-domain-name call. Propagation typically takes a few minutes; DNS caching on your own machine can make it feel longer.
Step 10: Put AWS WAF in Front of the API
Attaching a WAF web ACL to a regional API Gateway stage is standard practice by 2026 and one of the cheapest security wins available. Create a minimal web ACL with the AWS-managed common rule set, then associate it with your API’s stage ARN:
aws wafv2 associate-web-acl
--web-acl-arn "arn:aws:wafv2:$REGION:$ACCOUNT_ID:regional/webacl/tasks-api-acl/YOUR-ACL-ID"
--resource-arn "arn:aws:apigateway:$REGION::/restapis/$API_ID/stages/dev"
If you don’t have a web ACL yet, the Cloudflare WAF vs AWS WAF vs Azure WAF comparison breaks down managed rule group pricing and which one fits a serverless-first stack like this one.
Step 11: Enable CloudWatch Logging and X-Ray Tracing
Without execution logging turned on, a failed request in production is a black box. Enable it on the stage:
aws apigateway update-stage
--rest-api-id $API_ID --stage-name dev
--patch-operations
op=replace,path=/*/logging/loglevel,value=INFO
op=replace,path=/tracingEnabled,value=true
X-Ray tracing lets you see exactly how much of your total request latency is API Gateway overhead versus Lambda cold start versus the DynamoDB call — invaluable once you’re debugging a latency complaint instead of guessing at one.
Step 12: Move the Whole Stack to Infrastructure as Code
Everything above was built with raw CLI calls so you’d see exactly what each resource does. In practice, you don’t want to hand-run 30 CLI commands every time you deploy. Once the manual build works, port it to CloudFormation, SAM, or Terraform. The AWS CloudFormation Express Mode tutorial is the fastest path if you’re staying inside native AWS tooling — it compresses stack creation to a fraction of the standard deploy time.
A minimal SAM template for the same API/Lambda/DynamoDB trio looks like this:
Resources:
TasksApi:
Type: AWS::Serverless::Api
Properties:
StageName: dev
TasksFunction:
Type: AWS::Serverless::Function
Properties:
Handler: index.handler
Runtime: nodejs20.x
CodeUri: ./tasks-api
Events:
CreateTask:
Type: Api
Properties:
RestApiId: !Ref TasksApi
Path: /tasks
Method: POST
TasksTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: tasks
AttributeDefinitions:
- AttributeName: taskId
AttributeType: S
KeySchema:
- AttributeName: taskId
KeyType: HASH
BillingMode: PAY_PER_REQUEST
Step 13: Test the Complete Flow End to End
Run through the full CRUD cycle to confirm nothing regressed after the hardening steps:
# Create
TASK_ID=$(curl -s -X POST "$INVOKE_URL/tasks" -d '{"title":"Test task"}' | python3 -c "import json,sys; print(json.load(sys.stdin)['taskId'])")
# Read one
curl "$INVOKE_URL/tasks/$TASK_ID"
# List all
curl "$INVOKE_URL/tasks"
# Delete
curl -X DELETE "$INVOKE_URL/tasks/$TASK_ID"
# Confirm deletion
curl -i "$INVOKE_URL/tasks/$TASK_ID"
The final call should return a 404. If any step returns a 500, check CloudWatch Logs for the Lambda function first — that’s where nearly every runtime error in this stack surfaces.
REST API vs HTTP API vs WebSocket API: Which One Should You Build
This tutorial deliberately used a REST API because it exposes every feature — usage plans, WAF association, private endpoints, request validation — that HTTP APIs either lack or handle differently. But REST APIs cost roughly 3.5x more per request than HTTP APIs, so the choice matters for anything beyond a prototype.
| API type | Price per million requests | Free tier (new accounts, 12 mo) | Best for |
|---|---|---|---|
| HTTP API | $1.00 (first 300M/mo), then $0.90 | 1 million calls/month | Simple Lambda-proxy backends, cost-sensitive workloads |
| REST API | $3.50 (first 333M/mo), tiered down to ~$2.38 | 1 million calls/month | API keys, usage plans, WAF, private VPC endpoints, request validation |
| WebSocket API | $1.00 per million messages + $0.25 per million connection-minutes | 1 million messages/month | Chat, live dashboards, multiplayer state sync |
Pricing for both HTTP and REST APIs covers only the API calls you receive and the data you transfer out — there’s no minimum fee or upfront commitment, and private API data transfer isn’t billed separately, according to AWS’s official API Gateway pricing page. If your API is purely a thin proxy in front of Lambda with no need for API keys or WAF, switching to an HTTP API is close to a drop-in swap and the cheapest lever you can pull on this stack.
5 Common Pitfalls When Building on API Gateway
1. Forgetting the Lambda resource-based permission. The console adds this automatically; the CLI and most IaC tools do not unless you explicitly configure it. Without it, every call returns a generic 500 with no useful CloudWatch entry on the Lambda side, because the invocation never actually reaches your function — it fails at the trust boundary.
2. Deploying changes but forgetting to redeploy the stage. Every change to resources, methods, or integrations lives in the API definition, but none of it takes effect until you run create-deployment against a stage again. This is the number one cause of “I changed it but nothing happened” tickets.
3. Choosing REST APIs for high-volume, simple proxy workloads. At meaningful scale, the price gap between REST and HTTP APIs compounds fast. If you don’t need usage plans, API keys, or WAF, you’re paying 3.5x for features you’re not using.
4. Over-scoping the Lambda execution role. AmazonDynamoDBFullAccess is fine for a tutorial; in production it means a compromised function can read and write every DynamoDB table in the account. Scope the policy to the specific table ARN and specific actions before shipping.
5. Skipping throttling configuration entirely. Leaving default account-level throttling as your only safeguard means a traffic spike, a retry storm from a buggy client, or a DDoS attempt can all hit your Lambda concurrency limit at once, potentially starving other functions in the same account.
Advanced Tips for Production API Gateway Deployments
Use request validation to reject malformed payloads at the API Gateway layer, before they ever invoke Lambda — this shaves cost and latency off every bad request, since you’re not paying for compute time on input you were going to reject anyway. Enable response streaming on REST APIs for endpoints returning large payloads or long-running generative responses, since it lets clients start processing data before the full response completes.
If your workload has a service-to-service leg — say, an internal microservice calling this API from inside the same VPC — use a private API with a VPC endpoint instead of routing through the public internet and back. Private API Gateway endpoints exempt you from data transfer charges at the API Gateway layer entirely, on top of the latency and security wins.
For teams standardizing on agent-based tooling, API Gateway added support for exposing existing REST APIs as Model Context Protocol (MCP)-compatible endpoints around re:Invent 2025 — worth evaluating if you’re building internal tools that AI agents need to call directly rather than going through a bespoke integration layer.
Finally, set a CloudWatch alarm on the 4XXError and 5XXError metrics per stage. A sudden spike in 4XX errors after a deploy almost always means a client is calling a route or method you just changed or removed — catching it in an alarm beats catching it in a support ticket.
Troubleshooting: 8 Issues You’ll Likely Hit
“Internal Server Error” with no CloudWatch log entry. Almost always the missing lambda:InvokeFunction permission for the API Gateway principal. Re-run the add-permission command from Step 5 and confirm the source ARN matches your actual API ID and stage.
Changes don’t appear after updating a method or integration. You updated the API definition but didn’t redeploy the stage. Run create-deployment again.
403 Forbidden on every request. Check whether a resource policy, WAF rule, or an authorizer you forgot about is blocking the call. Test with curl -v to see the exact response headers — API Gateway often includes a hint in the x-amzn-ErrorType header.
429 Too Many Requests during a normal load test. You’ve hit the throttle limit you set in Step 8, or the account-level default. Raise the stage-level throttle or request a service quota increase if the account-level ceiling is the actual bottleneck.
Custom domain returns “The specified domain name does not exist” right after creation. Domain name propagation inside API Gateway itself can take a few minutes even after the API call succeeds. Wait and retry before assuming the configuration is wrong.
CORS errors in the browser console but curl works fine. curl doesn’t send preflight OPTIONS requests; browsers do. You need an explicit OPTIONS method on every resource that returns the correct Access-Control-Allow-Origin and Access-Control-Allow-Methods headers, even when using Lambda proxy integration, since proxy integration doesn’t auto-generate CORS responses.
Lambda function times out intermittently. Check whether it’s a cold start issue (first invocation after idle) versus a genuine slow downstream call to DynamoDB. X-Ray tracing from Step 11 will show you exactly where the time goes.
WAF web ACL association fails with a resource-not-found error. The stage ARN format for WAF association is different from the invoke URL — double-check you’re using the arn:aws:apigateway:REGION::/restapis/API_ID/stages/STAGE_NAME format exactly, not a URL.
Cleaning Up: Avoiding Surprise Charges
Everything in this tutorial fits comfortably inside the free tier for a learning project, but if you’re tearing it down, remove resources in this order to avoid dependency errors:
aws apigateway delete-rest-api --rest-api-id $API_ID
aws lambda delete-function --function-name tasks-api-handler
aws dynamodb delete-table --table-name tasks
aws iam detach-role-policy --role-name tasks-api-lambda-role --policy-arn arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess
aws iam detach-role-policy --role-name tasks-api-lambda-role --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name tasks-api-lambda-role
A custom domain name and any ACM certificate you requested won’t incur charges on their own, but leaving an unused WAF web ACL running will — WAF bills per web ACL per month regardless of traffic, so delete it if you’re not keeping the API live.
Step 14: Add Request Validation With a JSON Schema Model
Right now, a malformed POST body — say, one missing the title field — still travels all the way to Lambda before your handler notices something’s wrong. That’s wasted invocation time and wasted cost. API Gateway can reject the request before it ever reaches your function by validating it against a JSON Schema model attached to the method.
Create the model first:
aws apigateway create-model
--rest-api-id $API_ID
--name "TaskInput"
--content-type "application/json"
--schema '{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "TaskInput",
"type": "object",
"properties": {
"title": { "type": "string", "minLength": 1, "maxLength": 200 }
},
"required": ["title"]
}'
Then attach it to the POST method on /tasks with a request validator that checks the body:
VALIDATOR_ID=$(aws apigateway create-request-validator
--rest-api-id $API_ID
--name "validate-body"
--validate-request-body
--query "id" --output text)
aws apigateway update-method
--rest-api-id $API_ID --resource-id $TASKS_ID --http-method POST
--patch-operations
op=replace,path=/requestValidatorId,value=$VALIDATOR_ID
op=replace,path=/requestModels/application~1json,value=TaskInput
Redeploy the stage, then test with a deliberately broken payload:
curl -i -X POST "$INVOKE_URL/tasks" -d '{}'
You should get a 400 straight from API Gateway, with a body like {"message":"Invalid request body"}, and — critically — no Lambda invocation logged in CloudWatch at all. That’s the point: the bad request never cost you compute time.
Calculating Your Actual Monthly Cost
Pricing pages are abstract until you run your own numbers through them. Here’s a worked example for three traffic profiles, using the REST API tier this tutorial builds on, with Lambda and DynamoDB costs excluded so you can see the API Gateway line item in isolation.
| Monthly traffic | REST API cost | HTTP API cost | Savings if you switch |
|---|---|---|---|
| 500,000 requests (small internal tool) | $0 (within free tier) | $0 (within free tier) | $0 — both free |
| 10 million requests/month | ~$35.00 | ~$10.00 | ~$25.00/mo (71%) |
| 500 million requests/month | ~$1,558 (tiered) | ~$465 (tiered) | ~$1,093/mo (70%) |
These figures come directly from the tiered REST pricing ($3.50 per million for the first 333 million requests, stepping down at higher volume) and flat HTTP pricing ($1.00 per million for the first 300 million, then $0.90 per million) published on AWS’s pricing page. Data transfer out is billed separately at roughly $0.09 per GB in most US regions on top of these request costs, and doesn’t apply at all to private APIs. The takeaway for anyone scaling past a prototype: run this math before you lock in REST API-only features you might not actually need, because the gap between REST and HTTP pricing only gets more expensive as traffic grows.
WebSocket pricing follows a different shape entirely — $1.00 per million messages sent or received (metered in 32 KB increments, so a 40 KB message bills as two) plus $0.25 per million connection-minutes. A chat app holding 10,000 concurrent connections open for a full month, for instance, would rack up roughly $1,051 in connection-minute charges alone before a single message is sent, which is why WebSocket workloads need their own cost model rather than being estimated off REST or HTTP numbers.
Security Checklist Before You Call This Production-Ready
Run through this list before pointing real traffic at the API you just built. Each item maps back to a step above, so treat gaps here as unfinished work rather than optional polish.
- Every route requires an authorizer — nothing is left on
NONEexcept genuinely public endpoints (Step 7) - The Lambda execution role is scoped to the specific table ARN and specific actions, not a full-access managed policy (Step 3)
- Stage-level throttling is set explicitly rather than relying on undocumented account defaults (Step 8)
- A WAF web ACL with at least the AWS-managed common rule set is associated with the stage (Step 10)
- CloudWatch logging and X-Ray tracing are both enabled on the stage (Step 11)
- Request validation rejects malformed bodies before they reach Lambda (Step 14)
- CORS headers are explicitly configured on every resource a browser will call, not just tested with curl
- A CloudWatch alarm exists on the
5XXErrormetric so failures surface before a customer reports them
How This Compares to Other Managed Gateways
API Gateway isn’t the only option for fronting serverless compute, and it isn’t automatically the right one for every team. If your workloads span multiple clouds or you already run a self-hosted gateway for other services, it’s worth weighing alternatives before committing your entire routing layer to one vendor’s product. The Kong vs AWS API Gateway vs Apigee comparison breaks down the throughput and cost tradeoffs in more depth, including where a self-hosted gateway pulls ahead on raw requests-per-second.
The short version: API Gateway wins on operational simplicity and native IAM/Cognito integration if you’re already deep in the AWS ecosystem. It loses ground when you need a single gateway spanning multiple clouds, or when request volume is high enough that a self-hosted option’s flat infrastructure cost undercuts API Gateway’s per-request pricing.
Extending the Project: What to Build Next
Once the base API is solid, the natural next steps are: adding request validation with a JSON Schema model so malformed bodies get rejected before Lambda runs, wiring API Gateway access logs into a centralized log aggregator instead of relying on raw CloudWatch, and moving authentication from the open NONE setting used in early steps to the Cognito or JWT authorizer from Step 7 across every route, not just the one used as an example. If the function itself grows complex enough to need its own HTTP routing internally, the AWS Lambda API build guide covers patterns for structuring that logic without duplicating what API Gateway already handles for you.
For teams standardizing infrastructure across the org, pairing this API Gateway setup with the base Lambda configuration covered in the AWS Lambda setup guide gives you a reusable template you can fork for every new serverless service instead of rebuilding the plumbing from scratch each time.
Frequently Asked Questions
Is API Gateway free to use?
Not indefinitely, but new AWS accounts get a free tier covering 1 million REST API calls, 1 million HTTP API calls, and 1 million WebSocket messages per month for the first 12 months. After that, or after the free tier expires, you pay per request based on the API type you chose.
Should I use a REST API or an HTTP API for a new project?
Default to an HTTP API unless you specifically need usage plans, API keys, request validation, private VPC endpoints, or WAF association — those features are REST-API-only, and HTTP APIs cost roughly 71% less per request at equivalent volume.
Why does my API return a generic 500 error with nothing in CloudWatch?
This almost always means API Gateway never successfully invoked your Lambda function, most commonly because the resource-based permission granting apigateway.amazonaws.com the right to call it is missing or scoped to the wrong API ID.
What’s the difference between a Lambda authorizer and a JWT authorizer?
A JWT authorizer validates a token’s signature natively inside API Gateway with no extra compute cost or latency. A Lambda authorizer runs your own function on every request to make a custom authorization decision, which adds an invocation but supports logic a standard token check can’t express.
Can API Gateway route to something other than Lambda?
Yes. It supports direct integrations with many AWS services (DynamoDB, S3, Step Functions, SQS, and others), HTTP proxy integrations to any public or private HTTP endpoint, and — as of late 2025 — private integrations directly to an Application Load Balancer inside a VPC.
Do I need to redeploy every time I change something?
Yes. Changes to resources, methods, integrations, or authorizers only take effect on a stage after you explicitly create a new deployment against that stage. This is one of the most common sources of confusion for people new to the service.
How do I secure a private, internal-only API?
Create the API with a PRIVATE endpoint configuration, attach a resource policy restricting access to a specific VPC endpoint, and route traffic through that VPC endpoint rather than the public internet. Private APIs also aren’t charged for data transfer out at the API Gateway layer.
Is WAF worth adding for a small internal API?
For an internal, low-traffic API behind a private endpoint, it’s usually unnecessary overhead. For any public-facing API accepting traffic from the open internet, attaching even a minimal AWS-managed rule set is a low-cost way to filter out common exploit patterns before they reach your Lambda function.