AWS Lambda JSON: Events, Responses, and API Gateway Integration

Last updated:

AWS Lambda handles JSON at every layer: the event object delivered by the runtime, the response shape expected by API Gateway, and the event structures from S3, SQS, and DynamoDB Streams. Each trigger has a distinct JSON schema, and several have subtle gotchas — particularly API Gateway's requirement that the response body be a string, not an object. This guide covers all of them with working TypeScript and Python examples.

Lambda Handler Structure and Event Parsing

The Lambda runtime deserializes the trigger payload into a JSON object before calling your handler. For API Gateway proxy integration, the HTTP request body arrives inside event.body as a raw string — you must parse it yourself.

import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'

export const handler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  // API Gateway passes body as a JSON string
  if (!event.body) {
    return { statusCode: 400, body: JSON.stringify({ error: 'Missing body' }) }
  }

  let payload: { name: string; email: string }
  try {
    payload = JSON.parse(event.body)
  } catch {
    return { statusCode: 400, body: JSON.stringify({ error: 'Invalid JSON' }) }
  }

  const result = { id: Date.now(), name: payload.name, email: payload.email }

  return {
    statusCode: 201,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(result),
  }
}

Python equivalent:

import json

def handler(event, context):
    # API Gateway proxy: body is a JSON string
    body_str = event.get('body', '{}')
    try:
        body = json.loads(body_str)
    except json.JSONDecodeError:
        return {
            'statusCode': 400,
            'body': json.dumps({'error': 'Invalid JSON body'}),
        }

    name = body.get('name', 'World')
    return {
        'statusCode': 200,
        'headers': {'Content-Type': 'application/json'},
        'body': json.dumps({'message': f'Hello, {name}!'}),
    }

API Gateway Proxy Response Structure

API Gateway requires a specific JSON shape from your Lambda. The body field must always be a string — pass it through JSON.stringify() before returning.

{
  "statusCode": 200,
  "headers": {
    "Content-Type": "application/json",
    "Access-Control-Allow-Origin": "*"
  },
  "body": "{\"message\": \"Success\", \"id\": 42}",
  "isBase64Encoded": false
}
WrongCorrectWhy
body: { id: 1 }body: JSON.stringify({ id: 1 })body must be a string
statusCode: "200"statusCode: 200must be integer
Omit headersInclude Content-Typebrowser needs it
Return undefinedReturn response objectLambda requires a return value

S3 Event Structure

S3 events deliver bucket and object metadata in event.Records. Decode the object key before using it — S3 URL-encodes special characters and replaces spaces with +.

import { S3Event } from 'aws-lambda'
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'

const s3 = new S3Client({})

export const handler = async (event: S3Event) => {
  for (const record of event.Records) {
    const bucket = record.s3.bucket.name
    const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '))

    // Fetch the JSON file that was uploaded
    const response = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
    const bodyStr = await response.Body?.transformToString('utf-8')
    if (!bodyStr) continue

    const data = JSON.parse(bodyStr) as Record<string, unknown>
    console.log('Processed:', key, 'keys:', Object.keys(data).length)
  }
}

Key S3 event JSON fields:

{
  "Records": [{
    "eventSource": "aws:s3",
    "eventName": "ObjectCreated:Put",
    "s3": {
      "bucket": { "name": "my-bucket", "arn": "arn:aws:s3:::my-bucket" },
      "object": {
        "key": "uploads/data.json",
        "size": 1024,
        "eTag": "abc123"
      }
    }
  }]
}

SQS Event Structure

SQS delivers messages in event.Records, where each record.body is a string — even when the original message was JSON. Parse it individually and handle errors carefully to avoid redriving entire batches.

import { SQSEvent } from 'aws-lambda'

interface OrderEvent {
  orderId: string
  customerId: string
  total: number
  items: Array<{ productId: string; qty: number }>
}

export const handler = async (event: SQSEvent) => {
  for (const record of event.Records) {
    let order: OrderEvent
    try {
      order = JSON.parse(record.body) as OrderEvent
    } catch {
      console.error('Invalid SQS message body:', record.body)
      continue  // skip — don't throw (avoids redrive loop for bad messages)
    }

    console.log(`Processing order ${order.orderId} for customer ${order.customerId}`)
    await processOrder(order)
  }
}

// Note: if your Lambda throws for a message, SQS will retry it up to maxReceiveCount
// Partial batch failure: return { batchItemFailures: [{ itemIdentifier: record.messageId }] }

DynamoDB JSON and Streams

