StackPractices
intermediate By Mathias Paulenko

Build Serverless APIs with API Gateway

How to design, deploy, and manage serverless HTTP APIs using AWS API Gateway, Lambda, and function-as-a-service patterns.

Topics: serverless

Overview

Serverless computing lets you run code without provisioning or managing servers. AWS Lambda executes your functions in response to events, and API Gateway acts as the HTTP front door that routes requests to those functions. Together they form a pay-per-request, auto-scaling API platform that eliminates idle server costs.

This architecture is ideal for APIs with variable or unpredictable traffic. A startup might serve a hundred requests per day at launch and a million per day six months later — serverless handles both without capacity planning. The trade-offs are cold start latency (the delay when a function wakes up after being idle) and vendor-specific tooling that creates lock-in.

When to Use

  • Building APIs with sporadic or unpredictable traffic patterns. See Rate Limiting for protecting APIs under load.
  • Prototyping products where server costs should scale to zero when idle.
  • Processing webhooks, file uploads, or scheduled events via HTTP. See Input Validation for validating incoming requests.
  • Creating microservices where each endpoint has different resource needs.

Skip it for sustained high-traffic APIs where a warm container is cheaper, and for long-running work above Lambda’s 15-minute limit — that belongs on ECS/Fargate or a batch system.

Solution

AWS Lambda Handler (Python)

import json

def lambda_handler(event, context):
    # API Gateway passes HTTP data in the event object
    method = event['httpMethod']
    path = event['path']
    query = event.get('queryStringParameters', {})

    if method == 'GET' and path == '/users':
        return {
            'statusCode': 200,
            'headers': {'Content-Type': 'application/json'},
            'body': json.dumps({'users': ['alice', 'bob']})
        }

    if method == 'POST' and path == '/users':
        body = json.loads(event['body'])
        return {
            'statusCode': 201,
            'body': json.dumps({'id': '123', 'name': body['name']})
        }

    return {'statusCode': 404, 'body': json.dumps({'error': 'Not found'})}

AWS Lambda Handler (Node.js)

exports.handler = async (event) => {
  const { httpMethod, path, body } = event;

  if (httpMethod === 'GET' && path === '/users') {
    return {
      statusCode: 200,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ users: ['alice', 'bob'] })
    };
  }

  if (httpMethod === 'POST' && path === '/users') {
    try {
      const data = JSON.parse(body);
      return {
        statusCode: 201,
        body: JSON.stringify({ id: '123', name: data.name })
      };
    } catch {
      return { statusCode: 400, body: JSON.stringify({ error: 'Invalid JSON body' }) };
    }
  }

  return { statusCode: 404, body: JSON.stringify({ error: 'Not found' }) };
};

Without that try/catch, a malformed body throws and API Gateway returns a generic 502 — a 400 tells the client what it actually did wrong.

Terraform Deployment

A working API needs five pieces the minimal examples usually skip: the IAM role, the AWS_PROXY integration, the permission that lets API Gateway invoke the function, and a deployment with a stage.

resource "aws_iam_role" "lambda_role" {
  name = "users-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action    = "sts:AssumeRole"
      Effect    = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_lambda_function" "handler" {
  function_name    = "users-handler"
  handler          = "index.handler"
  runtime          = "nodejs20.x"
  filename         = "function.zip"
  source_code_hash = filebase64sha256("function.zip")
  role             = aws_iam_role.lambda_role.arn
}

resource "aws_api_gateway_rest_api" "api" {
  name = "users-api"
}

resource "aws_api_gateway_resource" "users" {
  rest_api_id = aws_api_gateway_rest_api.api.id
  parent_id   = aws_api_gateway_rest_api.api.root_resource_id
  path_part   = "users"
}

resource "aws_api_gateway_method" "get_users" {
  rest_api_id   = aws_api_gateway_rest_api.api.id
  resource_id   = aws_api_gateway_resource.users.id
  http_method   = "GET"
  authorization = "NONE"
}

resource "aws_api_gateway_integration" "get_users" {
  rest_api_id             = aws_api_gateway_rest_api.api.id
  resource_id             = aws_api_gateway_resource.users.id
  http_method             = aws_api_gateway_method.get_users.http_method
  type                    = "AWS_PROXY"
  integration_http_method = "POST"
  uri                     = aws_lambda_function.handler.invoke_arn
}

resource "aws_lambda_permission" "apigw" {
  statement_id  = "AllowAPIGatewayInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.handler.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_api_gateway_rest_api.api.execution_arn}/*/*"
}

resource "aws_api_gateway_deployment" "deploy" {
  rest_api_id = aws_api_gateway_rest_api.api.id
  stage_name  = "prod"
  depends_on  = [aws_api_gateway_integration.get_users]
}

output "invoke_url" {
  value = "https://${aws_api_gateway_rest_api.api.id}.execute-api.${var.region}.amazonaws.com/prod"
}

AWS_PROXY is the key choice here: it forwards the whole HTTP request to Lambda and expects the {statusCode, headers, body} response shape the handlers return — no mapping templates to maintain.

If you apply this and the endpoint returns 500 Internal Server Error, the usual suspect is the missing aws_lambda_permission — API Gateway needs explicit permission to invoke the function and the error it surfaces doesn’t say so. Check aws lambda get-policy on the function, and look at the API Gateway access logs for Execution failed due to configuration error while you’re at it.

