Aws

API Gateway: REST and WebSocket APIs

Build REST and WebSocket APIs on AWS API Gateway with Lambda integration, authorization, and real-time communication patterns

API Gateway: REST and WebSocket APIs

Overview

AWS API Gateway is the front door for your serverless applications, handling request routing, authorization, throttling, and protocol translation so your Lambda functions can focus on business logic. It supports three API types: REST APIs (full-featured), HTTP APIs (lightweight and cheaper), and WebSocket APIs (persistent bidirectional connections). This article covers practical patterns for building production REST and WebSocket APIs with Node.js Lambda functions, including authorization, deployment, and the real-world gotchas that documentation glosses over.

Prerequisites

  • AWS account with IAM permissions for API Gateway, Lambda, and CloudFormation
  • Node.js 18+ installed locally
  • AWS CLI configured with credentials
  • AWS SAM CLI installed (pip install aws-sam-cli)
  • Basic understanding of Lambda functions and HTTP protocols

REST API vs HTTP API

API Gateway offers two flavors for HTTP-based APIs, and choosing wrong costs you either money or features.

REST API is the original, fully-featured product. It supports request/response transformations, API keys, usage plans, WAF integration, resource policies, request validation, and caching. It runs on the execute-api endpoint format https://{id}.execute-api.{region}.amazonaws.com/{stage}.

HTTP API is the newer, stripped-down version. It is roughly 70% cheaper, has lower latency, and supports JWT authorizers natively. But it lacks usage plans, API keys, request validation, WAF integration, and most transformation capabilities.

Here is when to use each:

Feature REST API HTTP API
Price per million requests ~$3.50 ~$1.00
Lambda proxy integration Yes Yes
API keys / usage plans Yes No
Request validation Yes No
WAF integration Yes No
Request/response transforms Yes Limited
Caching Yes No
JWT authorizer (native) No Yes
Lambda authorizer Yes Yes
Private integrations (VPC Link) Yes Yes

My recommendation: use REST API when you need API keys, throttling per client, request validation, or caching. Use HTTP API for internal microservice communication or when cost is the primary driver. For anything customer-facing with multiple consumers, REST API pays for itself.

Lambda Proxy Integration

Lambda proxy integration is the pattern you should use 95% of the time. API Gateway forwards the entire HTTP request as a structured event to your Lambda function, and your function returns a structured response that API Gateway translates back to HTTP.

Here is a Lambda handler using proxy integration:

// handlers/getItems.js
var AWS = require("aws-sdk");
var dynamodb = new AWS.DynamoDB.DocumentClient();

exports.handler = function(event, context, callback) {
  console.log("Request event:", JSON.stringify(event, null, 2));

  var tableName = process.env.TABLE_NAME;
  var userId = event.requestContext.authorizer.claims.sub;
  var category = event.queryStringParameters
    ? event.queryStringParameters.category
    : null;

  var params = {
    TableName: tableName,
    KeyConditionExpression: "userId = :uid",
    ExpressionAttributeValues: {
      ":uid": userId
    }
  };

  if (category) {
    params.FilterExpression = "category = :cat";
    params.ExpressionAttributeValues[":cat"] = category;
  }

  dynamodb.query(params, function(err, data) {
    if (err) {
      console.error("DynamoDB error:", err);
      return callback(null, {
        statusCode: 500,
        headers: {
          "Content-Type": "application/json",
          "Access-Control-Allow-Origin": "*"
        },
        body: JSON.stringify({ error: "Internal server error" })
      });
    }

    callback(null, {
      statusCode: 200,
      headers: {
        "Content-Type": "application/json",
        "Access-Control-Allow-Origin": "*"
      },
      body: JSON.stringify({
        items: data.Items,
        count: data.Count
      })
    });
  });
};

The proxy integration event object contains everything you need:

// What the event object looks like
{
  "resource": "/items",
  "path": "/items",
  "httpMethod": "GET",
  "headers": {
    "Authorization": "Bearer eyJ...",
    "Content-Type": "application/json"
  },
  "queryStringParameters": {
    "category": "electronics"
  },
  "pathParameters": {
    "itemId": "abc-123"
  },
  "requestContext": {
    "authorizer": {
      "claims": {
        "sub": "user-uuid-here",
        "email": "[email protected]"
      }
    },
    "identity": {
      "sourceIp": "203.0.113.42"
    }
  },
  "body": null,
  "isBase64Encoded": false
}

Critical detail: the body in your response must be a string. If you return an object, API Gateway will fail with a 502 Bad Gateway. Always JSON.stringify() your response body.

Request and Response Transformations

REST APIs support Velocity Template Language (VTL) mappings that transform requests before they hit your Lambda and transform responses before they go back to the client. This is useful when your Lambda expects a different shape than what the client sends.

Here is a mapping template that extracts fields from the request body and adds context:

## Request mapping template
#set($inputRoot = $input.path('$'))
{
  "userId": "$context.authorizer.claims.sub",
  "action": "$inputRoot.action",
  "payload": {
    "name": "$inputRoot.name",
    "email": "$inputRoot.email"
  },
  "metadata": {
    "sourceIp": "$context.identity.sourceIp",
    "requestTime": "$context.requestTime",
    "stage": "$context.stage"
  }
}

Response mapping templates work the same way:

## Response mapping template - strip internal fields
#set($inputRoot = $input.path('$'))
{
  "status": "success",
  "data": {
    "id": "$inputRoot.id",
    "name": "$inputRoot.name",
    "createdAt": "$inputRoot.createdAt"
  }
}

In practice, I avoid mapping templates in favor of Lambda proxy integration. VTL is painful to debug, poorly documented, and moves logic out of your codebase into the API Gateway configuration. If you need request shaping, do it in a thin Lambda layer.

Authorization Patterns

API Gateway supports four authorization mechanisms. Each fits different use cases.

API Keys

API keys identify callers and enforce usage plans. They are not a security mechanism by themselves — they are caller identification tokens. Always combine API keys with another auth method.

# SAM template snippet
Resources:
  MyApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prod
      Auth:
        ApiKeyRequired: true

  MyApiKey:
    Type: AWS::ApiGateway::ApiKey
    Properties:
      Name: partner-acme-corp
      Enabled: true

  MyUsagePlan:
    Type: AWS::ApiGateway::UsagePlan
    Properties:
      UsagePlanName: partner-tier
      Throttle:
        BurstLimit: 100
        RateLimit: 50
      Quota:
        Limit: 10000
        Period: MONTH
      ApiStages:
        - ApiId: !Ref MyApi
          Stage: prod

Clients send the key in the x-api-key header. Never put API keys in query strings — they end up in access logs and browser history.

Cognito User Pool Authorizer

For user-facing applications with Cognito authentication:

  MyCognitoAuthorizer:
    Type: AWS::ApiGateway::Authorizer
    Properties:
      Name: CognitoAuth
      Type: COGNITO_USER_POOLS
      IdentitySource: method.request.header.Authorization
      RestApiId: !Ref MyApi
      ProviderARNs:
        - !GetAtt UserPool.Arn

The client sends the Cognito ID token in the Authorization header. API Gateway validates the token and injects the claims into event.requestContext.authorizer.claims. No Lambda cold start for auth, which is a significant latency win.

Lambda Authorizer

Lambda authorizers (formerly custom authorizers) run a Lambda function to produce an IAM policy. There are two types: token-based (receives a single token) and request-based (receives the full request).

// authorizers/tokenAuthorizer.js
var jwt = require("jsonwebtoken");

var SECRET = process.env.JWT_SECRET;

exports.handler = function(event, context, callback) {
  var token = event.authorizationToken;

  if (!token || !token.startsWith("Bearer ")) {
    return callback("Unauthorized");
  }

  token = token.substring(7);

  try {
    var decoded = jwt.verify(token, SECRET);

    var policy = generatePolicy(decoded.sub, "Allow", event.methodArn, {
      userId: decoded.sub,
      role: decoded.role,
      email: decoded.email
    });

    callback(null, policy);
  } catch (err) {
    console.error("Token validation failed:", err.message);
    callback("Unauthorized");
  }
};

function generatePolicy(principalId, effect, resource, context) {
  var arnParts = resource.split(":");
  var apiGatewayArn = arnParts[5].split("/");
  var region = arnParts[3];
  var accountId = arnParts[4];
  var apiId = apiGatewayArn[0];
  var stage = apiGatewayArn[1];

  // Allow all methods on this API to avoid caching issues
  var resourceArn = "arn:aws:execute-api:" + region + ":" + accountId + ":" +
    apiId + "/" + stage + "/*";

  var policy = {
    principalId: principalId,
    policyDocument: {
      Version: "2012-10-17",
      Statement: [
        {
          Action: "execute-api:Invoke",
          Effect: effect,
          Resource: resourceArn
        }
      ]
    }
  };

  if (context) {
    policy.context = context;
  }

  return policy;
}

Important: the authorization result is cached by default (300 seconds). If your authorizer returns a policy for a specific method ARN (e.g., GET /items), subsequent requests to POST /items with the same token will be denied because the cached policy does not cover that method. Always generate a wildcard resource ARN as shown above.

IAM Authorization

IAM auth uses AWS Signature Version 4 to sign requests. It is ideal for service-to-service communication within AWS. The calling service needs execute-api:Invoke permission on the API resource.

// Calling an IAM-authorized API from another Lambda
var AWS = require("aws-sdk");
var https = require("https");
var url = require("url");

exports.handler = function(event, context, callback) {
  var endpoint = new AWS.Endpoint(process.env.API_ENDPOINT);
  var request = new AWS.HttpRequest(endpoint, process.env.AWS_REGION);

  request.method = "POST";
  request.path = "/prod/orders";
  request.headers["Content-Type"] = "application/json";
  request.headers["Host"] = endpoint.host;
  request.body = JSON.stringify({ orderId: "12345", status: "confirmed" });

  var signer = new AWS.Signers.V4(request, "execute-api");
  signer.addAuthorization(AWS.config.credentials, new Date());

  var parsedUrl = url.parse(endpoint.href);
  var options = {
    hostname: parsedUrl.hostname,
    path: request.path,
    method: request.method,
    headers: request.headers
  };

  var req = https.request(options, function(res) {
    var body = "";
    res.on("data", function(chunk) { body += chunk; });
    res.on("end", function() {
      callback(null, {
        statusCode: res.statusCode,
        body: body
      });
    });
  });

  req.write(request.body);
  req.end();
};

Usage Plans and Throttling

Usage plans are one of the strongest reasons to pick REST API over HTTP API. They let you define per-client rate limits and quotas.

API Gateway throttles at two levels:

  1. Account-level: 10,000 requests per second across all APIs in a region, with a 5,000 request burst. These are soft limits you can request increases for.
  2. Stage/method-level: Configured per stage or per method override.
  3. Usage plan-level: Per API key throttling and monthly quotas.
  FreeTierPlan:
    Type: AWS::ApiGateway::UsagePlan
    Properties:
      UsagePlanName: free-tier
      Description: Free tier - 1000 requests per month
      Throttle:
        BurstLimit: 10
        RateLimit: 5
      Quota:
        Limit: 1000
        Period: MONTH
      ApiStages:
        - ApiId: !Ref MyApi
          Stage: prod
          Throttle:
            /items/GET:
              BurstLimit: 5
              RateLimit: 2

  ProTierPlan:
    Type: AWS::ApiGateway::UsagePlan
    Properties:
      UsagePlanName: pro-tier
      Description: Pro tier - 100k requests per month
      Throttle:
        BurstLimit: 200
        RateLimit: 100
      Quota:
        Limit: 100000
        Period: MONTH
      ApiStages:
        - ApiId: !Ref MyApi
          Stage: prod

When a client exceeds their rate limit, API Gateway returns a 429 Too Many Requests response with a Retry-After header. Build your client SDKs to handle this gracefully with exponential backoff.

WebSocket API Setup

WebSocket APIs maintain persistent connections between clients and your backend, enabling real-time communication without polling. API Gateway manages the connection lifecycle and routes messages to Lambda functions based on route keys.

Every WebSocket API has three required routes:

  • $connect — fired when a client opens a connection
  • $disconnect — fired when a connection closes
  • $default — catch-all for messages that do not match a custom route

You define custom routes using a routeSelectionExpression, which is typically $request.body.action. When a client sends {"action": "sendMessage", "text": "hello"}, API Gateway routes it to the sendMessage route handler.

// handlers/wsConnect.js
var AWS = require("aws-sdk");
var dynamodb = new AWS.DynamoDB.DocumentClient();

var TABLE_NAME = process.env.CONNECTIONS_TABLE;

exports.handler = function(event, context, callback) {
  var connectionId = event.requestContext.connectionId;
  var userId = event.queryStringParameters
    ? event.queryStringParameters.userId
    : "anonymous";

  var params = {
    TableName: TABLE_NAME,
    Item: {
      connectionId: connectionId,
      userId: userId,
      connectedAt: new Date().toISOString(),
      ttl: Math.floor(Date.now() / 1000) + 86400 // 24 hour TTL
    }
  };

  dynamodb.put(params, function(err) {
    if (err) {
      console.error("Failed to store connection:", err);
      return callback(null, { statusCode: 500, body: "Failed to connect" });
    }

    console.log("Connected:", connectionId, "User:", userId);
    callback(null, { statusCode: 200, body: "Connected" });
  });
};
// handlers/wsDisconnect.js
var AWS = require("aws-sdk");
var dynamodb = new AWS.DynamoDB.DocumentClient();

var TABLE_NAME = process.env.CONNECTIONS_TABLE;

exports.handler = function(event, context, callback) {
  var connectionId = event.requestContext.connectionId;

  var params = {
    TableName: TABLE_NAME,
    Key: { connectionId: connectionId }
  };

  dynamodb.delete(params, function(err) {
    if (err) {
      console.error("Failed to remove connection:", err);
    }
    callback(null, { statusCode: 200, body: "Disconnected" });
  });
};

Connection Management for WebSocket

The critical piece of WebSocket APIs is sending messages back to connected clients. You use the ApiGatewayManagementApi client to post messages to specific connection IDs.

// handlers/wsSendMessage.js
var AWS = require("aws-sdk");
var dynamodb = new AWS.DynamoDB.DocumentClient();

var TABLE_NAME = process.env.CONNECTIONS_TABLE;

exports.handler = function(event, context, callback) {
  var domain = event.requestContext.domainName;
  var stage = event.requestContext.stage;
  var endpoint = "https://" + domain + "/" + stage;

  var apigw = new AWS.ApiGatewayManagementApi({ endpoint: endpoint });

  var body = JSON.parse(event.body);
  var message = {
    type: "message",
    text: body.text,
    from: body.userId,
    timestamp: new Date().toISOString()
  };

  // Get all active connections
  var scanParams = { TableName: TABLE_NAME };

  dynamodb.scan(scanParams, function(err, data) {
    if (err) {
      console.error("Scan error:", err);
      return callback(null, { statusCode: 500, body: "Error" });
    }

    var postCalls = data.Items.map(function(connection) {
      return sendToConnection(apigw, connection.connectionId, message);
    });

    Promise.all(postCalls).then(function() {
      callback(null, { statusCode: 200, body: "Sent" });
    }).catch(function(err) {
      console.error("Broadcast error:", err);
      callback(null, { statusCode: 500, body: "Error" });
    });
  });
};

function sendToConnection(apigw, connectionId, message) {
  return new Promise(function(resolve, reject) {
    var params = {
      ConnectionId: connectionId,
      Data: JSON.stringify(message)
    };

    apigw.postToConnection(params, function(err) {
      if (err) {
        if (err.statusCode === 410) {
          // Connection is stale, clean it up
          console.log("Stale connection:", connectionId);
          var dynamodb = new AWS.DynamoDB.DocumentClient();
          dynamodb.delete({
            TableName: process.env.CONNECTIONS_TABLE,
            Key: { connectionId: connectionId }
          }, function() {
            resolve();
          });
          return;
        }
        console.error("Post error for", connectionId, ":", err);
        reject(err);
        return;
      }
      resolve();
    });
  });
}

The 410 Gone check is not optional. Connections go stale all the time — network drops, client crashes, idle timeouts. If you do not clean up stale connections, your broadcasts will slow down and fail repeatedly.

WebSocket connections have a hard idle timeout of 10 minutes and a maximum connection duration of 2 hours. Send periodic pings from the client to keep connections alive:

// Client-side WebSocket with keepalive
var ws = new WebSocket("wss://abc123.execute-api.us-east-1.amazonaws.com/prod?userId=user1");

var keepAliveInterval;

ws.onopen = function() {
  console.log("Connected");
  keepAliveInterval = setInterval(function() {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ action: "ping" }));
    }
  }, 5 * 60 * 1000); // Ping every 5 minutes
};

ws.onmessage = function(event) {
  var data = JSON.parse(event.data);
  console.log("Received:", data);
};

ws.onclose = function() {
  console.log("Disconnected");
  clearInterval(keepAliveInterval);
  // Implement reconnection with backoff
  setTimeout(function() {
    console.log("Reconnecting...");
    // recreate connection
  }, 3000);
};

Stages and Deployments

A stage is a named reference to a deployment of your API. Common patterns include dev, staging, and prod. Each stage can have its own configuration:

  • Stage variables (like environment variables for your API)
  • Throttling settings
  • Logging configuration
  • Caching settings
  • Canary deployments
  ApiStage:
    Type: AWS::ApiGateway::Stage
    Properties:
      StageName: prod
      RestApiId: !Ref MyApi
      DeploymentId: !Ref ApiDeployment
      Variables:
        lambdaAlias: live
        tableName: items-prod
      MethodSettings:
        - HttpMethod: "*"
          ResourcePath: "/*"
          ThrottlingBurstLimit: 500
          ThrottlingRateLimit: 200
          LoggingLevel: INFO
          DataTraceEnabled: false
          MetricsEnabled: true

Stage variables are accessible in mapping templates as ${stageVariables.variableName} and in Lambda proxy integration as part of the event context. Use them to point different stages at different Lambda aliases or DynamoDB tables.

Canary deployments let you send a percentage of traffic to a new deployment while keeping the rest on the current version:

  ApiStage:
    Type: AWS::ApiGateway::Stage
    Properties:
      StageName: prod
      CanarySetting:
        PercentTraffic: 10
        UseStageCache: false

Custom Domain Names

Nobody wants to share https://abc123def4.execute-api.us-east-1.amazonaws.com/prod with their API consumers. Custom domains map a friendly domain to your API stage.

  ApiDomainName:
    Type: AWS::ApiGateway::DomainName
    Properties:
      DomainName: api.example.com
      CertificateArn: !Ref AcmCertificate
      EndpointConfiguration:
        Types:
          - EDGE
      SecurityPolicy: TLS_1_2

  ApiBasePathMapping:
    Type: AWS::ApiGateway::BasePathMapping
    Properties:
      DomainName: !Ref ApiDomainName
      RestApiId: !Ref MyApi
      Stage: prod
      BasePath: v1

This maps https://api.example.com/v1/items to your prod stage /items resource. For edge-optimized endpoints, create the ACM certificate in us-east-1 regardless of your API's region. For regional endpoints, the certificate must be in the same region as the API.

You can map multiple APIs or stages under the same domain using base path mappings:

  • api.example.com/v1 points to REST API v1 prod stage
  • api.example.com/v2 points to REST API v2 prod stage
  • api.example.com/ws points to WebSocket API prod stage

CORS Configuration

CORS with API Gateway is a consistent source of frustration. For Lambda proxy integration, your Lambda function must return CORS headers — API Gateway does not add them automatically.

Here is a utility module:

// utils/response.js
var ALLOWED_ORIGINS = [
  "https://app.example.com",
  "https://admin.example.com"
];

function buildResponse(statusCode, body, origin) {
  var allowedOrigin = "*";

  if (origin && ALLOWED_ORIGINS.indexOf(origin) !== -1) {
    allowedOrigin = origin;
  }

  return {
    statusCode: statusCode,
    headers: {
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin": allowedOrigin,
      "Access-Control-Allow-Headers": "Content-Type,Authorization,X-Api-Key",
      "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
      "Access-Control-Max-Age": "86400"
    },
    body: JSON.stringify(body)
  };
}

module.exports = { buildResponse: buildResponse };

You also need an OPTIONS method to handle preflight requests. In SAM, you can enable CORS globally:

  MyApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prod
      Cors:
        AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'"
        AllowHeaders: "'Content-Type,Authorization,X-Api-Key'"
        AllowOrigin: "'https://app.example.com'"
        MaxAge: "'86400'"

Note the nested quotes — SAM requires the values to be wrapped in single quotes inside the double quotes. Getting this wrong produces no error but CORS silently fails.

Request Validation

REST APIs can validate incoming requests before they reach your Lambda function, saving you execution time and cost on malformed requests.

  RequestValidator:
    Type: AWS::ApiGateway::RequestValidator
    Properties:
      Name: validate-body-and-params
      RestApiId: !Ref MyApi
      ValidateRequestBody: true
      ValidateRequestParameters: true

  ItemModel:
    Type: AWS::ApiGateway::Model
    Properties:
      RestApiId: !Ref MyApi
      ContentType: application/json
      Name: CreateItemModel
      Schema:
        $schema: "http://json-schema.org/draft-04/schema#"
        type: object
        required:
          - name
          - category
        properties:
          name:
            type: string
            minLength: 1
            maxLength: 200
          category:
            type: string
            enum:
              - electronics
              - clothing
              - books
              - other
          price:
            type: number
            minimum: 0
          description:
            type: string
            maxLength: 2000

When validation fails, API Gateway returns a 400 response with a message like Invalid request body before your Lambda is ever invoked. This is free request filtering.

Complete Working Example

Here is a full SAM template that deploys a REST API with Lambda proxy integration and a WebSocket API for real-time notifications.

# template.yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: REST and WebSocket API with Lambda integration

Globals:
  Function:
    Runtime: nodejs18.x
    Timeout: 30
    MemorySize: 256
    Environment:
      Variables:
        TABLE_NAME: !Ref ItemsTable
        CONNECTIONS_TABLE: !Ref ConnectionsTable

Resources:
  # ---- REST API ----
  RestApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prod
      Auth:
        DefaultAuthorizer: CognitoAuthorizer
        Authorizers:
          CognitoAuthorizer:
            UserPoolArn: !GetAtt UserPool.Arn
        ApiKeyRequired: false
      Cors:
        AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'"
        AllowHeaders: "'Content-Type,Authorization'"
        AllowOrigin: "'*'"

  GetItemsFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: handlers/getItems.handler
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref ItemsTable
      Events:
        GetItems:
          Type: Api
          Properties:
            RestApiId: !Ref RestApi
            Path: /items
            Method: GET

  CreateItemFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: handlers/createItem.handler
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ItemsTable
      Environment:
        Variables:
          WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/prod"
      Events:
        CreateItem:
          Type: Api
          Properties:
            RestApiId: !Ref RestApi
            Path: /items
            Method: POST

  # ---- WebSocket API ----
  WebSocketApi:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: NotificationsWebSocket
      ProtocolType: WEBSOCKET
      RouteSelectionExpression: "$request.body.action"

  ConnectRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref WebSocketApi
      RouteKey: "$connect"
      AuthorizationType: NONE
      Target: !Sub "integrations/${ConnectIntegration}"

  ConnectIntegration:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref WebSocketApi
      IntegrationType: AWS_PROXY
      IntegrationUri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${WsConnectFunction.Arn}/invocations"

  DisconnectRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref WebSocketApi
      RouteKey: "$disconnect"
      AuthorizationType: NONE
      Target: !Sub "integrations/${DisconnectIntegration}"

  DisconnectIntegration:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref WebSocketApi
      IntegrationType: AWS_PROXY
      IntegrationUri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${WsDisconnectFunction.Arn}/invocations"

  DefaultRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref WebSocketApi
      RouteKey: "$default"
      AuthorizationType: NONE
      Target: !Sub "integrations/${DefaultIntegration}"

  DefaultIntegration:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref WebSocketApi
      IntegrationType: AWS_PROXY
      IntegrationUri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${WsDefaultFunction.Arn}/invocations"

  WebSocketStage:
    Type: AWS::ApiGatewayV2::Stage
    Properties:
      ApiId: !Ref WebSocketApi
      StageName: prod
      AutoDeploy: true

  WsConnectFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: handlers/wsConnect.handler
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ConnectionsTable

  WsDisconnectFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: handlers/wsDisconnect.handler
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ConnectionsTable

  WsDefaultFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: handlers/wsDefault.handler
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ConnectionsTable
        - Statement:
            - Effect: Allow
              Action:
                - execute-api:ManageConnections
              Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*"

  # Lambda permissions for WebSocket API
  WsConnectPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref WsConnectFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*"

  WsDisconnectPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref WsDisconnectFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*"

  WsDefaultPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref WsDefaultFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*"

  # ---- DynamoDB Tables ----
  ItemsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: api-gateway-items
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: userId
          AttributeType: S
        - AttributeName: itemId
          AttributeType: S
      KeySchema:
        - AttributeName: userId
          KeyType: HASH
        - AttributeName: itemId
          KeyType: RANGE

  ConnectionsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: ws-connections
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: connectionId
          AttributeType: S
      KeySchema:
        - AttributeName: connectionId
          KeyType: HASH
      TimeToLiveSpecification:
        AttributeName: ttl
        Enabled: true

  # ---- Cognito ----
  UserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: api-users
      AutoVerifiedAttributes:
        - email

Outputs:
  RestApiUrl:
    Value: !Sub "https://${RestApi}.execute-api.${AWS::Region}.amazonaws.com/prod"
  WebSocketUrl:
    Value: !Sub "wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/prod"

The createItem handler demonstrates the REST-to-WebSocket bridge — when a new item is created via REST, it notifies all WebSocket clients:

// handlers/createItem.js
var AWS = require("aws-sdk");
var dynamodb = new AWS.DynamoDB.DocumentClient();
var crypto = require("crypto");

var TABLE_NAME = process.env.TABLE_NAME;
var CONNECTIONS_TABLE = process.env.CONNECTIONS_TABLE;
var WS_ENDPOINT = process.env.WEBSOCKET_ENDPOINT;

exports.handler = function(event, context, callback) {
  var userId = event.requestContext.authorizer.claims.sub;
  var body = JSON.parse(event.body);

  var item = {
    userId: userId,
    itemId: crypto.randomUUID(),
    name: body.name,
    category: body.category,
    price: body.price || 0,
    createdAt: new Date().toISOString()
  };

  var params = {
    TableName: TABLE_NAME,
    Item: item
  };

  dynamodb.put(params, function(err) {
    if (err) {
      console.error("DynamoDB error:", err);
      return callback(null, {
        statusCode: 500,
        headers: { "Access-Control-Allow-Origin": "*" },
        body: JSON.stringify({ error: "Failed to create item" })
      });
    }

    // Notify WebSocket clients
    notifyClients({ type: "itemCreated", item: item }, function(notifyErr) {
      if (notifyErr) {
        console.error("Notification error:", notifyErr);
        // Do not fail the request if notification fails
      }

      callback(null, {
        statusCode: 201,
        headers: {
          "Content-Type": "application/json",
          "Access-Control-Allow-Origin": "*"
        },
        body: JSON.stringify(item)
      });
    });
  });
};

function notifyClients(message, done) {
  var apigw = new AWS.ApiGatewayManagementApi({ endpoint: WS_ENDPOINT });

  dynamodb.scan({ TableName: CONNECTIONS_TABLE }, function(err, data) {
    if (err) return done(err);
    if (!data.Items || data.Items.length === 0) return done();

    var promises = data.Items.map(function(conn) {
      return apigw.postToConnection({
        ConnectionId: conn.connectionId,
        Data: JSON.stringify(message)
      }).promise().catch(function(postErr) {
        if (postErr.statusCode === 410) {
          return dynamodb.delete({
            TableName: CONNECTIONS_TABLE,
            Key: { connectionId: conn.connectionId }
          }).promise();
        }
        console.error("Post to", conn.connectionId, "failed:", postErr.message);
      });
    });

    Promise.all(promises).then(function() { done(); }).catch(done);
  });
}

Deploy with:

sam build
sam deploy --guided --stack-name api-gateway-demo --capabilities CAPABILITY_IAM

Common Issues and Troubleshooting

1. 502 Bad Gateway — Malformed Lambda Response

Execution failed due to configuration error: Malformed Lambda proxy response

This is the most common API Gateway error. Your Lambda proxy integration response must have statusCode as an integer and body as a string. Returning body: { message: "hello" } instead of body: JSON.stringify({ message: "hello" }) triggers this every time.

Fix: always stringify the body and ensure statusCode is a number, not a string.

2. Missing Authentication Token for OPTIONS

{"message": "Missing Authentication Token"}

This happens when your API requires authorization but the OPTIONS preflight request does not have a corresponding method configured. Preflight requests do not carry Authorization headers. Configure the OPTIONS method with NONE authorization:

  OptionsMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      AuthorizationType: NONE
      HttpMethod: OPTIONS
      # ... mock integration returning CORS headers

With SAM, the Cors property on AWS::Serverless::Api handles this automatically, but only if you configure it correctly with the nested quotes.

3. WebSocket 410 Gone on postToConnection

GoneException: Unable to send message. Connection abc123 is no longer available.

This means the connection ID is stale. The client disconnected but your connections table still has the entry. Always handle 410 by deleting the stale connection from your store, as shown in the connection management section. Enable TTL on your connections table as a safety net.

4. Lambda Authorizer Caching Causes 403 on Different Methods

{"message": "User is not authorized to access this resource with an explicit deny"}

Your Lambda authorizer returned a policy for a specific resource ARN like arn:aws:execute-api:us-east-1:123456:abc/prod/GET/items. API Gateway cached that policy. When the same client hits POST /items, the cached policy does not match and API Gateway denies it. Use a wildcard resource ARN in your policy: arn:aws:execute-api:us-east-1:123456:abc/prod/*. Or disable authorizer caching by setting the TTL to 0, at the cost of an extra Lambda invocation per request.

5. CORS Errors Despite Correct Headers

Access to XMLHttpRequest at 'https://api.example.com/items' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.

If your Lambda throws an unhandled exception, API Gateway returns a 502 without your CORS headers. The browser sees the missing CORS headers and reports it as a CORS error, hiding the real 502. Always wrap your handler in a try/catch that returns a proper response with CORS headers, even for errors. Also check that your Lambda's Gateway Response is configured to include CORS headers for 4XX and 5XX responses:

  GatewayResponse4XX:
    Type: AWS::ApiGateway::GatewayResponse
    Properties:
      RestApiId: !Ref MyApi
      ResponseType: DEFAULT_4XX
      ResponseParameters:
        gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
        gatewayresponse.header.Access-Control-Allow-Headers: "'Content-Type,Authorization'"

  GatewayResponse5XX:
    Type: AWS::ApiGateway::GatewayResponse
    Properties:
      RestApiId: !Ref MyApi
      ResponseType: DEFAULT_5XX
      ResponseParameters:
        gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
        gatewayresponse.header.Access-Control-Allow-Headers: "'Content-Type,Authorization'"

Best Practices

  • Use Lambda proxy integration unless you have a specific reason not to. Non-proxy integration with VTL mapping templates adds complexity, is harder to test locally, and moves logic out of version control.

  • Always return CORS headers from your Lambda function, even in error responses. Configure Gateway Responses for 4XX and 5XX to add CORS headers for cases where API Gateway itself generates the error before reaching your Lambda.

  • Enable CloudWatch logging on your API stages. Set the logging level to INFO for production and ERROR for cost-sensitive environments. Execution logs are invaluable when debugging integration issues.

  • Set up CloudWatch alarms on 4XX and 5XX error rates. A spike in 5XX errors usually means a Lambda deployment broke the response format. A spike in 4XX errors could indicate a broken client release or an attack.

  • Use request validation on REST APIs to reject malformed requests before Lambda. Every request that fails validation is a Lambda invocation you do not pay for. Define JSON schemas for POST and PUT request bodies.

  • Implement exponential backoff and retry logic in your API clients. API Gateway throttling returns 429 status codes. Well-behaved clients back off; poorly written clients hammer the API harder, making things worse for everyone.

  • Use DynamoDB TTL on your WebSocket connections table. The $disconnect route is not guaranteed to fire (network issues, Lambda errors). TTL ensures stale connections are eventually cleaned up even if the disconnect handler fails.

  • Version your APIs using stages or base path mappings, not URL path segments. Use api.example.com/v1/items via base path mapping rather than building /v1/items into your route definitions. This keeps your Lambda code version-agnostic.

  • Keep Lambda authorizer policies broad (wildcard resources). Narrow policies combined with authorizer caching produce confusing 403 errors on some methods but not others. Cache the policy for the entire API, not a single method.

  • Set appropriate timeouts. API Gateway has a maximum integration timeout of 29 seconds. If your Lambda needs more time, consider an asynchronous pattern — accept the request, return 202, and process in the background.

References

Powered by Contentful