Skip to content

Building Serverless APIs with AWS Lambda and API Gateway — Guide

DodaTech Updated 2026-06-23 9 min read

In this tutorial, you'll learn about Building Serverless APIs with AWS Lambda and API Gateway. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Serverless APIs use AWS Lambda for compute and API Gateway for HTTP endpoints, enabling pay-per-request pricing, automatic scaling, and zero server management for building cost-effective REST APIs.

What You'll Learn

You will learn to build Serverless REST APIs with AWS Lambda and API Gateway, write Lambda handlers in Node.js and Python, configure API Gateway routes and authentication, manage environment variables, and monitor Serverless applications.

Why Serverless APIs Matter

Serverless eliminates server management, scales from zero to thousands of requests instantly, and charges only for actual usage. AWS Lambda costs nothing when idle, making it ideal for variable traffic patterns. Companies using Serverless report 60 percent lower operational costs and 40 percent faster time to market.

Real-World Use

DodaTech runs several Serverless APIs. Doda Browser analytics endpoint processes millions of events through Lambda, DodaZIP uses Serverless functions for file metadata extraction, and Durga Antivirus Pro threat report submission runs on Lambda with API Gateway for automatic scaling during threat outbreaks.

Serverless APIs Learning Path

flowchart LR
  A[Cloud Computing Basics] --> B[Lambda Concepts]
  B --> C[Function Handlers]
  C --> D[API Gateway Integration]
  D --> E[Environment & Secrets]
  E --> F[Deployment & CI/CD]
  F --> G[Monitoring & Debugging]
  B:::current

  classDef current fill:#f90,color:#fff,stroke:#333,stroke-width:2px

Prerequisites

Understand RESTful Api Design Best Practices and basic Cloud Computing Basics. An AWS account is required for deployment. Familiarity with JavaScript Basics or Python Basics is needed.

AWS Lambda Basics

AWS Lambda runs your code in response to events and automatically manages the compute resources.

Lambda Execution Model

sequenceDiagram
    Client->>API Gateway: HTTP Request
    API Gateway->>Lambda: Invoke function
    Lambda->>Lambda: Cold start (init)
    Lambda->>Lambda: Execute handler
    Lambda->>API Gateway: Response
    API Gateway->>Client: HTTP Response

Lambda Function Structure

// Node.js Lambda handler
exports.handler = async (event, context) => {
  // event: API Gateway event object
  // context: Lambda runtime information

  const { httpMethod, path, queryStringParameters, body, headers } = event;

  return {
    statusCode: 200,
    headers: {
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin": "https://dodatech.com"
    },
    body: JSON.stringify({
      message: "Hello from DodaTech Lambda!",
      path,
      method: httpMethod
    })
  };
};
# Python Lambda handler
import json

def lambda_handler(event, context):
    http_method = event.get("httpMethod")
    path = event.get("path")

    return {
        "statusCode": 200,
        "headers": {
            "Content-Type": "application/json",
            "Access-Control-Allow-Origin": "https://dodatech.com"
        },
        "body": json.dumps({
            "message": "Hello from DodaTech Lambda!",
            "path": path,
            "method": http_method
        })
    }

Building a Serverless User API

Step 1: Project Structure

dodatech-serverless/
├── src/
│   ├── handlers/
│   │   ├── users.js
│   │   └── auth.js
│   ├── db.js
│   └── utils.js
├── template.yaml       # SAM template
├── serverless.yml      # Serverless Framework config
├── package.json
└── requirements.txt

Step 2: Lambda Handler

// src/handlers/users.js
const AWS = require("aws-sdk");
const dynamoDb = new AWS.DynamoDB.DocumentClient();
const { v4: uuidv4 } = require("uuid");

const USERS_TABLE = process.env.USERS_TABLE;

exports.listUsers = async (event) => {
  try {
    const params = {
      TableName: USERS_TABLE,
      Limit: 20
    };

    // Handle pagination
    if (event.queryStringParameters?.cursor) {
      params.ExclusiveStartKey = {
        id: event.queryStringParameters.cursor
      };
    }

    const result = await dynamoDb.scan(params).promise();

    return formatResponse(200, {
      success: true,
      data: result.Items,
      cursor: result.LastEvaluatedKey?.id || null
    });
  } catch (error) {
    console.error("Error listing users:", error);
    return formatResponse(500, {
      success: false,
      error: "Internal server error"
    });
  }
};