DynamoDB Streams deliver items in DynamoDB JSON — a typed format where each value is wrapped with its type descriptor. Use unmarshall() from @aws-sdk/util-dynamodb to convert to plain JavaScript objects.

import { DynamoDBStreamEvent } from 'aws-lambda'
import { unmarshall } from '@aws-sdk/util-dynamodb'

// DynamoDB stores items in DynamoDB JSON format (typed attribute values)
// { "id": { "S": "123" }, "age": { "N": "30" } }
// unmarshall() converts to plain JS: { id: "123", age: 30 }

export const handler = async (event: DynamoDBStreamEvent) => {
  for (const record of event.Records) {
    if (record.eventName === 'INSERT' && record.dynamodb?.NewImage) {
      const item = unmarshall(record.dynamodb.NewImage as Record<string, any>)
      console.log('New item:', JSON.stringify(item, null, 2))
    }
    if (record.eventName === 'REMOVE' && record.dynamodb?.OldImage) {
      const removed = unmarshall(record.dynamodb.OldImage as Record<string, any>)
      console.log('Deleted:', removed.id)
    }
  }
}
DynamoDB TypeSymbolExampleUnmarshalled
StringS{"S": "hello"}"hello"
NumberN{"N": "42"}42
BooleanBOOL{"BOOL": true}true
NullNULL{"NULL": true}null
MapM{"M": {"k": {"S": "v"}}}{"k": "v"}
ListL{"L": [{"N": "1"}]}[1]

Environment Variables and JSON Config

Lambda environment variables are always strings. Store JSON config as a serialized string and parse it once at module level so the parsed object is reused across warm invocations.

// Lambda env vars are strings — parse JSON config at cold start
const CONFIG = JSON.parse(process.env.APP_CONFIG ?? '{}') as {
  dbHost: string
  cacheTimeout: number
  featureFlags: Record<string, boolean>
}

// Lazy-parsed (re-used across warm invocations)
export const handler = async (event: unknown) => {
  if (CONFIG.featureFlags.betaFeature) {
    // use beta feature
  }
  return { statusCode: 200, body: JSON.stringify({ host: CONFIG.dbHost }) }
}

// In Lambda console or SAM template:
// APP_CONFIG = {"dbHost":"db.internal","cacheTimeout":300,"featureFlags":{"betaFeature":true}}

Common Lambda JSON Pitfalls and Solutions

PitfallSymptomFix
body not stringifiedAPI GW 502 Bad GatewayJSON.stringify(result) before assigning to body
event.body not parsedBody is a string, not objectJSON.parse(event.body) at top of handler
Large responses413 Payload Too LargeAPI GW max 10MB; compress or paginate
Missing CORS headersBrowser blocks responseAdd Access-Control-Allow-Origin to headers
Circular JSONTypeError in JSON.stringifyUse a custom replacer or avoid circular refs
SQS bad message loopInfinite retryCatch parse errors, return batchItemFailures
// CORS-enabled response helper
function jsonResponse(statusCode: number, body: unknown, cors = true): APIGatewayProxyResult {
  return {
    statusCode,
    headers: {
      'Content-Type': 'application/json',
      ...(cors ? { 'Access-Control-Allow-Origin': '*' } : {}),
    },
    body: JSON.stringify(body),
  }
}

FAQ

Why does API Gateway send event.body as a string, not a JSON object?

API Gateway HTTP proxy integration passes the raw request body as a string to maintain flexibility — Lambda can receive any content type (JSON, XML, binary, form-encoded), so API Gateway cannot assume the body is always valid JSON. Your handler must call JSON.parse(event.body) manually. Always check event.headers["Content-Type"] first to confirm the body is JSON before parsing. If event.isBase64Encoded is true, base64-decode the body before parsing. A null or undefined event.body means the request had no body — guard against this before calling JSON.parse.

How do I return a JSON response from a Lambda function?

For API Gateway proxy integration, return { statusCode: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }. The body field MUST be a string — passing a plain object causes a 502 Bad Gateway error from API Gateway. If you are invoking Lambda directly (not via API Gateway), you can return any JSON-serializable value and the runtime will serialize it automatically. Always set the Content-Type header so browsers and API clients know how to interpret the response.

How do I handle JSON parsing errors in Lambda?

