An HTTP API with API Gateway is, for me, the moment a Lambda function turns into a real service for the first time. In part 1 you still called your function with aws lambda invoke. That is great for learning, but no browser, no frontend and no colleague with curl can reach your code that way. For that you need a URL, routes and a few rules about who may knock and how often.
That is exactly what we build today, again entirely as code with the AWS CDK and TypeScript. By the end, your project has a public HTTP address with two routes, a function that handles errors cleanly, CORS for your frontend, throttling against load spikes, and access logs. As a bonus, I show you how to protect a route with a JWT authorizer once you have a sign-in in place.
In short
An HTTP API in API Gateway makes your Lambda function reachable via URL and takes care of routing, CORS, throttling and authentication. With the CDK you describe all of it in TypeScript, test it locally and deploy it with one command. The most common pitfall is a function that crashes on broken input instead of answering with a clear status code.
What you will learn in this article
- When an HTTP API, a REST API or a function URL is the right fit
- How to write a Lambda handler for payload format 2.0 with clean error codes
- How to define HTTP API, routes, CORS, throttling and access logs in the CDK
- How to test the API with curl and spot typical errors quickly
- How to secure a route with a JWT authorizer when you need to
What an HTTP API with API Gateway does
Amazon API Gateway is the gatekeeper in front of your code. It accepts HTTP requests from the internet, decides where they go based on method and path, and returns the response to the caller. An HTTP API is the lean, cheaper flavor of the service. Next to it there is the older REST API with more features, plus WebSocket APIs for long-lived connections.
Four terms will stay with you throughout this article. A route combines an HTTP method and a path, for example GET /hello. An integration describes what happens behind the route; in our case it invokes a Lambda function. A stage is a published version of the API with its own address and its own settings. The stage called $default sits directly on the base URL, without an extra path segment. Finally, the payload format defines what the event your function receives looks like. We use version 2.0, which is more compact than 1.0 and the CDK default for Lambda integrations.
HTTP API or REST API?
This question comes up in almost every team, so here is the short version of the official AWS comparison. The REST API can do a lot more, while the HTTP API is leaner and costs less.
| Feature | HTTP API | REST API |
|---|---|---|
| Built-in JWT authorizer | yes | no (only via Lambda authorizer) |
| IAM and Lambda authorizers | yes | yes |
| API keys and usage plans per customer | no | yes |
| AWS WAF directly on the API | no | yes |
| Caching, request validation | no | yes |
| Private endpoints (reachable only inside a VPC) | no | yes |
| X-Ray tracing, execution logs | no | yes |
| Automatic stage deployments | yes | no |
My rule of thumb: if you don't specifically need any of these REST advantages, go with the HTTP API. If you need API keys for external customers, a web application firewall directly on the API or a private endpoint, there is no way around the REST API. For most internal services, frontends and webhooks, the HTTP API is more than enough.
And why not just a function URL?
Lambda can provide a URL of its own, the so-called function URL. It is even simpler, but it has neither routes nor built-in throttling per route. You can only secure it with IAM or not at all, and you limit the rate indirectly through the function's reserved concurrency. For a single webhook, that is perfectly fine. Once you need several endpoints, CORS rules for a frontend or token-based sign-in, API Gateway is the better choice. If you want to ship an entire Express app, my article on Lambda Web Functions with CDK and CloudFront shows a third option.
The architecture from this article: API Gateway checks and routes, the function responds, both write logs.
Prerequisites
You need the project from part 1 or a fresh CDK project in TypeScript. Specifically:
- Node.js 22 or newer and an AWS account with a configured AWS CLI (profile with a region)
- The
hello-serverlessproject with esbuild and@types/aws-lambda, as set up in part 1 - A bootstrapped account (
npx cdk bootstrapfrom part 1) -
curlin your terminal; on Windows, Git Bash or WSL work well
You don't need to install any new packages. The building blocks for API Gateway already ship with aws-cdk-lib: aws-apigatewayv2 for API and stage, aws-apigatewayv2-integrations for the Lambda connection and aws-apigatewayv2-authorizers for authentication.
HTTP API with API Gateway step by step
Step 1: Write the handler for HTTP requests
The function from part 1 received a simple object with a name. Behind API Gateway the event looks different: it contains the route as routeKey, headers, query parameters, the body as text and a context with a request ID. Your response has to deliver a status code, headers and a body as a string. That is why you create a new file. This way the function from part 1 stays unchanged.
// lambda/api.ts
import type {
APIGatewayProxyEventV2,
APIGatewayProxyStructuredResultV2,
} from 'aws-lambda';
const MAX_NAME_LENGTH = 50;
// Helper: JSON response with status code and matching content type
const json = (
statusCode: number,
body: unknown,
): APIGatewayProxyStructuredResultV2 => ({
statusCode,
headers: { 'content-type': 'application/json; charset=utf-8' },
body: JSON.stringify(body),
});
// Validates the name and returns either the cleaned value or an error message
const validateName = (
raw: unknown,
): { ok: true; name: string } | { ok: false; error: string } => {
if (raw === undefined || raw === null || raw === '') {
return { ok: true, name: 'serverless world' };
}
if (typeof raw !== 'string') {
return { ok: false, error: 'name must be a string' };
}
const name = raw.trim();
if (name.length > MAX_NAME_LENGTH) {
return { ok: false, error: `name must not exceed ${MAX_NAME_LENGTH} characters` };
}
return { ok: true, name: name || 'serverless world' };
};
// Reads the body, even if API Gateway passes it on base64-encoded
const readBody = (event: APIGatewayProxyEventV2): string => {
if (!event.body) return '';
return event.isBase64Encoded
? Buffer.from(event.body, 'base64').toString('utf8')
: event.body;
};
export const handler = async (
event: APIGatewayProxyEventV2,
): Promise<APIGatewayProxyStructuredResultV2> => {
const requestId = event.requestContext.requestId;
console.log(
JSON.stringify({ level: 'info', msg: 'request', route: event.routeKey, requestId }),
);
switch (event.routeKey) {
case 'GET /hello': {
const result = validateName(event.queryStringParameters?.name);
if (!result.ok) return json(400, { error: result.error });
return json(200, { message: `Hello, ${result.name}!`, requestId });
}
case 'POST /hello': {
let data: unknown;
try {
data = JSON.parse(readBody(event) || '{}');
} catch {
return json(400, { error: 'body is not valid JSON' });
}
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
return json(400, { error: 'body must be a JSON object' });
}
const result = validateName((data as { name?: unknown }).name);
if (!result.ok) return json(400, { error: result.error });
return json(201, { message: `Hello, ${result.name}! Good to have you here.`, requestId });
}
default:
return json(404, { error: `Unknown route: ${event.routeKey}` });
}
};
A few decisions in there are deliberate. The handler validates the name itself and answers nonsense with a 400 instead of crashing. A crash would reach the caller as 500 Internal Server Error, and nobody would know what went wrong. In addition, the function reads the body even when API Gateway passes it on base64-encoded. That happens, for example, with content that API Gateway does not recognize as text. Unknown routes get a friendly 404.
One detail about format 2.0: if your function returns valid JSON without a statusCode, API Gateway automatically turns it into a 200 response with application/json. That is convenient, but for error codes and custom headers you need the explicit form shown above.
Step 2: Describe API, routes and stage in the stack
Now for the actual infrastructure part. Replace lib/hello-serverless-stack.ts with this version. The part from the first article stays in, and everything under “Part 2” is new.
// lib/hello-serverless-stack.ts
import * as path from 'node:path';
import * as cdk from 'aws-cdk-lib/core';
import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
import { AccessLogFormat } from 'aws-cdk-lib/aws-apigateway';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import { HttpJwtAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as logs from 'aws-cdk-lib/aws-logs';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Construct } from 'constructs';
export class HelloServerlessStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// --- Part 1: function for direct invocation (unchanged) ---
const logGroup = new logs.LogGroup(this, 'HelloLogs', {
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
const helloFunction = new NodejsFunction(this, 'HelloFunction', {
entry: path.join(__dirname, '..', 'lambda', 'hello.ts'),
handler: 'handler',
runtime: lambda.Runtime.NODEJS_24_X,
architecture: lambda.Architecture.ARM_64,
memorySize: 256,
timeout: cdk.Duration.seconds(10),
logGroup,
description: 'Serverless from scratch, part 1: hello function',
bundling: { minify: true },
});
// --- Part 2: function behind the HTTP API ---
const apiFunctionLogs = new logs.LogGroup(this, 'ApiFunctionLogs', {
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
const apiFunction = new NodejsFunction(this, 'ApiFunction', {
entry: path.join(__dirname, '..', 'lambda', 'api.ts'),
handler: 'handler',
runtime: lambda.Runtime.NODEJS_24_X,
architecture: lambda.Architecture.ARM_64,
memorySize: 256,
timeout: cdk.Duration.seconds(10),
logGroup: apiFunctionLogs,
description: 'Serverless from scratch, part 2: function behind the HTTP API',
bundling: { minify: true },
});
// The HTTP API itself; we deliberately create the stage ourselves below
const api = new apigwv2.HttpApi(this, 'HelloApi', {
apiName: 'hello-api',
description: 'Serverless from scratch, part 2',
createDefaultStage: false,
corsPreflight: {
allowOrigins: ['http://localhost:5173'],
allowMethods: [apigwv2.CorsHttpMethod.GET, apigwv2.CorsHttpMethod.POST],
allowHeaders: ['content-type', 'authorization'],
maxAge: cdk.Duration.hours(1),
},
});
// API access log: one line of JSON per request
const accessLogs = new logs.LogGroup(this, 'ApiAccessLogs', {
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
const stage = api.addStage('DefaultStage', {
stageName: '$default',
autoDeploy: true,
// Protection against load spikes and cost traps: at most 10 requests/s, briefly up to 20
throttle: { rateLimit: 10, burstLimit: 20 },
accessLogSettings: {
destination: new apigwv2.LogGroupLogDestination(accessLogs),
format: AccessLogFormat.custom(
JSON.stringify({
requestId: '$context.requestId',
time: '$context.requestTime',
ip: '$context.identity.sourceIp',
route: '$context.routeKey',
status: '$context.status',
latencyMs: '$context.responseLatency',
error: '$context.integrationErrorMessage',
}),
),
},
});
const integration = new HttpLambdaIntegration('ApiIntegration', apiFunction);
// Optional: POST only with a valid JWT if an issuer is set via context
const jwtIssuer = this.node.tryGetContext('jwtIssuer') as string | undefined;
const jwtAudience = this.node.tryGetContext('jwtAudience') as string | undefined;
const postAuthorizer =
jwtIssuer && jwtAudience
? new HttpJwtAuthorizer('JwtAuthorizer', jwtIssuer, { jwtAudience: [jwtAudience] })
: undefined;
api.addRoutes({
path: '/hello',
methods: [apigwv2.HttpMethod.GET],
integration,
});
api.addRoutes({
path: '/hello',
methods: [apigwv2.HttpMethod.POST],
integration,
authorizer: postAuthorizer,
});
new cdk.CfnOutput(this, 'FunctionName', {
value: helloFunction.functionName,
description: 'Name of the Lambda function for aws lambda invoke',
});
new cdk.CfnOutput(this, 'LogGroupName', {
value: logGroup.logGroupName,
description: 'Log group for aws logs tail',
});
new cdk.CfnOutput(this, 'ApiUrl', {
value: stage.url,
description: 'Base URL of the HTTP API',
});
new cdk.CfnOutput(this, 'ApiAccessLogGroup', {
value: accessLogs.logGroupName,
description: 'Log group with the API access logs',
});
}
}
Here is what happens, in plain words:
-
NodejsFunctionbuilds the new function with esbuild, just like in part 1, with its own log group and seven days of retention. -
HttpApicreates the API.corsPreflightallows browser calls fromhttp://localhost:5173, the default port of the Vite development server. Later, put the address of your real frontend here. -
createDefaultStage: falseswitches off the automatically created stage. Instead, we create the$defaultstage ourselves withaddStage, because that is where we want to configure throttling and access logs. -
throttlelimits the stage to an average of 10 requests per second with short bursts up to 20. For a learning project that is deliberately tight. It protects you from a script in an endless loop driving up your bill. -
accessLogSettingswrites one JSON line per request with route, status and latency into a separate log group. -
HttpLambdaIntegrationconnects both routes to the function. In the process, the CDK automatically creates the permission that allows API Gateway to invoke the function, and only for the/hellopath. - The optional JWT authorizer only becomes active if you pass an issuer and an audience at deploy time. More on that below.
Step 3: Write and run the tests
Before anything goes to the cloud, we check handler and stack locally. The handler tests rebuild minimal events in format 2.0.
// test/api.test.ts
import type { APIGatewayProxyEventV2 } from 'aws-lambda';
import { handler } from '../lambda/api';
// Builds a minimal event in payload format 2.0
const event = (
routeKey: string,
extra: Partial<APIGatewayProxyEventV2> = {},
): APIGatewayProxyEventV2 =>
({
version: '2.0',
routeKey,
rawPath: '/hello',
rawQueryString: '',
headers: {},
isBase64Encoded: false,
requestContext: { requestId: 'req-1' },
...extra,
}) as unknown as APIGatewayProxyEventV2;
const body = (response: { body?: string }) => JSON.parse(response.body ?? '{}');
test('GET greets the name from the query string', async () => {
const response = await handler(
event('GET /hello', { queryStringParameters: { name: 'Sascha' } }),
);
expect(response.statusCode).toBe(200);
expect(body(response).message).toBe('Hello, Sascha!');
});
test('POST with JSON body returns 201', async () => {
const response = await handler(
event('POST /hello', { body: JSON.stringify({ name: 'Team' }) }),
);
expect(response.statusCode).toBe(201);
expect(body(response).message).toMatch(/^Hello, Team!/);
});
test('POST with base64-encoded body works too', async () => {
const response = await handler(
event('POST /hello', {
body: Buffer.from('{"name":"Base64"}').toString('base64'),
isBase64Encoded: true,
}),
);
expect(response.statusCode).toBe(201);
});
test('broken JSON leads to 400 instead of 500', async () => {
const response = await handler(event('POST /hello', { body: '{name:' }));
expect(response.statusCode).toBe(400);
expect(body(response).error).toMatch(/JSON/);
});
test('a name that is too long is rejected', async () => {
const response = await handler(
event('GET /hello', { queryStringParameters: { name: 'x'.repeat(51) } }),
);
expect(response.statusCode).toBe(400);
});
test('unknown route returns 404', async () => {
const response = await handler(event('DELETE /hello'));
expect(response.statusCode).toBe(404);
});
The new stack test in test/api-stack.test.ts checks the generated CloudFormation template. Simply keep the test from part 1. This way you notice right away if someone accidentally removes the throttling or takes the authorizer off the POST endpoint.
// test/api-stack.test.ts
import * as cdk from 'aws-cdk-lib/core';
import { Match, Template } from 'aws-cdk-lib/assertions';
import { HelloServerlessStack } from '../lib/hello-serverless-stack';
const template = (context: Record<string, string> = {}) => {
const app = new cdk.App({ context });
return Template.fromStack(new HelloServerlessStack(app, 'TestStack'));
};
test('HTTP API with two routes and CORS', () => {
const t = template();
t.hasResourceProperties('AWS::ApiGatewayV2::Api', {
ProtocolType: 'HTTP',
CorsConfiguration: Match.objectLike({ AllowOrigins: ['http://localhost:5173'] }),
});
t.hasResourceProperties('AWS::ApiGatewayV2::Route', { RouteKey: 'GET /hello' });
t.hasResourceProperties('AWS::ApiGatewayV2::Route', { RouteKey: 'POST /hello' });
});
test('stage throttles and writes access logs', () => {
template().hasResourceProperties('AWS::ApiGatewayV2::Stage', {
StageName: '$default',
AutoDeploy: true,
DefaultRouteSettings: { ThrottlingRateLimit: 10, ThrottlingBurstLimit: 20 },
AccessLogSettings: Match.objectLike({ DestinationArn: Match.anyValue() }),
});
});
test('without context there is no authorizer', () => {
template().resourceCountIs('AWS::ApiGatewayV2::Authorizer', 0);
});
test('with jwtIssuer and jwtAudience, POST is protected', () => {
const t = template({
jwtIssuer: 'https://issuer.example.com',
jwtAudience: 'my-client-id',
});
t.hasResourceProperties('AWS::ApiGatewayV2::Route', {
RouteKey: 'POST /hello',
AuthorizationType: 'JWT',
});
t.hasResourceProperties('AWS::ApiGatewayV2::Route', {
RouteKey: 'GET /hello',
AuthorizationType: 'NONE',
});
});
npx tsc --noEmit
npx jest
Expected result: all four test files pass; together with the tests from part 1, that makes 13 tests. If the stack test fails, it is almost always a typo in RouteKey or StageName.
Step 4: Synthesize and deploy
npx cdk diff
npx cdk deploy
cdk diff shows you upfront what will be created: one function, two log groups, the API, a stage, an integration, two routes and two Lambda permissions. Because the CDK creates new IAM permissions, cdk deploy asks for confirmation once. At the end, the outputs appear in the terminal. The output looks roughly like this (IDs and region are example values):
✅ HelloServerlessStack
Outputs:
HelloServerlessStack.ApiAccessLogGroup = HelloServerlessStack-ApiAccessLogs1A2B3C4D-AbCdEf
HelloServerlessStack.ApiUrl = https://a1b2c3d4e5.execute-api.eu-central-1.amazonaws.com/
HelloServerlessStack.FunctionName = HelloServerlessStack-HelloFunctionD909AE8C-XyZ123
HelloServerlessStack.LogGroupName = HelloServerlessStack-HelloLogsEE29EE27-IjKlMnO
Step 5: Call the API with curl
Put the URL into a shell variable so you don't have to type it. The URL already ends with a slash.
API_URL=$(aws cloudformation describe-stacks --stack-name HelloServerlessStack \
--query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" --output text)
curl -i "${API_URL}hello?name=Sascha"
You should see status code 200 and a JSON body with "message":"Hello, Sascha!". Now the POST endpoint, once correctly and once with broken JSON:
curl -i -X POST "${API_URL}hello" \
-H 'content-type: application/json' \
-d '{"name":"Team"}'
curl -i -X POST "${API_URL}hello" \
-H 'content-type: application/json' \
-d '{name:'
The first call returns 201, the second 400 with the message “body is not valid JSON”. By the way, API Gateway answers a route that does not exist on its own, without starting your function: curl -i "${API_URL}doesnotexist" returns 404 with {"message":"Not Found"}.
🎬 How a request flows through the HTTP API – this animated diagram only runs on my site: watch it on sascha-ms.de
Step 6: Check CORS
Before many calls to a different domain, browsers send an advance request with the OPTIONS method, the so-called preflight. API Gateway answers it for you as soon as CORS is configured, even without an OPTIONS route of your own. You can reproduce it with curl:
curl -i -X OPTIONS "${API_URL}hello" \
-H 'Origin: http://localhost:5173' \
-H 'Access-Control-Request-Method: POST'
The response contains access-control-allow-origin: http://localhost:5173 and the allowed methods. Try the same call with a different origin: those headers are then missing, and a browser would block the call. One important point: if CORS is configured on the API, API Gateway ignores CORS headers that your function sets itself. So maintain the rules in exactly one place, the stack.
Step 7: Try out the throttling
With 60 parallel requests you clearly exceed the limit from the stack. The following command counts which status codes come back:
(for i in $(seq 1 60); do
curl -s -o /dev/null -w "%{http_code}\n" "${API_URL}hello" &
done; wait) | sort | uniq -c
Some of the responses are 200, the rest 429 Too Many Requests. The exact split varies from run to run. AWS explicitly describes throttling as a best-effort target rather than a hard ceiling. For clients this means: on a 429, wait briefly and retry with increasing delays.
Step 8: Read access logs and function logs
The access logs show every request from API Gateway's point of view, including those that never reached your function, such as throttled requests or unknown routes.
aws logs tail <value of ApiAccessLogGroup> --since 15m
A line looks roughly like this: {"requestId":"…","route":"GET /hello","status":"429","latencyMs":"…"}. You find the logs of the function itself in the log group that starts with ApiFunctionLogs. There, each invocation has the line with route and request ID from the handler. Using the request ID, you can match an error in the access log to the corresponding entry of your function.
Which protection fits when?
So far our API is public, slowed down only by the throttling. For a greeting function that is fine, for anything with real data it is not. An HTTP API gives you three ways to check callers before your function even starts.
| Option | Suitable for | What you need |
|---|---|---|
| JWT authorizer | Frontends and apps with user sign-in | An identity provider such as Amazon Cognito, Entra ID, Auth0 or Keycloak |
| IAM authorization | Calls between AWS services and scripts | Signed requests (SigV4) and matching IAM permissions |
| Lambda authorizer | Custom checks, such as your own tokens or headers | An additional function that says yes or no |
Enable the JWT authorizer for the POST route
The stack is already prepared. If you pass an issuer and an audience at deploy time, a JWT authorizer protects the POST route while GET stays public. For a Cognito user pool, it looks like this (pool ID and client ID are placeholders):
npx cdk deploy \
-c jwtIssuer=https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_AbCdEfGhI \
-c jwtAudience=your-app-client-id
API Gateway then fetches the issuer's public keys, checks signature, expiry, issuer and audience of the token from the Authorization header, and lets only valid requests through. Without a valid token, API Gateway rejects the request before your function runs, typically with 401 Unauthorized. The verified claims, meaning the statements inside the token, end up in your handler under event.requestContext.authorizer.jwt.claims. Two notes from the documentation are worth knowing: only RSA-signed tokens are supported, and the keys are cached for up to two hours. So when you rotate keys, plan a transition period in which both the old and the new key are valid.
A word of caution when combining this with CORS: if you put an authorizer on a catch-all route such as $default, the OPTIONS requests land there too and fail without a token. AWS recommends a separate OPTIONS /{proxy+} route without an authorizer for this. With our explicit routes, the problem does not occur.
Typical errors with HTTP API and Lambda
The browser reports a CORS error, but curl works
Almost always the origin does not match exactly. For the browser, http://localhost:5173 and http://127.0.0.1:5173 are different origins, and so is an address with and without a trailing slash. Also check whether your frontend sends headers such as authorization that are not listed in allowHeaders.
The API answers with 500 Internal Server Error
Then your function crashed or returned a response in the wrong format, for example a body as an object instead of a string. Look at the function logs. The handler from this article catches broken JSON, but an extension you write yourself might not yet.
Every call returns 404 Not Found
The route does not exist the way you call it. Routes are case-sensitive, and POST /hello is different from GET /hello. In the access logs, such a request shows up with status 404 while your function logged nothing for it.
Long calls abort after 30 seconds
The integration timeout of an HTTP API is at most 30 seconds and cannot be raised. If a task takes longer, accept the job synchronously, respond with 202 Accepted and process the rest asynchronously, for instance through a queue. That is what the planned part 4 of this series is about.
Suddenly lots of 429 errors appear
Either your stage throttling kicks in, or the account limit has been reached. By default, all APIs in an account share 10,000 requests per second per region with a burst of 5,000, and some regions have lower limits. AWS can raise the account limit on request.
What does an HTTP API with API Gateway cost?
You pay per request. According to the AWS pricing page, the example calculations put an HTTP API at 1.00 US dollar per million requests for the first 300 million per month, after which it gets cheaper. Prices differ slightly by region, so check the pricing page for yours. Requests are metered in 512 KB increments: a request of 4.5 MB counts as nine requests. For an API with 100,000 calls per month you end up at around 10 cents plus Lambda costs. You can work out what the function behind it costs with my Lambda cost calculator.
About the free tier: the pricing page lists one million HTTP API calls per month for up to twelve months, for new AWS customers. Accounts created on or after July 15, 2025 receive Free Tier credits instead. You can see which variant applies to you in the Billing console. The access logs go to CloudWatch Logs and cost storage there, which is why the stack keeps them for seven days.
And when you are done: npx cdk destroy removes everything again, log groups included.
My take
I like the HTTP API because it leaves out the right things. With the REST API, you quickly end up configuring and paying for features you never use. The HTTP API makes you decide the few important things consciously: routes, CORS, throttling and authentication. For beginners, that is a gift.
I want to push back on the reflex of leaving the API open for now “because it's only a test”. Throttling costs you one line and protects your account and your budget. At the very least, that belongs in every API, including the first one. Just as clearly: if you need API keys for partners, a WAF or a private endpoint, don't try to rebuild that on top of the HTTP API. In that case the REST API is the right tool.
One more thing that matters to me: your function is part of the API. If it crashes with a stack trace on unexpected input, the API is broken, no matter how nice the stack looks. Validating input and returning clear status codes are not extras.
FAQ
What is the difference between an HTTP API and a REST API in API Gateway?
The HTTP API is leaner and cheaper and comes with a JWT authorizer. The REST API additionally offers API keys, usage plans, AWS WAF, caching, request validation and private endpoints. If you need none of those, the HTTP API is enough.
How long can a Lambda function behind an HTTP API run?
The integration of an HTTP API waits at most 30 seconds, and this value cannot be raised. Accept longer tasks, respond with 202 and continue processing them asynchronously.
Do I need my own OPTIONS route for CORS?
No. As soon as CORS is configured on the HTTP API, API Gateway answers preflight requests itself. You only need an OPTIONS /{proxy+} route if an authorizer on a catch-all route would otherwise block the preflights as well.
Which payload format should I use for Lambda?
Version 2.0 for new projects. It is more compact, the CDK uses it by default, and the types APIGatewayProxyEventV2 and APIGatewayProxyStructuredResultV2 from @types/aws-lambda match it. You mainly need version 1.0 for older functions coming from a REST API.
If you are wondering whether the REST API might be the better choice than the HTTP API, I compared features, prices and event formats in REST API vs HTTP API: Choosing API Gateway.
Your next step
Build a third route, for example GET /hello/{name} with a path parameter that you read from event.pathParameters in the handler. Write the test first, then the code, and watch the new route show up in the access logs. If you like, connect a small Vite frontend to your API afterwards and look at the preflight in your browser's developer tools.
I'm curious: do you start new APIs with an HTTP API, a REST API or a function URL these days, and why? Feel free to share it in the comments.
Next in the series: Go to the “Serverless from scratch” overview. Previous part: Deploy a Lambda function with CDK and TypeScript. Next part: DynamoDB with Lambda and CDK: Build a Guestbook API. If you want to protect your API against duplicate calls, idempotency in AWS Lambda has the right tools for you.
I show how to validate the request body of an API like this with a single schema and generate the OpenAPI documentation from it in Zod in Lambda: Validation, Types and OpenAPI From One Schema.
To protect access for partners and other systems without their own login, use API keys; I show how that works on an HTTP API in Lambda authorizer for HTTP APIs: validate API keys.
Sources
- Amazon API Gateway Developer Guide: Choose between REST APIs and HTTP APIs
- API Gateway: AWS Lambda proxy integrations for HTTP APIs (payload format 1.0 and 2.0)
- API Gateway: Configuring CORS for HTTP APIs
- API Gateway: Throttle requests to your HTTP APIs
- API Gateway: Control access to HTTP APIs with JWT authorizers
- API Gateway: Configure logging for HTTP APIs
- API Gateway: Quotas for HTTP APIs and Amazon API Gateway quotas
- Amazon API Gateway Pricing
- AWS Lambda Developer Guide: Creating and managing function URLs
- AWS CDK API Reference: aws-cdk-lib.aws_apigatewayv2
- Tested with: CDK CLI 2.1146.1, aws-cdk-lib 2.274.0, TypeScript 7.0.2, esbuild 0.28.2, Jest 30 (tsc, 13 tests, cdk synth with and without JWT context; deploy and curl output marked as examples)
This post was created with AI assistance.
👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)