exports.createUser = async (event) => {
  try {
    const body = JSON.parse(event.body);

    // Validate input
    if (!body.name || !body.email) {
      return formatResponse(422, {
        success: false,
        error: "Name and email are required"
      });
    }

    const user = {
      id: uuidv4(),
      name: body.name,
      email: body.email,
      role: body.role || "member",
      createdAt: new Date().toISOString()
    };

    await dynamoDb.put({
      TableName: USERS_TABLE,
      Item: user,
      ConditionExpression: "attribute_not_exists(email)"
    }).promise();

    return formatResponse(201, {
      success: true,
      data: user
    });
  } catch (error) {
    if (error.code === "ConditionalCheckFailedException") {
      return formatResponse(409, {
        success: false,
        error: "User with this email already exists"
      });
    }

    console.error("Error creating user:", error);
    return formatResponse(500, {
      success: false,
      error: "Internal server error"
    });
  }
};

exports.getUser = async (event) => {
  try {
    const { id } = event.pathParameters;

    const result = await dynamoDb.get({
      TableName: USERS_TABLE,
      Key: { id }
    }).promise();

    if (!result.Item) {
      return formatResponse(404, {
        success: false,
        error: "User not found"
      });
    }

    return formatResponse(200, {
      success: true,
      data: result.Item
    });
  } catch (error) {
    console.error("Error getting user:", error);
    return formatResponse(500, {
      success: false,
      error: "Internal server error"
    });
  }
};

exports.deleteUser = async (event) => {
  try {
    const { id } = event.pathParameters;

    await dynamoDb.delete({
      TableName: USERS_TABLE,
      Key: { id },
      ReturnValues: "ALL_OLD"
    }).promise();

    return formatResponse(204, null);
  } catch (error) {
    console.error("Error deleting user:", error);
    return formatResponse(500, {
      success: false,
      error: "Internal server error"
    });
  }
};

function formatResponse(statusCode, body) {
  return {
    statusCode,
    headers: {
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin": "https://dodatech.com",
      "Access-Control-Allow-Credentials": true
    },
    body: body ? JSON.stringify(body) : null
  };
}

Step 3: Deploy with SAM (AWS Serverless Application Model)

# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Runtime: nodejs20.x
    Timeout: 10
    MemorySize: 256
    Environment:
      Variables:
        USERS_TABLE: !Ref UsersTable

Resources:
  UsersTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: dodatech-users
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      KeySchema:
        - AttributeName: id
          KeyType: HASH
      BillingMode: PAY_PER_REQUEST

  UsersApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prod
      Cors:
        AllowOrigin: "'https://dodatech.com'"
        AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'"
        AllowHeaders: "'Content-Type,Authorization'"

  ListUsersFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: handlers/users.listUsers
      Events:
        Api:
          Type: Api
          Properties:
            RestApiId: !Ref UsersApi
            Path: /users
            Method: GET
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref UsersTable

  CreateUserFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: handlers/users.createUser
      Events:
        Api:
          Type: Api
          Properties:
            RestApiId: !Ref UsersApi
            Path: /users
            Method: POST
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref UsersTable

Step 4: Build and Deploy

# Build the SAM application
sam build

# Deploy to AWS
sam deploy --guided

# Expected output:
# CloudFormation outputs:
# UsersApi - https://api-id.execute-api.us-east-1.amazonaws.com/prod
# UsersTable - dodatech-users

Step 5: Test the API

# Create a user
curl -X POST https://api-id.execute-api.us-east-1.amazonaws.com/prod/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","email":"alice@example.com"}'

# Expected response:
{"success":true,"data":{"id":"uuid","name":"Alice","email":"alice@example.com","role":"member","createdAt":"2026-06-23T10:00:00Z"}}

# List users
curl https://api-id.execute-api.us-east-1.amazonaws.com/prod/users

# Expected response:
{"success":true,"data":[{"id":"uuid","name":"Alice","email":"alice"@example".com"}],"cursor":null}

Using the Serverless Framework

# serverless.yml
service: dodatech-serverless-api

provider:
  name: aws
  runtime: nodejs20.x
  region: us-east-1
  environment:
    USERS_TABLE: ${self:service}-${sls:stage}-users
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - dynamodb:Query
            - dynamodb:Scan
            - dynamodb:GetItem
            - dynamodb:PutItem
            - dynamodb:UpdateItem
            - dynamodb:DeleteItem
          Resource: !Sub arn:aws:dynamodb:*:*:table/${self:provider.environment.USERS_TABLE}

functions:
  listUsers:
    handler: src/handlers/users.listUsers
    events:
      - http:
          path: users
          method: GET
          cors: true

  createUser:
    handler: src/handlers/users.createUser
    events:
      - http:
          path: users
          method: POST
          cors: true

resources:
  Resources:
    UsersTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:provider.environment.USERS_TABLE}
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH
        BillingMode: PAY_PER_REQUEST

