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.

Diagram: client calls API Gateway with CORS, throttling, JWT and two routes, behind it Lambda and CloudWatch Logs

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-serverless project with esbuild and @types/aws-lambda, as set up in part 1
  • A bootstrapped account (npx cdk bootstrap from part 1)
  • curl in 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:

  1. NodejsFunction builds the new function with esbuild, just like in part 1, with its own log group and seven days of retention.
  2. HttpApi creates the API. corsPreflight allows browser calls from http://localhost:5173, the default port of the Vite development server. Later, put the address of your real frontend here.
  3. createDefaultStage: false switches off the automatically created stage. Instead, we create the $default stage ourselves with addStage, because that is where we want to configure throttling and access logs.
  4. throttle limits 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.
  5. accessLogSettings writes one JSON line per request with route, status and latency into a separate log group.
  6. HttpLambdaIntegration connects 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 /hello path.
  7. 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...
Source:https://dev.to/sascha-ms/http-api-with-api-gateway-and-lambda-in-cdk-1cnd