DEV Community

Cover image for REST API vs HTTP API: Choosing API Gateway
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

REST API vs HTTP API: Choosing API Gateway

REST API vs HTTP API: you face this choice every time you put a Lambda function on the internet through API Gateway in AWS. The console shows both tiles side by side, the names sound almost identical, and many tutorials simply use whatever the author is used to. My position is clear: the HTTP API is my default. There are, however, a handful of reasons that make me reach for the REST API without hesitation, and in 2026 that list has grown rather than shrunk.

In this article you get a decision guide based on the current feature lists and prices from AWS. After that, you build the same small API once as an HTTP API and once as a REST API, with the Serverless Framework and with AWS CDK, and see the differences directly in code.

In short

The HTTP API costs less, is simpler and is enough for most Lambda backends with JWT sign-in. You need the REST API as soon as AWS WAF, API keys with quotas, private endpoints, caching, request validation or response streaming come into play. The biggest pitfall when switching is the different event format your Lambda function receives.

What you will learn in this article

  • Which features only the REST API has and which only the HTTP API has
  • What both options cost at 10 or 100 million requests per month
  • A five-question decision guide for your next project
  • A Lambda handler that works with payload 1.0 and 2.0
  • How to set up and test both APIs with serverless.yml and AWS CDK

How do REST API and HTTP API differ?

Amazon API Gateway is the managed front door for your backends: it accepts HTTP requests, checks them if you want it to, forwards them to Lambda, other AWS services or any HTTP endpoint, and returns the response. Under the API Gateway umbrella there are three products. The REST API is the older and more comprehensive option (internally “v1”). The HTTP API arrived later as a leaner, cheaper option (internally “v2”). I will leave the third product, the WebSocket API, aside here. I show how to use this forwarding to arbitrary HTTP endpoints to replace a legacy system route by route in the article on the strangler fig pattern with Lambda.

One common misunderstanding first: the names say nothing about architectural style. With either option you build perfectly normal REST interfaces with resources, verbs and status codes. At AWS, “REST API” is simply a product name. The difference lies in features, price and how a request reaches your function.

The HTTP API deliberately leaves out much of what the REST API has accumulated over the years. In return it costs less, can deploy changes automatically and comes with a JWT authorizer that validates tokens without any code of your own. The REST API, on the other hand, is closer to a complete API management product: keys, quotas, caching, validation, transformations, firewall and developer portal.

Feature comparison: what only the REST API can do

The following table summarizes the feature list from the API Gateway documentation. I kept only the rows where the two options differ and grouped them by topic.

Feature REST API HTTP API
Endpoint types Regional, edge-optimized, private Regional only
AWS WAF yes no
Resource policies yes no
JWT authorizer (e.g. Cognito, Auth0, Entra ID) no (only via Lambda authorizer) yes, built in
API keys, usage plans, per-client quotas yes no
Developer portal yes no
Caching yes no
Request validation against JSON Schema yes no
Body transformation (mapping templates) yes no
Canary deployments yes no
Automatic deployments no yes
Execution logs, X-Ray tracing yes no
Mock integrations yes no
Private integration via AWS Cloud Map no yes
Response streaming yes no

Both support IAM authorization, Lambda authorizers, mTLS, custom domains, CORS, access logs, CloudWatch metrics and private integrations through Network or Application Load Balancers. For a typical Lambda backend behind a single-page app, the right-hand column is therefore often all you need.

Response streaming: the new reason for the REST API

One point has shifted my view recently. The REST API can now stream responses. To enable it, you set the integration’s responseTransferMode to STREAM. According to the documentation, a stream may then run for up to 15 minutes; the first 10 MB pass through unthrottled, after which API Gateway limits the rate to 2 MB per second. Streaming works only with proxy integrations, meaning AWS_PROXY for Lambda and HTTP_PROXY, and you lose caching and mapping templates. For AI chats, where the first token should appear on screen quickly, that is a real argument. The HTTP API cannot do this.

The same goes for long-running requests: on the HTTP API every integration ends after 30 seconds at most, and that limit cannot be raised. For Regional and private REST APIs you can request more than 29 seconds through Service Quotas, though you may have to accept a lower throttling limit for your account in return. For long jobs I still prefer the job pattern with 202 and SQS.

What do REST API and HTTP API cost?