Environment Variables and Secrets

// Using environment variables
const TABLE_NAME = process.env.TABLE_NAME;
const STAGE = process.env.STAGE;

// Using AWS Secrets Manager
const AWS = require("aws-sdk");
const secretsManager = new AWS.SecretsManager();

async function getSecret(secretName) {
  const result = await secretsManager.getSecretValue({
    SecretId: secretName
  }).promise();

  return JSON.parse(result.SecretString);
}

Monitoring and Troubleshooting

// Structured logging
exports.handler = async (event) => {
  const requestId = event.requestContext?.requestId;

  console.log(JSON.stringify({
    level: "info",
    requestId,
    path: event.path,
    method: event.httpMethod,
    timestamp: new Date().toISOString()
  }));

  try {
    // Function logic
  } catch (error) {
    console.error(JSON.stringify({
      level: "error",
      requestId,
      error: error.message,
      Stack: error.Stack,
      timestamp: new Date().toISOString()
    }));

    throw error;
  }
};

CloudWatch Logs Queries

# Find errors in the last hour
fields @timestamp, @message
| filter @message like /error/i
| sort @timestamp desc
| limit 50

# Find slow invocations
fields @timestamp, @duration
| filter @duration > 3000
| sort @duration desc
| limit 20

Common Errors

  1. Cold start latency — The first invocation after a period of inactivity takes 1-5 seconds longer. Keep functions warm with scheduled events or use Provisioned Concurrency for latency-sensitive endpoints.

  2. Dependency packaging issues — Forgetting to include node_modules or Python packages in the deployment package. Use Lambda Layers for shared dependencies to keep deployment packages small.

  3. Timeout too short — Lambda default timeout is 3 seconds. Database operations and external API calls can exceed this. Set timeout appropriately (max 15 minutes for HTTP endpoints, 10-30 seconds for API Gateway).

  4. DynamoDB hot partitions — Using sequential IDs as partition keys causes throttling. Use random UUIDs or composite keys that distribute writes evenly across partitions.

  5. VPC without NAT Gateway — Lambda functions in a VPC cannot access the internet or public AWS services without a NAT Gateway. Use VPC endpoints for DynamoDB and S3, or deploy functions outside the VPC.

  6. Missing IAM permissions — Lambda functions need explicit permissions for every AWS service they access. Use least-privilege IAM policies. Debug with CloudWatch logs to find missing permissions.

  7. API Gateway timeout mismatchAPI Gateway has a 29-second timeout. If your Lambda function takes longer, API Gateway returns 504 before the function completes. Keep Lambda execution under 29 seconds for synchronous invocations.

Practice Questions

  1. What is a Lambda cold start and how do you mitigate it?
  2. How do you handle CORS in a Serverless API?
  3. What is the difference between DynamoDB scan and query?
  4. How do you manage environment variables for different stages?
  5. What IAM permissions are needed for Lambda to write to DynamoDB?

Challenge

Build a complete Serverless API for a file metadata service. Use API Gateway for HTTP endpoints, Lambda for compute, DynamoDB for metadata storage, S3 for file storage, and SQS for async processing. Implement endpoints for upload URL generation (presigned S3 URL), file metadata CRUD, async metadata extraction via SQS and Lambda, search by filename and file type, and pagination with Cursor. Deploy using SAM or Serverless Framework.

FAQ

How much does a Serverless API cost? Costs depend on usage. AWS Lambda costs $0.20 per million requests plus compute time. API Gateway costs $3.50 per million requests. DynamoDB on-demand costs $1.25 per million writes. A low-traffic API can cost under $5 per month.

What is the difference between Lambda and EC2 for APIs? Lambda is Serverless (no server management, auto-scaling, pay per request). EC2 gives full control over the environment but requires server management and has always-on costs. Choose Lambda for variable traffic and simple APIs. Choose EC2 for predictable high traffic and custom runtime requirements.

How do I handle database connections in Lambda? Create the database client outside the handler function so it can be reused across invocations (connection pooling). Do not create connections inside the handler. For relational databases with RDS Proxy, use connection pooling to avoid exhausting database connections.

Can Lambda functions access a VPC? Yes. Configure VPC settings in the function configuration. Lambda creates an elastic network interface (ENI) in the VPC subnet. Use VPC endpoints or NAT Gateway for internet access. Functions outside VPC cannot access RDS or ElastiCache in a VPC.

How do I debug Lambda functions? Use CloudWatch Logs for log output, CloudWatch Metrics for performance monitoring, and X-Ray for distributed tracing. Enable active tracing in API Gateway and Lambda to see the full request path. Test locally with SAM CLI before deploying.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro