What it actually is
Amazon API Gateway is a managed front door for HTTP and WebSocket traffic. Clients call a public (or private) URL that AWS operates. API Gateway matches the request to a route, decides whether the caller is allowed in, applies throttling, and forwards the request to an integration: a Lambda function, an HTTP backend, a load balancer behind a VPC link, or another AWS service. Your code never sees a request that did not pass those checks.
The thing the docs bury is that "API Gateway" is really three products with one name. REST APIs are the original and the most feature-rich. HTTP APIs came later, with a deliberately smaller feature set and a lower price. WebSocket APIs keep long-lived connections open and route messages instead of requests. The AWS comparison page states the trade in one sentence: REST APIs support more features, while HTTP APIs are designed with minimal features so they can be offered at a lower price. Most of the decisions below are a consequence of that sentence.
The model
Four nouns cover an HTTP API. An API is the container. A route is a method plus a path pattern such as GET /orders/{id}. An integration is what the route forwards to. A stage is a deployed snapshot with its own settings, and the special $default stage gives you a URL with no stage name in the path. An authorizer is attached per route, so one API can mix public and protected routes.
Route selection is worth knowing because it explains surprising 404s. API Gateway picks the most specific match: an exact route and method first, then a greedy path variable like {proxy+}, then the $default route. If nothing matches it returns {"message":"Not Found"}.
On the HTTP API side you choose how events reach Lambda through the payload format version. Version 2.0 is what you want for new work. It drops the multi-value header and query fields (duplicates are comma-joined), adds rawPath and a cookies array, and lets a function return a bare JSON object that API Gateway wraps as a 200 response. The console defaults to 2.0, but the CLI, CloudFormation and SDKs make you say it explicitly, which is why the code below sets PayloadFormatVersion="2.0".
What AWS operates: the endpoint fleet, TLS termination, scaling, the token-bucket throttle, and JWKS key fetching for the JWT authorizer. What you operate: the routes, the integration targets, the identity provider, the log groups and every decision about what a 401 should mean to your clients.
When to use it, when not to
The honest comparison is against its own siblings first, then against an ALB.
Need | HTTP API | REST API | Application Load Balancer |
|---|---|---|---|
JWT validation without code | Yes, native authorizer | No (use a Lambda authorizer) | OIDC authentication action |
API keys, per-client usage plans | No | Yes | No |
AWS WAF | No | Yes | Yes |
Request validation, body transformation | No | Yes | No |
Caching | No | Yes | No |
Private (VPC-only) endpoint | No | Yes | Internal scheme |
Price per million requests (first tier, US regions) | $1.00 | $3.50 | Hourly plus LCU, not per request |
The table is condensed from the AWS feature comparison. The rule I use: start with an HTTP API, and move to REST only when you can name the specific missing feature you need. The AWS page lists the triggers explicitly: API keys, per-client throttling, request validation, AWS WAF, or private API endpoints. "It sounds more enterprise" is not on the list.
Skip API Gateway entirely when you have one service behind one hostname and no need for per-route auth or throttling. An ALB with a Lambda or ECS target does that job with no per-request fee. Skip it also for payloads the gateway will fight you on: the HTTP API payload limit is 10 MB and the integration timeout is capped at 30 seconds, neither adjustable.
What it costs
You pay per request, tiered by volume. The AWS pricing page's worked examples show HTTP APIs at $1.00 per million requests for the first 300 million and $0.90 per million after that. REST APIs are $3.50 per million for the first 333 million, then $2.80 and $2.38 at higher tiers. The examples use US regions and some others, so check your region on the pricing page before you quote a number to a finance person. At the first tier that is a 3.5x gap, and it is the single largest reason to default to HTTP APIs.
The pricing page's HTTP API examples divide payload size by 512 KB to count billable requests, so a large request body can count as several. Plan for that if you proxy uploads.
WebSocket APIs bill two things: messages at $1.00 per million (US East, N. Virginia), metered in 32 KB increments with a 128 KB maximum message size, and connection minutes at $0.25 per million. REST API caching is billed by the hour per cache size; the page's example is 1.6 GB at $0.038 per hour, which is a fixed cost whether or not anyone calls you.
The surprise is rarely the request fee. It is data transfer out, billed at normal EC2 transfer rates (the page's examples use $0.09/GB), plus the CloudWatch Logs ingestion from access logs you turned on and forgot. At 100 million requests a month the HTTP API fee is about $100. A chatty JSON response of 20 KB at that volume moves roughly 2 TB, and the transfer line will be larger than the gateway line.
Free tier: new accounts get 1 million HTTP API calls, 1 million REST API calls, 1 million WebSocket messages and 750,000 connection minutes per month for 12 months. For accounts created from July 15, 2025 the page describes a credit-based free plan instead. Do not assume the old 12-month rule applies to your account.
The limits that bite
Four limits matter in practice, and they are different per API type.
The account throttle is shared. By default each Region in your account gets 10,000 requests per second across all HTTP, REST, WebSocket and WebSocket callback APIs together, backed by a token bucket with a maximum of 5,000 burst tokens. The default is 2,500 RPS and 1,250 burst in a list of newer Regions that includes Milan, Jakarta, Spain, Zurich and others. The RPS is adjustable through Service Quotas (quota L-8A5B8E43), but the burst is set by the service team and you cannot change it. The practical consequence: one noisy API can throttle every other API in the same account and Region. Put production and experiments in different accounts.
Throttling is a best-effort target. The docs say so plainly: throttles are targets, not guaranteed ceilings, and occasional overruns happen. Per-route settings let you cap a route lower than the account limit but never higher. Do not use API Gateway throttling as your billing-grade rate limiter. It protects your backend from a flood; it does not meter a customer.
HTTP API hard limits: 300 routes per API (adjustable), 300 integrations per API (not adjustable), 10 stages (adjustable), 10 authorizers per API, 10 scopes per route, and a 1,500 ms timeout when API Gateway fetches your identity provider's JWKS and OIDC discovery documents. If your issuer is slow or behind a firewall, authorization fails and the error will not mention that.
REST API timeouts: since June 2024 you can request an integration timeout above the old 29 seconds for Regional and private REST APIs, but the announcement warns it might cost you account-level throttle quota. HTTP APIs stay at 30 seconds.
Build it
We will build an HTTP API in front of one Lambda function, protected by a JWT authorizer backed by an Amazon Cognito user pool, with per-route throttling and JSON access logs. Then we will call it three ways and read the results.
Prerequisites and IAM
You need an AWS account, Python 3.10+, boto3 installed and credentials for a user or role that can create the resources below. For a sandbox account, AdministratorAccess is the honest answer. For something tighter, the caller needs at least these actions: iam:CreateRole, iam:AttachRolePolicy, iam:PassRole, lambda:CreateFunction, lambda:AddPermission, cognito-idp:* on user pools, apigateway:* (the apigatewayv2 API uses this permission prefix), and the CloudWatch Logs actions from the access logging page, including logs:CreateLogDelivery and logs:PutResourcePolicy. Cost of following along: a few thousand requests at $1.00 per million is a fraction of a cent. Cognito and Lambda usage at this scale sits inside their free allowances, and log storage for a few kilobytes is negligible. Delete everything at the end anyway.
I wrote this against the current docs and did not execute it end to end against a live account in this run. Treat the first run as a test and read the output.
Step 1: the function and its role
# setup.py (pip install boto3)
import boto3, io, json, time, zipfile
R = "us-east-1"
iam, lam = boto3.client("iam"), boto3.client("lambda", region_name=R)
cog = boto3.client("cognito-idp", region_name=R)
apigw = boto3.client("apigatewayv2", region_name=R)
logs = boto3.client("logs", region_name=R)
acct = boto3.client("sts").get_caller_identity()["Account"]
S = {} # state we save for cleanup
role = iam.create_role(RoleName="sl149-lambda", AssumeRolePolicyDocument=json.dumps({
"Version": "2012-10-17", "Statement": [{"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"}, "Action": "sts:AssumeRole"}]}))
iam.attach_role_policy(RoleName="sl149-lambda",
PolicyArn="arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole")
S["role"] = "sl149-lambda"
time.sleep(10) # IAM propagation
CODE = '''
import json
def handler(event, context):
claims = event.get("requestContext", {}).get("authorizer", {}).get("jwt", {}).get("claims", {})
return {"statusCode": 200, "headers": {"content-type": "application/json"},
"body": json.dumps({"route": event["routeKey"], "path": event["rawPath"],
"sub": claims.get("sub"), "client_id": claims.get("client_id")})}
'''
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr("app.py", CODE)
fn = lam.create_function(FunctionName="sl149-fn", Runtime="python3.13",
Role=role["Role"]["Arn"], Handler="app.handler", Code={"ZipFile": buf.getvalue()})
S["fn"] = "sl149-fn"
Check the supported Lambda runtime list before you run this; python3.13 was current when I wrote it and runtimes deprecate on a schedule. Notice what the function reads: in payload format 2.0 the validated token claims arrive at requestContext.authorizer.jwt.claims, so your code never parses a token or touches a public key.
Step 2: the identity provider
pool = cog.create_user_pool(PoolName="sl149-pool")["UserPool"]
client = cog.create_user_pool_client(UserPoolId=pool["Id"], ClientName="sl149-app",
GenerateSecret=False, ExplicitAuthFlows=["ALLOW_USER_PASSWORD_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"]
)["UserPoolClient"]
cog.admin_create_user(UserPoolId=pool["Id"], Username="demo", MessageAction="SUPPRESS")
cog.admin_set_user_password(UserPoolId=pool["Id"], Username="demo",
Password="Sl149-demo-Passw0rd!", Permanent=True)
S.update(pool=pool["Id"], client=client["ClientId"])
ISSUER = f"https://cognito-idp.{R}.amazonaws.com/{pool['Id']}"
The issuer URL format comes straight from the JWT authorizer docs. API Gateway will append the standard discovery path, fetch the signing keys from the issuer's jwks_uri, and cache them for up to two hours on a best-effort basis. That cache is the thing to remember when you rotate keys: leave a grace period.
api = apigw.create_api(Name="sl149-api", ProtocolType="HTTP")
S["api"] = api["ApiId"]
integ = apigw.create_integration(ApiId=api["ApiId"], IntegrationType="AWS_PROXY",
IntegrationUri=fn["FunctionArn"], PayloadFormatVersion="2.0")
auth = apigw.create_authorizer(ApiId=api["ApiId"], Name="cognito-jwt", AuthorizerType="JWT",
IdentitySource=["$request.header.Authorization"],
JwtConfiguration={"Issuer": ISSUER, "Audience": [client["ClientId"]]})
target = f"integrations/{integ['IntegrationId']}"
apigw.create_route(ApiId=api["ApiId"], RouteKey="GET /health", Target=target)
for rk in ["GET /me", "GET /orders/{id}"]:
apigw.create_route(ApiId=api["ApiId"], RouteKey=rk, Target=target,
AuthorizationType="JWT", AuthorizerId=auth["AuthorizerId"])
lam.add_permission(FunctionName="sl149-fn", StatementId="apigw-invoke",
Action="lambda:InvokeFunction", Principal="apigateway.amazonaws.com",
SourceArn=f"arn:aws:execute-api:{R}:{acct}:{api['ApiId']}/*/*")
Two details deserve attention. First, the add_permission call. The console wires it for you; the CLI, CloudFormation and SDKs do not, and forgetting it is the classic cause of {"message":"Internal Server Error"}. Second, the audience. A Cognito access token carries client_id rather than aud, and API Gateway only checks client_id when aud is absent, so listing the app client ID as the audience works for both token types. That also means an ID token will pass this authorizer. The docs say there is no standard way to tell access tokens from ID tokens, and recommend requiring scopes on routes if you only want access tokens.
Step 4: stage, logs and throttling
logs.create_log_group(logGroupName="/sl149/access")
lg = f"arn:aws:logs:{R}:{acct}:log-group:/sl149/access"
fmt = json.dumps({"requestId": "$context.requestId", "ip": "$context.identity.sourceIp",
"route": "$context.routeKey", "status": "$context.status",
"authError": "$context.authorizer.error", "integErr": "$context.integrationErrorMessage",
"errType": "$context.error.responseType"})
apigw.create_stage(ApiId=api["ApiId"], StageName="$default", AutoDeploy=True,
AccessLogSettings={"DestinationArn": lg, "Format": fmt},
RouteSettings={"GET /orders/{id}": {"ThrottlingRateLimit": 1.0, "ThrottlingBurstLimit": 2}})
S["log"] = "/sl149/access"
S["url"] = api["ApiEndpoint"]
json.dump(S, open("state.json", "w"))
print(S["url"], client["ClientId"])
The $default stage with auto-deploy means route and integration changes go live without a separate deployment step. The route setting gives GET /orders/{id} a steady state of 1 request per second and a burst bucket of 2, deliberately tiny so we can trip it. The log format uses $context variables from the logging docs; integrationErrorMessage is the variable the Lambda troubleshooting page tells you to add when you see a 500.
Verify it works
# verify.py
import json, time, urllib.request, urllib.error, boto3
S = json.load(open("state.json"))
cog = boto3.client("cognito-idp", region_name="us-east-1")
tok = cog.initiate_auth(ClientId=S["client"], AuthFlow="USER_PASSWORD_AUTH",
AuthParameters={"USERNAME": "demo", "PASSWORD": "Sl149-demo-Passw0rd!"}
)["AuthenticationResult"]["AccessToken"]
def call(path, token=None):
req = urllib.request.Request(S["url"] + path)
if token: req.add_header("Authorization", "Bearer " + token)
try:
r = urllib.request.urlopen(req); return r.status, r.read().decode()[:120]
except urllib.error.HTTPError as e:
return e.code, e.read().decode()[:120] + " | " + str(e.headers.get("www-authenticate"))
print("public ", call("/health"))
print("no token", call("/me"))
print("good ", call("/me", tok))
print("bad aud ", call("/me", "not-a-token"))
codes = [call("/orders/1", tok)[0] for _ in range(20)]
print("burst ", codes)
Expected shape of the output: /health returns 200 with no credentials. /me without a token returns 401 with {"message":"Unauthorized"}. /me with the access token returns 200 and a body containing your sub and the app client ID. A garbage token returns 401, and the www-authenticate header carries an invalid_token error with a description, the same mechanism the AWS JWT troubleshooting page documents for audience mismatches. The twenty rapid calls to /orders/1 should return a few 200s followed by a run of 429s, since we set a rate of 1 per second and burst of 2. Because the docs describe throttles as best-effort, expect the exact count of 200s to vary between runs, and do not write a test that asserts it.
Then read /sl149/access in CloudWatch Logs. Every request, including the rejected ones, should appear with its route, status and error type. If the 401s are missing from your logs, check that the stage has the log destination and that the log delivery permissions listed on the logging page were present when you created the stage.
When it breaks
{"message":"Internal Server Error"} on a Lambda-backed route. The AWS troubleshooting page lists three causes in order: API Gateway lacks permission to invoke the function, the function returned a response in the wrong format, or the function code itself failed. Add $context.integrationErrorMessage to the access log (we already did), call again and read it. For the first cause the fix is the add_permission call, scoped as tightly as api-id/stage/*/route if you want.
401 {"message":"Unauthorized"} with a valid-looking token. Read the www-authenticate header. The documented example is error="invalid_token" with "the token does not have a valid audience". Decode the token and compare iss, aud (or client_id) and scopes against the authorizer. A wrong issuer, an expired exp, or a token from a different app client all end up here. Remember that if aud is present API Gateway ignores client_id.
{"message":"Not Found"} on a path that should exist. Either the route key does not match the method and path exactly, or there is no $default stage and you called a URL without the stage name. If you get Not Found because the stage is missing, no CloudWatch log entry is generated, which makes it look like the request never arrived.
429 when traffic is nowhere near your route limit. The account-level throttle is shared across every API in the Region. Check whether another API in the same account is consuming the 10,000 RPS, and use Service Quotas if you need a higher steady-state rate.
401 only for new users after an identity provider change. API Gateway caches the signing keys for up to two hours. A key rotation with no overlap will fail validation until the cache refreshes.
Cleanup
import json, boto3
S = json.load(open("state.json")); R = "us-east-1"
boto3.client("apigatewayv2", region_name=R).delete_api(ApiId=S["api"])
boto3.client("lambda", region_name=R).delete_function(FunctionName=S["fn"])
boto3.client("cognito-idp", region_name=R).delete_user_pool(UserPoolId=S["pool"])
boto3.client("logs", region_name=R).delete_log_group(logGroupName=S["log"])
iam = boto3.client("iam")
iam.detach_role_policy(RoleName=S["role"],
PolicyArn="arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole")
iam.delete_role(RoleName=S["role"])
Deleting the API removes its routes, stage and authorizer. If you deleted the Cognito pool first, nothing breaks, but you lose the ability to mint tokens for a final test. Confirm with aws apigatewayv2 get-apis, aws lambda list-functions and aws cognito-idp list-user-pools --max-results 20 that nothing named sl149 remains. After that the bill for this exercise is whatever a few thousand requests cost, which is a fraction of a cent.