The price difference is significant. In the US East (N. Virginia) Region, the HTTP API costs 1.00 US dollar per million requests for the first 300 million per month. The REST API costs 3.50 US dollars per million for the first 333 million. Other Regions have their own prices, so always check the official pricing page for your Region.

Requests per month HTTP API REST API Difference
1 million $1.00 $3.50 $2.50
10 million $10.00 $35.00 $25.00
100 million $100.00 $350.00 $250.00

Two details are easy to miss. First, the HTTP API meters requests in 512 KB increments: a 4.5 MB upload is billed as nine requests. Second, on the REST API the cache costs extra, billed per hour whether it is used or not. The AWS pricing example lists 0.038 US dollars per hour for a 1.6 GB cache, roughly 28 US dollars per month. New accounts get one million calls per month for each option in the Free Tier for twelve months.

My take on the numbers: for small APIs, a few dollars’ difference should not drive the decision. From a few tens of millions of requests per month, however, the gap becomes noticeable, especially when the Lambda function behind it costs only cents. At that point you quickly pay more for the gateway than for the actual compute.

Decision guide: REST API vs HTTP API in five questions

For new projects I go through the following questions in order. As soon as one gets a “yes”, it is the REST API. If all of them get a “no”, I use the HTTP API.

  1. Do you need AWS WAF directly on the gateway or a private endpoint inside your VPC? Both exist only for the REST API. A CloudFront distribution with WAF in front of an HTTP API is an alternative, but the API itself then remains publicly reachable.
  2. Do you sell or hand out access with per-customer quotas? Usage plans with API keys, throttling and a monthly quota are REST only.
  3. Do you want to validate requests against a schema or cache responses at the gateway? That saves Lambda invocations, but only the REST API offers it.
  4. Do you have to stream responses or answer synchronously for longer than 30 seconds? Again, only the REST API helps.
  5. Do you need canary releases, execution logs or X-Ray directly on the gateway? REST once more.

For example, a backend for a React app with Cognito sign-in: five times “no”, so an HTTP API with a JWT authorizer. A partner interface where cafés preorder bread and each café gets its own quota: question 2 is “yes”, so a REST API. If you still want to stay with the HTTP API, you can check keys with a Lambda authorizer for HTTP APIs, but then you have to count quotas yourself.

Decision tree REST API vs HTTP API: five yes/no questions, any yes leads to REST API, otherwise HTTP API

Five questions: as soon as one is answered with yes, choose the REST API.

If you are not yet sure whether API Gateway is the right entry point at all or whether a Lambda function URL is enough, the AWS service finder helps you decide.

What happens to a request in REST API and HTTP API?

The feature set becomes more tangible when you follow the path of a request. With the REST API, a request can pass several stations before it reaches your function. With the HTTP API, the path is short.

🎬 Request path through REST API and HTTP API – this animated diagram only runs on my site: watch it on sascha-ms.de

Every station in the upper lane is optional. You pay for it with configuration complexity, but you save code in the function. A failed validation or a cache hit never reaches Lambda at all.

Step by step: the same API as HTTP API and REST API

Now it gets practical. We build a small bakery API with two routes: GET /breads/{slug} returns price and stock of a bread, POST /preorders accepts a preorder. A single Lambda function is attached to both API options. You would not do that in a real project, but here it shows very clearly how differently the two gateways package the same request.

Prerequisites

  • Node.js 22 and npm
  • An AWS account with credentials in your terminal (for deployment)
  • For option A the Serverless Framework v4 (npm i -g serverless), for option B the AWS CDK CLI (npm i -D aws-cdk); your account must be prepared for CDK once with cdk bootstrap
  • Basic Lambda knowledge; if that is new to you, start with building an HTTP API with API Gateway and Lambda in CDK

Step 1: Create the project

mkdir bakery-api && cd bakery-api
npm init -y
npm pkg set type=module
npm i -D typescript tsx @types/node@22 @types/aws-lambda esbuild
mkdir src test
Enter fullscreen mode Exit fullscreen mode
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test", "lib", "bin"]
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Bring payload 1.0 and 2.0 to a common shape

This is the most important technical difference. The REST API sends your function an event in payload format 1.0: method in httpMethod, path in path, client IP under requestContext.identity. The HTTP API uses payload format 2.0 by default: method under requestContext.http.method, path in rawPath, cookies in a separate field, repeated headers combined into one value. CDK and the Serverless Framework both default to 2.0. A handler that knows only one of the formats gets undefined with the other one and, in the worst case, returns a 500.