Wrap JSON.parse in a try/catch block and return an appropriate error response rather than letting the exception propagate. In Node.js, JSON.parse throws SyntaxError on invalid input; in Python, json.loads raises json.JSONDecodeError. For API Gateway handlers, return { statusCode: 400, body: JSON.stringify({ error: "Invalid JSON body" }) }. Always log the raw body string before parsing so you can inspect bad payloads in CloudWatch. For SQS triggers, avoid throwing on parse errors — return batchItemFailures instead to prevent the bad message from being retried as part of the entire batch.

How do I parse S3 event JSON in Lambda?

The S3 event delivers metadata in event.Records[0].s3.bucket.name and event.Records[0].s3.object.key. The object key is URL-encoded — always decode it with decodeURIComponent(key.replace(/\+/g, " ")) before using it in an SDK call. To read the actual file content, call GetObjectCommand and convert the response body stream to a string. Then JSON.parse the string. Never assume the uploaded file is valid JSON — wrap the parse in try/catch and log errors. Process each record in a loop since a single S3 event can contain multiple records.

What is DynamoDB JSON and how is it different from regular JSON?

DynamoDB JSON uses typed attribute descriptors to encode each value: {"S": "hello"} for strings, {"N": "42"} for numbers (always a string!), {"BOOL": true} for booleans, {"NULL": true} for null, {"L": [...]} for lists, and {"M": {...}} for maps. DynamoDB Streams events always deliver items in this format. Use unmarshall() from @aws-sdk/util-dynamodb to convert DynamoDB JSON to plain JavaScript objects, and marshall() to convert back.

How do I pass JSON configuration to a Lambda function?

Store the JSON config as a string in a Lambda environment variable (max 4KB total for all env vars). In the Lambda console or your SAM/CDK template, set APP_CONFIG to a JSON string. In your handler, call JSON.parse(process.env.APP_CONFIG ?? '')at module level — outside the handler function — so parsing happens once per cold start and the result is reused across all warm invocations. For larger configs (>4KB), use AWS SSM Parameter Store or Secrets Manager. For dynamic feature flags, consider AWS AppConfig, which supports safe deployments and rollbacks.

What is the maximum JSON response size from API Gateway Lambda?

API Gateway has a 10MB payload limit for both HTTP API and REST API Lambda proxy integrations. WebSocket APIs are limited to 128KB per message. If your JSON response exceeds 10MB, upload the data to S3 and return a pre-signed URL in the response instead. Alternatively, enable Lambda response streaming (supported with Lambda function URLs and HTTP APIs), which raises the limit to 20MB. For large but repetitive JSON, gzip compression typically achieves 70–90% size reduction — set Content-Encoding: gzip in the response headers and return base64-encoded compressed data with isBase64Encoded: true.

How do I enable CORS for JSON responses from Lambda via API Gateway?

Add Access-Control-Allow-Origin: * (or a specific origin) to every Lambda response's headers object. You must also handle the preflight OPTIONS request — either by adding an OPTIONS route that returns the CORS headers with a 200 status, or by enabling CORS in the API Gateway console/configuration, which handles OPTIONS automatically. For Lambda function URLs, configure CORS in the function URL settings. Missing or incorrect CORS headers silently cause browsers to block the response with a network error, which can be misleading to debug.

Definitions

event object
The JSON-parsed input passed to a Lambda handler; shape depends on the trigger (API Gateway, S3, SQS, DynamoDB Streams, EventBridge); always inspect the raw event JSON in CloudWatch logs when debugging.
proxy integration
API Gateway mode where the full HTTP request (headers, body, path, method) is forwarded to Lambda as a JSON event; the Lambda must return a specific JSON response shape with statusCode, headers, and body as a string.
DynamoDB JSON
A JSON encoding that annotates each value with its DynamoDB type descriptor (S, N, BOOL, L, M, NULL); the @aws-sdk/util-dynamodb package provides marshall/unmarshall helpers to convert between DynamoDB JSON and plain JavaScript objects.
batchItemFailures
Partial batch response format for SQS Lambda triggers; return a list of failed messageIds so only those messages are retried rather than the entire batch, preventing good messages from being reprocessed.
cold start
The latency penalty when Lambda initializes a new execution environment; module-level code (including JSON.parse of env vars) runs once per cold start and is cached for subsequent warm invocations.

Further reading and primary sources

  • AWS Lambda Developer GuideOfficial reference for Lambda handler signatures, event formats, environment variables, and runtime behavior
  • API Gateway Payload FormatsHTTP API Lambda proxy request and response payload format, including body stringification requirements
  • AWS SDK v3 for JavaScriptAWS SDK v3 reference including S3Client, DynamoDB unmarshall, and SQS clients used in Lambda handlers