Explanation

  • API Gateway: Receives HTTP requests, handles TLS termination, throttling, caching, and authentication, then invokes the Lambda function with the request data in a structured event object.
  • Lambda: Event-driven function execution, stateless by design. AWS handles the scaling — 1,000 simultaneous requests mean 1,000 function instances running in parallel, and you pay for execution time and memory, nothing else.
  • Cold starts: When a function hasn’t been invoked recently, AWS initializes a fresh runtime instance — expect 100ms to 2s added to that first call depending on the runtime and the memory you assigned. Provisioned concurrency keeps functions warm for latency-sensitive endpoints — see Cold Start Optimization for the full playbook.
  • Infrastructure as Code: Terraform, AWS SAM, or the Serverless Framework express your API routes, functions, IAM roles, and environment variables as version-controlled config instead of console clicks.
flowchart diagram: Client

Variants

PlatformAPI GatewayFunction RuntimeBest For
AWSAPI Gateway + LambdaPython, Node, Java, GoMature platform, broad integrations
AzureAPI Management + Functions.NET, Node, PythonMicrosoft ecosystem, Visual Studio integration
GCPCloud Endpoints + Cloud FunctionsNode, Python, GoBigQuery integration, competitive pricing

API Gateway also supports WebSocket APIs for bidirectional traffic — see Real-time WebSockets — and Lambda authorizers for custom token validation before the request ever reaches your handler.

Production Considerations

The boilerplate checklists for serverless APIs all say the same things — these are the ones that actually bite in production:

  • Authentication: authorization = "NONE" is fine for a demo and wrong for a real API. Attach a Lambda authorizer (see the companion recipe) or Cognito/IAM for anything user-facing; use OAuth 2.0 flows for third-party clients.
  • Secrets: never bake credentials into the deployment package or plain environment variables — use AWS Secrets Manager (Azure Key Vault, Google Secret Manager on the other clouds) and rotate them.
  • Idempotency: gateways and clients retry failed requests whether you like it or not — a POST that isn’t idempotent creates duplicates you then have to explain. Key writes on a client-supplied idempotency token or deduplication ID.
  • Partial failures: with SQS-triggered functions, a single bad record fails the whole batch. Return batchItemFailures so only the poison message is retried — and configure a dead letter queue for what keeps failing.
  • VPC access: functions that touch private resources need VPC configuration, which changes cold start behavior and costs a NAT gateway for outbound internet. Don’t default to it — add it when you actually need private subnets.
  • Testing: mock AWS SDK calls for unit tests (SAM CLI or LocalStack emulate API Gateway + Lambda locally), run integration tests against a real deployed stage, and load test before launch — the “it scales automatically” promise hides throttling and concurrency limits that only show under real traffic.
  • Gradual rollouts: use Lambda versions and aliases with canary traffic shifting (SAM supports it natively) instead of updating $LATEST in place.

What Works

  • Design for statelessness: anything your function stashes in memory may vanish on the next invocation — pass state through the request or an external store.
  • Minimize deployment package size: large packages increase cold start time.
  • Set appropriate timeouts and memory: memory scales CPU proportionally. If a function is slow, increasing memory may be cheaper than paying for longer execution at lower memory.
  • Use environment variables for config: database URLs and feature flags should be set via environment variables, not baked into the deployment package.
  • Log as JSON, not prose: emit one JSON object per request carrying its request ID — that’s what makes fields queries in CloudWatch Logs Insights actually useful when you’re chasing a bug.
  • Use dead letter queues: failed async invocations are retried automatically. A DLQ captures persistent failures so you can inspect and reprocess them.

Common Mistakes

  • Treating Lambda like a long-running server: functions hard-stop at 15 minutes, mid-work if necessary. Move anything longer to batch processing (AWS Batch) or containers (ECS/Fargate).
  • Ignoring cold starts: a user-facing API with 3-second cold starts makes for a terrible experience. Use provisioned concurrency or a keep-alive ping for latency-sensitive endpoints.
  • Over-provisioning memory: memory scales linearly with the bill, and most handlers never touch the 1,024 MB someone typed in on day one. Profile yours and allocate what it actually uses.
  • Hardcoding credentials: never commit AWS keys or database passwords to your repository.
  • Not handling partial failures: in batch processing (SQS triggers), a single bad record can cause the entire batch to fail.

Further Reading

Frequently Asked Questions

How do I handle database connections in Lambda?

Use connection pooling with a lightweight proxy like RDS Proxy, or implement your own connection reuse logic — open the connection once outside the handler so warm invocations reuse it. Opening a new database connection on every invocation is slow and can exhaust the database's connection limit.

Can I run a full-stack application on serverless?

Yes, but evaluate the trade-offs. Static sites and APIs are excellent fits. Long-running WebSocket connections or stateful sessions may be better served by containers or EC2.

Is serverless cheaper than traditional servers?

It comes down to your traffic shape: sporadic traffic makes serverless cheap because you pay per request, while sustained high traffic tips the scale toward provisioned containers or EC2. Model your actual volumes with the AWS pricing calculator before committing — the crossover point surprises most teams.

How do I test Lambda functions locally?

Use the AWS SAM CLI or the Serverless Framework to emulate API Gateway and Lambda locally. These tools mount your code in a Docker container that matches the AWS runtime environment.

What's the difference between API Gateway REST and HTTP APIs?

HTTP APIs are newer, cheaper, and faster — they support OIDC/JWT authorization and cost about a third of REST APIs. REST APIs add capabilities HTTP APIs lack: API keys, usage plans, request validation, caching, and private endpoints. For most new APIs, start with HTTP API; reach for REST when you need those extras.

How do I add authentication to an API Gateway API?

Three common paths: a Lambda authorizer (custom token validation with your own logic), Cognito user pools (managed identity), or IAM authentication for internal service-to-service calls. The Lambda authorizer approach is covered in the companion recipe.