That is why I translate the event into a small structure of my own right at the start. The rest of the code only knows this structure and is therefore independent of the gateway. This also pays off if you migrate later.

// src/http-event.ts
import type { APIGatewayProxyEvent, APIGatewayProxyEventV2 } from 'aws-lambda';

// REST API sends payload 1.0, HTTP API sends payload 2.0 by default
export type AnyApiEvent = APIGatewayProxyEvent | APIGatewayProxyEventV2;

export interface NormalizedRequest {
  apiType: 'rest' | 'http';
  method: string;
  path: string;
  pathParameters: Record<string, string | undefined>;
  headers: Record<string, string | undefined>;
  body: string | null;
  sourceIp: string;
}

export function isPayloadV2(event: AnyApiEvent): event is APIGatewayProxyEventV2 {
  return (event as APIGatewayProxyEventV2).version === '2.0';
}

function lowerCaseKeys(headers: Record<string, string | undefined>): Record<string, string | undefined> {
  return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
}

function decodeBody(body: string | null | undefined, isBase64Encoded: boolean): string | null {
  if (body === null || body === undefined) return null;
  return isBase64Encoded ? Buffer.from(body, 'base64').toString('utf8') : body;
}

export function normalize(event: AnyApiEvent): NormalizedRequest {
  if (isPayloadV2(event)) {
    return {
      apiType: 'http',
      method: event.requestContext.http.method,
      path: event.rawPath,
      pathParameters: event.pathParameters ?? {},
      headers: lowerCaseKeys(event.headers ?? {}),
      body: decodeBody(event.body, event.isBase64Encoded),
      sourceIp: event.requestContext.http.sourceIp,
    };
  }
  return {
    apiType: 'rest',
    method: event.httpMethod,
    path: event.path,
    pathParameters: event.pathParameters ?? {},
    headers: lowerCaseKeys(event.headers ?? {}),
    body: decodeBody(event.body, event.isBase64Encoded),
    sourceIp: event.requestContext.identity.sourceIp,
  };
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Write the handler

The handler works only with the normalized request. It validates the preorder body itself, even though the REST API will also do this at the gateway later. The HTTP API does not validate bodies, and tests should run without a gateway. Both options understand the response format with statusCode, headers and body in the same way.

// src/handler.ts
import { normalize, type AnyApiEvent } from './http-event.js';

interface Bread {
  name: string;
  priceCents: number;
  availableToday: number;
}

interface ApiResult {
  statusCode: number;
  headers: Record<string, string>;
  body: string;
}

// Sample data instead of a database, so the focus stays on API Gateway
const BREADS: Record<string, Bread> = {
  rye: { name: 'Rye sourdough', priceCents: 420, availableToday: 12 },
  spelt: { name: 'Wholegrain spelt', priceCents: 480, availableToday: 0 },
};

function json(statusCode: number, payload: unknown, apiType: string): ApiResult {
  return {
    statusCode,
    headers: { 'content-type': 'application/json', 'x-api-type': apiType },
    body: JSON.stringify(payload),
  };
}

export interface Preorder {
  breadSlug: string;
  quantity: number;
  pickupDate: string;
}

// Even behind a REST API with request validation, the function checks the body itself:
// the HTTP API does not validate bodies, and tests should run without a gateway.
export function parsePreorder(body: string | null): Preorder | string {
  if (!body) return 'Body is missing';
  let data: unknown;
  try {
    data = JSON.parse(body);
  } catch {
    return 'Body is not valid JSON';
  }
  const { breadSlug, quantity, pickupDate } = data as Record<string, unknown>;
  if (typeof breadSlug !== 'string' || !(breadSlug in BREADS)) return 'Unknown bread';
  if (!Number.isInteger(quantity) || (quantity as number) < 1 || (quantity as number) > 20) {
    return 'Quantity must be between 1 and 20';
  }
  if (typeof pickupDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(pickupDate)) {
    return 'Provide the pickup date as YYYY-MM-DD';
  }
  return { breadSlug, quantity: quantity as number, pickupDate };
}

export const handler = async (event: AnyApiEvent): Promise<ApiResult> => {
  const req = normalize(event);

  if (req.method === 'GET' && req.pathParameters.slug) {
    const bread = BREADS[req.pathParameters.slug];
    if (!bread) return json(404, { message: 'Bread not found' }, req.apiType);
    return json(200, { slug: req.pathParameters.slug, ...bread }, req.apiType);
  }

  if (req.method === 'POST' && req.path.endsWith('/preorders')) {
    const result = parsePreorder(req.body);
    if (typeof result === 'string') return json(400, { message: result }, req.apiType);
    return json(201, { message: 'Preorder accepted', ...result }, req.apiType);
  }

  return json(404, { message: 'Route not found' }, req.apiType);
};
Enter fullscreen mode Exit fullscreen mode

Step 4: Cover both formats with tests

The tests build trimmed events in both formats and send them through the same handler. The x-api-type header tells you which option was detected.

// test/handler.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { APIGatewayProxyEvent, APIGatewayProxyEventV2 } from 'aws-lambda';
import { handler, parsePreorder } from '../src/handler.js';

// Trimmed events: only the fields the handler reads
function restEvent(method: string, path: string, slug?: string, body?: string): APIGatewayProxyEvent {
  return {
    httpMethod: method,
    path,
    pathParameters: slug ? { slug } : null,
    headers: { 'Content-Type': 'application/json' },
    body: body ?? null,
    isBase64Encoded: false,
    requestContext: { identity: { sourceIp: '203.0.113.10' } },
  } as unknown as APIGatewayProxyEvent;
}

function httpEvent(method: string, path: string, slug?: string, body?: string): APIGatewayProxyEventV2 {
  return {
    version: '2.0',
    rawPath: path,
    pathParameters: slug ? { slug } : undefined,
    headers: { 'content-type': 'application/json' },
    body,
    isBase64Encoded: false,
    requestContext: { http: { method, sourceIp: '203.0.113.20' } },
  } as unknown as APIGatewayProxyEventV2;
}

test('REST API (payload 1.0) returns a bread', async () => {
  const res = await handler(restEvent('GET', '/breads/rye', 'rye'));
  assert.equal(res.statusCode, 200);
  assert.equal(res.headers['x-api-type'], 'rest');
  assert.equal(JSON.parse(res.body).name, 'Rye sourdough');
});

test('HTTP API (payload 2.0) returns the same bread', async () => {
  const res = await handler(httpEvent('GET', '/breads/rye', 'rye'));
  assert.equal(res.statusCode, 200);
  assert.equal(res.headers['x-api-type'], 'http');
});

test('unknown bread returns 404', async () => {
  const res = await handler(httpEvent('GET', '/breads/baguette', 'baguette'));
  assert.equal(res.statusCode, 404);
});

test('valid preorder returns 201 for both formats', async () => {
  const body = JSON.stringify({ breadSlug: 'rye', quantity: 2, pickupDate: '2026-11-02' });
  assert.equal((await handler(restEvent('POST', '/preorders', undefined, body))).statusCode, 201);
  assert.equal((await handler(httpEvent('POST', '/preorders', undefined, body))).statusCode, 201);
});

test('Base64-encoded body is decoded', async () => {
  const raw = JSON.stringify({ breadSlug: 'spelt', quantity: 1, pickupDate: '2026-11-03' });
  const event = httpEvent('POST', '/preorders', undefined, Buffer.from(raw).toString('base64'));
  event.isBase64Encoded = true;
  assert.equal((await handler(event)).statusCode, 201);
});

test('validation reports clear errors', () => {
  assert.equal(parsePreorder(null), 'Body is missing');
  assert.equal(parsePreorder('{broken'), 'Body is not valid JSON');
  assert.match(String(parsePreorder('{"breadSlug":"rye","quantity":50,"pickupDate":"2026-11-02"}')), /between 1 and 20/);
  assert.match(String(parsePreorder('{"breadSlug":"rye","quantity":1,"pickupDate":"tomorrow"}')), /YYYY-MM-DD/);
});
Enter fullscreen mode Exit fullscreen mode
npx tsc --noEmit
node --import tsx --test test/*.test.ts
Enter fullscreen mode Exit fullscreen mode

Expected result: # pass 6 and # fail 0.

Step 5a: Both APIs with the Serverless Framework

In the Serverless Framework the difference is a single word: httpApi creates routes in an HTTP API, http creates resources in a REST API. REST-specific settings such as API keys, usage plan and schema live under provider.apiGateway, CORS for the HTTP API under provider.httpApi. Version 4 of the framework builds TypeScript itself with esbuild.

# serverless.yml
service: bakery-api
frameworkVersion: '4'

provider:
  name: aws
  runtime: nodejs22.x
  region: eu-central-1
  memorySize: 256
  timeout: 10
  # Settings for the HTTP API
  httpApi:
    cors:
      allowedOrigins:
        - https://example.com
      allowedMethods:
        - GET
        - POST
      allowedHeaders:
        - content-type
  # Settings for the REST API
  apiGateway:
    apiKeys:
      - cafe-am-markt
    usagePlan:
      quota:
        limit: 10000
        period: MONTH
      throttle:
        rateLimit: 10
        burstLimit: 20
    request:
      schemas:
        preorder:
          name: Preorder
          schema: ${file(schemas/preorder.json)}

functions:
  api:
    handler: src/handler.handler
    events:
      # HTTP API (payload 2.0)
      - httpApi: 'GET /breads/{slug}'
      - httpApi: 'POST /preorders'
      # REST API (payload 1.0), API key required
      - http:
          path: breads/{slug}
          method: get
          private: true
      - http:
          path: preorders
          method: post
          private: true
          request:
            schemas:
              application/json: preorder
Enter fullscreen mode Exit fullscreen mode

Store the JSON Schema for the preorder in its own file. API Gateway expects JSON Schema draft 4 here.

{
  "$schema": "http://json-schema.org/draft-04/schema#",
  "type": "object",
  "required": ["breadSlug", "quantity", "pickupDate"],
  "additionalProperties": false,
  "properties": {
    "breadSlug": { "type": "string", "minLength": 1 },
    "quantity": { "type": "integer", "minimum": 1, "maximum": 20 },
    "pickupDate": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" }
  }
}
Enter fullscreen mode Exit fullscreen mode
serverless deploy
Enter fullscreen mode Exit fullscreen mode

After deployment, the framework lists two base URLs and the value of the API key. The HTTP API has no stage name in its path; the REST API does. The stage name matches the framework stage, which is dev if you do not specify one.

Step 5b: The same APIs with AWS CDK

In CDK, both options have been part of aws-cdk-lib for some time: the REST API in aws-apigateway, the HTTP API in aws-apigatewayv2 with its integrations in aws-apigatewayv2-integrations. Install the libraries first.

npm i aws-cdk-lib constructs
npm i -D aws-cdk
Enter fullscreen mode Exit fullscreen mode
// lib/bakery-api-stack.ts
import { CfnOutput, Duration, Stack, type StackProps } from 'aws-cdk-lib';
import type { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import * as apigw from 'aws-cdk-lib/aws-apigateway';
import { CorsHttpMethod, HttpApi, HttpMethod } from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';

export class BakeryApiStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const fn = new NodejsFunction(this, 'BakeryFn', {
      entry: 'src/handler.ts',
      runtime: lambda.Runtime.NODEJS_22_X,
      memorySize: 256,
      timeout: Duration.seconds(10),
    });

    // Option 1: HTTP API – lean, low cost, payload 2.0
    const httpApi = new HttpApi(this, 'BakeryHttpApi', {
      apiName: 'bakery-http',
      corsPreflight: {
        allowOrigins: ['https://example.com'],
        allowMethods: [CorsHttpMethod.GET, CorsHttpMethod.POST],
        allowHeaders: ['content-type'],
      },
    });
    const httpIntegration = new HttpLambdaIntegration('BakeryHttpIntegration', fn);
    httpApi.addRoutes({ path: '/breads/{slug}', methods: [HttpMethod.GET], integration: httpIntegration });
    httpApi.addRoutes({ path: '/preorders', methods: [HttpMethod.POST], integration: httpIntegration });

    // Option 2: REST API – with validation, API key and usage plan
    const restApi = new apigw.RestApi(this, 'BakeryRestApi', {
      restApiName: 'bakery-rest',
      // Don't create an account-wide CloudWatch role (the setting applies to all REST APIs in the Region)
      cloudWatchRole: false,
      endpointConfiguration: { types: [apigw.EndpointType.REGIONAL] },
      deployOptions: {
        stageName: 'prod',
        throttlingRateLimit: 50,
        throttlingBurstLimit: 100,
        metricsEnabled: true,
      },
    });
    const restIntegration = new apigw.LambdaIntegration(fn);

    restApi.root
      .addResource('breads')
      .addResource('{slug}')
      .addMethod('GET', restIntegration, { apiKeyRequired: true });

    const preorderModel = restApi.addModel('PreorderModel', {
      contentType: 'application/json',
      modelName: 'Preorder',
      schema: {
        schema: apigw.JsonSchemaVersion.DRAFT4,
        type: apigw.JsonSchemaType.OBJECT,
        required: ['breadSlug', 'quantity', 'pickupDate'],
        additionalProperties: false,
        properties: {
          breadSlug: { type: apigw.JsonSchemaType.STRING, minLength: 1 },
          quantity: { type: apigw.JsonSchemaType.INTEGER, minimum: 1, maximum: 20 },
          pickupDate: { type: apigw.JsonSchemaType.STRING, pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
        },
      },
    });
    const bodyValidator = restApi.addRequestValidator('BodyValidator', {
      requestValidatorName: 'validate-body',
      validateRequestBody: true,
    });
    restApi.root.addResource('preorders').addMethod('POST', restIntegration, {
      apiKeyRequired: true,
      requestValidator: bodyValidator,
      requestModels: { 'application/json': preorderModel },
    });

    // Usage plan: throttling and quota per partner (best effort, no protection against abuse)
    const plan = restApi.addUsagePlan('PartnerPlan', {
      name: 'bakery-partner',
      throttle: { rateLimit: 10, burstLimit: 20 },
      quota: { limit: 10_000, period: apigw.Period.MONTH },
    });
    plan.addApiStage({ stage: restApi.deploymentStage });
    const cafeKey = restApi.addApiKey('CafeKey', { apiKeyName: 'cafe-am-markt' });
    plan.addApiKey(cafeKey);

    new CfnOutput(this, 'HttpApiUrl', { value: httpApi.apiEndpoint });
    new CfnOutput(this, 'RestApiUrl', { value: restApi.url });
    new CfnOutput(this, 'CafeKeyId', { value: cafeKey.keyId });
  }
}
Enter fullscreen mode Exit fullscreen mode
// bin/app.ts
import { App } from 'aws-cdk-lib';
import { BakeryApiStack } from '../lib/bakery-api-stack.js';

const app = new App();
new BakeryApiStack(app, 'BakeryApiStack', {
  env: { region: process.env.CDK_DEFAULT_REGION ?? 'eu-central-1' },
});
Enter fullscreen mode Exit fullscreen mode
{
  "app": "npx tsx bin/app.ts"
}
Enter fullscreen mode Exit fullscreen mode
npx cdk synth
npx cdk deploy
Enter fullscreen mode Exit fullscreen mode

The synth output nicely shows how many more resources the REST API needs: resources, methods, model, validator, deployment, stage, usage plan and key. The HTTP API gets by with an API, an integration, two routes and a stage. Setting cloudWatchRole: false prevents CDK from setting an account-wide logging role for API Gateway that affects all REST APIs in the Region.

Step 6: Call both options

After the CDK deployment, you fetch the API key value using the key ID from the outputs. Replace the placeholders with the outputs of your deployment.

aws apigateway get-api-key --api-key <CafeKeyId> --include-value --query value --output text

curl -i https://<http-api-id>.execute-api.eu-central-1.amazonaws.com/breads/rye

curl -i https://<rest-api-id>.execute-api.eu-central-1.amazonaws.com/prod/breads/rye \
  -H "x-api-key: <key-value>"

curl -i -X POST https://<rest-api-id>.execute-api.eu-central-1.amazonaws.com/prod/preorders \
  -H "x-api-key: <key-value>" -H "content-type: application/json" \
  -d '{"breadSlug":"rye","quantity":99,"pickupDate":"2026-11-02"}'
Enter fullscreen mode Exit fullscreen mode

This is what the result looks like (sample output, shortened): the HTTP API responds with 200 and x-api-type: http, the REST API without a key with 403 and {"message":"Forbidden"}, with a key with 200 and x-api-type: rest. The REST API rejects the preorder for 99 loaves with 400 and {"message": "Invalid request body"} without invoking Lambda. Send the same request to the HTTP API and the rejection comes from your handler.

To clean up: serverless remove or npx cdk destroy. As long as nobody calls the APIs, they cost nothing; a cached REST stage would be the exception.

Migrating from REST API to HTTP API

Many older projects run on the REST API because the HTTP API did not exist when they started. Switching can pay off, but it does not happen by itself. I check these points beforehand:

  • Event format: First switch the handler to a normalization like the one above. Alternatively, you can run an HTTP API with payload 1.0, which keeps the rework smaller.
  • API keys: If clients send x-api-key, you need a replacement, such as a Lambda authorizer or JWTs.
  • Mapping templates and validation: Everything that used to happen in the gateway moves into the function. A schema validator like Zod helps.
  • Stage path: The REST API has /prod/ in the path, the HTTP API with the $default stage does not. With a custom domain and API mappings, URLs stay stable for clients.
  • Error messages: The HTTP API has no custom gateway responses. Clients that parse specific error texts will notice the difference.
  • Logging and tracing: Execution logs and X-Ray at the gateway go away; access logs and tracing inside the function remain.

The opposite direction is realistic too: a project starts with the HTTP API, and months later a partner wants quotas or the security team requires WAF. If you already have the normalized handler, you only need to swap the gateway configuration.

Common mistakes when choosing REST API vs HTTP API

  • Using API keys for access control. AWS states it explicitly in the documentation: “Don’t use API keys for authentication or authorization to control access to your APIs.” Keys identify clients for quotas. For access control you need IAM, Cognito or an authorizer.
  • Treating usage plans as a cost brake. According to AWS, throttling and quotas are applied on a best-effort basis and can be exceeded. For costs, use AWS Budgets; against abuse, use WAF.
  • Choosing the REST API “just in case”. You permanently pay the higher price and carry more configuration for features you may never need.
  • Testing only one payload format. If you test locally with 1.0 events and then deploy behind an HTTP API, you only find the bug in production.
  • Overlooking the 30-second limit. On the HTTP API it is fixed. Longer work belongs in asynchronous processing.

My take

I start new Lambda backends with the HTTP API. It costs less, is quicker to set up, and the built-in JWT authorizer saves me a lot of work for apps with sign-in. For the team, it also means less gateway configuration that only two people understand.

I would push back on the common claim that the REST API is a legacy product. AWS keeps developing it, and response streaming in particular shows where things are heading: if you deliver AI responses or large downloads, you will find the new features there first. For public interfaces with partners, WAF requirements or private endpoints, the REST API remains my first choice. What matters is making the choice consciously and writing the code so that switching later does not hurt.

FAQ

Is the HTTP API faster than the REST API?

The HTTP API has fewer stations in the request path, but AWS does not give reliable latency figures in its feature overview. For most applications, the Lambda function’s run time dominates anyway. When in doubt, measure with your own load.

Can I use API keys with the HTTP API?

Not with built-in usage plans. You can, however, check keys with a Lambda authorizer and count quotas yourself, for example with a counter per key in DynamoDB.

How long can a request to API Gateway take?

On the HTTP API, 30 seconds at most, fixed. On the REST API, up to 29 seconds by default; for Regional and private APIs the limit can be raised, and response streaming allows up to 15 minutes.

Do I need API Gateway at all, or is a Lambda function URL enough?

For a single endpoint, webhooks or streaming without gateway features, a function URL is often enough. As soon as you need multiple routes, JWT validation, custom domains shared by several services or throttling, API Gateway is the better choice.

Your next step

Pick an API you are running right now and go through the five questions from the decision guide. If every answer is “no” and you are on the REST API, calculate the savings with your real request numbers. As preparation, add the normalization from step 2 to your handler. Then switching later is just a matter of configuration.

And I am curious: which option are you using right now, and was there a concrete reason for it, or was it simply the first tile in the console?

Sources

Tested with: Node.js 22.23, TypeScript 7.0.2, tsx 4.23, aws-cdk-lib 2.275.0, CDK CLI 2.1146.1, @types/aws-lambda 8.10.164 (tsc, 6 tests with node:test, cdk synth including the esbuild bundle). serverless.yml checked for valid YAML only. curl outputs are marked as samples.

This post was created with AI assistance.


👉 Next step: Working with AWS CDK? Grab my printable AWS CDK cheat sheet with commands, concepts and a stack skeleton.

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)