API Gateway doesn’t just return errors, it transforms them.

Let’s say you have a Lambda function that might fail. Normally, if Lambda throws an error, API Gateway will just pass that error back to the client with a generic 500 Internal Server Error status code and a default JSON body. Not very helpful for debugging, and definitely not what a polished API should expose.

Here’s a simple API Gateway setup:

{
  "openapi": "3.0.1",
  "info": {
    "title": "Error Demo API",
    "version": "1.0.0"
  },
  "paths": {
    "/fail": {
      "get": {
        "summary": "This endpoint will always fail",
        "responses": {
          "200": {
            "description": "Success (won't happen)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": { "type": "string" }
                  }
                }
              }
            }
          },
          "default": {
            "description": "Default error response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": { "type": "string" }
                  }
                }
              }
            }
          }
        },
        "x-amazon-apigateway-integration": {
          "type": "aws_proxy",
          "integrationHttpMethod": "POST",
          "uri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:my-failing-lambda/invocations",
          "passthroughBehavior": "when_no_templates"
        }
      }
    }
  }
}

If my-failing-lambda throws an error like this:

raise Exception("Something went wrong in the Lambda!")

API Gateway, by default, will return something like this to the client:

{
  "message": "Internal server error"
}

with a 500 Internal Server Error status.

This is where response customization comes in. You can define specific responses for different HTTP status codes within your API Gateway definition. For each response, you can then specify mappings for the body.

Let’s say your Lambda function actually returns a structured error object, like this:

import json

def lambda_handler(event, context):
    return {
        "statusCode": 400,
        "body": json.dumps({
            "errorType": "ValidationError",
            "errorMessage": "The provided 'id' is invalid.",
            "details": {
                "field": "id",
                "value": "abc-123"
            }
        })
    }

You can configure API Gateway to map this specific Lambda output to a more client-friendly response. In your OpenAPI definition, you’d modify the responses section for your /fail endpoint. Instead of relying on the default response, you’d define specific responses for the status codes your Lambda might return.

Here’s how you’d do it in OpenAPI 3.0:

paths:
  /fail:
    get:
      summary: This endpoint will return structured errors
      responses:
        "200":
          description: Success (won't happen)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        "400": # Targeting the specific error code from Lambda
          description: Bad Request
          content:
            application/json:
              schema:
                type: object
                properties:
                  errorCode:
                    type: string
                  userMessage:
                    type: string
                  details:
                    type: object
        "500": # Catch-all for other Lambda errors
          description: Internal Server Error
          content:
            application/json:
              schema:
                type: object
                properties:
                  errorCode:
                    type: string
                  userMessage:
                    type: string
      x-amazon-apigateway-integration:
        type: aws_proxy
        integrationHttpMethod: POST
        uri: arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:my-failing-lambda/invocations
        responses:
          # Mapping for the 400 response from Lambda
          400:
            statusCode: 400
            responseTemplates:
              application/json: |
                #set($inputRoot = $input.path('$'))
                {
                  "errorCode": "VALIDATION_ERROR",
                  "userMessage": "There was a problem with your request. Please check the details.",
                  "details": {
                    "field": "$inputRoot.details.field",
                    "value": "$inputRoot.details.value"
                  }
                }
          # Mapping for any other non-200 response from Lambda
          default:
            statusCode: 500
            responseTemplates:
              application/json: |
                #set($inputRoot = $input.path('$'))
                {
                  "errorCode": "UNEXPECTED_ERROR",
                  "userMessage": "An unexpected error occurred. Please try again later.",
                  "errorDetails": "$input.json('$')"
                }

When the Lambda function returns the 400 response, API Gateway intercepts it. The responses section within the x-amazon-apigateway-integration block defines how to transform this. The 400 key matches the Lambda’s statusCode. The responseTemplates then use Velocity Template Language (VTL) to construct the new JSON body. $inputRoot.details.field pulls the field value from the Lambda’s JSON response body. The default mapping catches any other status codes (like a 500 from Lambda itself) and maps them to an API Gateway-generated 500.

The result for the client, when the Lambda returns its structured 400 error, is now:

{
  "errorCode": "VALIDATION_ERROR",
  "userMessage": "There was a problem with your request. Please check the details.",
  "details": {
    "field": "id",
    "value": "abc-123"
  }
}

with a 400 Bad Request status code.

This allows you to decouple your internal error handling from the API contract you expose to your users. You can have complex, detailed errors inside your Lambda, but present a clean, consistent, and user-friendly error structure at the API Gateway level.

The most powerful aspect here is the default response template in the integration. It allows you to catch any unexpected error from your backend integration and map it to a consistent, safe error response for your clients, preventing raw backend errors from leaking out. You can even use $input.json('$') to include the raw error from the backend if you want to log it, but present a sanitized version to the user.

The next thing you’ll likely want to tackle is how to handle different request methods (POST, PUT, DELETE) and how their error responses might need distinct transformations.

Want structured learning?

Take the full Apigateway course →