When Claude wants to run code, you need a webhook it can call. Most teams slam together a Lambda URL, but that skips critical security and versioning features. Learn how API Gateway + Lambda gives you a production‑grade, type‑safe bridge for Claude’s function calls.
Claude (or any large language model, LLM) can generate a piece of JSON that describes “I want to call a function named addUser with these arguments”.
function calling is the process where the model sends that JSON to a webhook – an HTTP endpoint you control – and you turn the JSON into real work (e.g., a database write).
Key terms
name
, email
, etc. Claude expects the response to follow a tiny schema:
{
"status": "success",
"result": { "userId": "1234" }
}
If the payload is malformed or the endpoint rejects the request, Claude will fall back to a generic answer, which defeats the purpose of function calling.
In plain English:Claude is trying to hand you a note with a request. You need a reliable, secure mailbox (the endpoint) that can read the note, do the work, and hand back a reply Claude understands.
A Lambda URL is tempting because it’s a single line of code: aws lambda add-permission … && aws lambda create-function-url-config …
. It works for quick demos, but production systems need more than “just works”.
| Feature | Lambda URL | API Gateway |
|---|---|---|
| Authentication | ||
| Optional IAM auth only; no JWT support | Built‑in JWT authorizers (Cognito, OIDC) | |
| Throttling | ||
| Global per‑account limit | Per‑stage, per‑method limits | |
| Observability | ||
| CloudWatch logs only | Access logs, execution logs, metrics, tracing | |
| Versioning | ||
| You must manage separate URLs per version | Stages (dev, prod) let you roll out safely | |
| CORS (cross‑origin) | ||
| Manual header handling | Automatic CORS configuration | |
| Timeout | ||
| 30 s max (cannot be extended) | Same, but you can set up retries and dead‑letter queues |
When you create a REST API (v1) in API Gateway without enabling Lambda proxy integration, API Gateway flattens the request body. Any nested object inside Claude’s arguments is stripped away, so the Lambda receives an empty {}
. The model’s request silently disappears, and debugging becomes a nightmare.
Fix: enable Lambda proxy integration or write a mapping template that preserves the JSON structure.
Tip:Think of the non‑proxy mode as a mailroom that only forwards the envelope, not the letter inside. Proxy mode hands the whole envelope (including the note) to the kitchen.
Type safety means the compiler will tell you when you mistype a field or pass the wrong shape to the AWS SDK. In TypeScript we can achieve that with Zod for runtime validation and the satisfies
keyword for compile‑time guarantees.
// src/types.ts
import { z } from "zod";
/**
* Claude sends a function call with a name and an arguments object.
* We describe that shape with Zod so we can validate it at runtime.
*/
export const ClaudeAddUserSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().int().positive().optional(),
});
/**
* The full request body Claude will send.
*/
export const ClaudeRequestSchema = z.object({
function: z.literal("addUser"),
arguments: ClaudeAddUserSchema,
});
/**
* Export TypeScript types derived from the schemas.
*/
export type ClaudeAddUser = z.infer<typeof ClaudeAddUserSchema>;
export type ClaudeRequest = z.infer<typeof ClaudeRequestSchema>;
js
// src/handler.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
import { ClaudeRequestSchema, ClaudeAddUser } from "./types";
/**
* Create a DynamoDB client once so it can be reused across invocations.
*/
const ddb = new DynamoDBClient({});
/**
* The Lambda entry point that API Gateway will invoke.
*/
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
// -----------------------------------------------------------------
// 1️⃣ Parse and validate Claude's JSON payload.
// -----------------------------------------------------------------
let payload: ClaudeAddUser;
try {
// The body arrives as a string; JSON.parse converts it to an object.
const parsed = JSON.parse(event.body ?? "{}");
// Zod validates shape and throws if something is missing or wrong.
payload = ClaudeRequestSchema.parse(parsed).arguments;
} catch (err) {
// If validation fails, respond with Claude's error format.
return {
statusCode: 400,
body: JSON.stringify({
status: "error",
message: "Invalid request payload",
details: err instanceof Error ? err.message : String(err),
}),
};
}
// -----------------------------------------------------------------
// 2️⃣ Build a type‑safe PutItem command for DynamoDB.
// -----------------------------------------------------------------
// The satisfies keyword makes sure the object conforms to PutItemCommandInput.
const putCommand = {
TableName: "Users",
Item: {
userId: { S: crypto.randomUUID() }, // primary key
name: { S: payload.name },
email: { S: payload.email },
// age is optional; only add it if present.
...(payload.age && { age: { N: payload.age.toString() } }),
},
} satisfies PutItemCommand["input"]; // compile‑time check
// Execute the command.
await ddb.send(new PutItemCommand(putCommand));
// -----------------------------------------------------------------
// 3️⃣ Respond in Claude's expected schema.
// -----------------------------------------------------------------
return {
statusCode: 200,
body: JSON.stringify({
status: "success",
result: { userId: putCommand.Item.userId.S },
}),
};
};
What the code does, step by step
PutItemCommand
object. Using satisfies
tells TypeScript, “this object must match the shape the SDK expects”; if you miss a required field, the compiler screams.
Key takeaway:Combining Zod (runtime) andsatisfies
(compile‑time) gives you confidence that the data Claude sends is exactly what DynamoDB expects.
Now we have a Lambda that can talk to Claude, but we still need an HTTP endpoint that Claude can reach. API Gateway is the glue that turns a raw URL into a type‑safe, versioned, observable service.
// scripts/createApi.ts
import {
APIGatewayClient,
CreateRestApiCommand,
GetResourcesCommand,
CreateResourceCommand,
PutMethodCommand,
PutIntegrationCommand,
CreateDeploymentCommand,
} from "@aws-sdk/client-api-gateway";
const client = new APIGatewayClient({});
/**
* Helper to fetch the root resource ID ("/").
*/
async function getRootResourceId(apiId: string): Promise<string> {
const resources = await client.send(
new GetResourcesCommand({ restApiId: apiId })
);
const root = resources.items?.find((r) => r.path === "/");
if (!root?.id) throw new Error("Root resource not found");
return root.id;
}
/**
* Main function that creates the API, the /addUser resource,
* and wires it to the Lambda using proxy integration.
*/
export async function createClaudeApi(lambdaArn: string) {
// 1️⃣ Create the API.
const api = await client.send(
new CreateRestApiCommand({
name: "ClaudeFunctionCallingAPI",
description: "Endpoint for Claude to invoke addUser",
endpointConfiguration: { types: ["REGIONAL"] },
})
);
const apiId = api.id!;
console.log(`Created API ${apiId}`);
// 2️⃣ Create /addUser resource.
const rootId = await getRootResourceId(apiId);
const addUserRes = await client.send(
new CreateResourceCommand({
restApiId: apiId,
parentId: rootId,
pathPart: "addUser",
})
);
const addUserId = addUserRes.id!;
// 3️⃣ Add POST method (Claude always POSTs JSON).
await client.send(
new PutMethodCommand({
restApiId: apiId,
resourceId: addUserId,
httpMethod: "POST",
authorizationType: "NONE", // we will add Cognito authorizer later
})
);
// 4️⃣ Wire the method to Lambda using **proxy** integration.
await client.send(
new PutIntegrationCommand({
restApiId: apiId,
resourceId: addUserId,
httpMethod: "POST",
type: "AWS_PROXY", // critical! preserves nested JSON
integrationHttpMethod: "POST",
uri: `arn:aws:apigateway:${process.env.AWS_REGION}:lambda:path/2015-03-31/functions/${lambdaArn}/invocations`,
})
);
// 5️⃣ Deploy the API to a stage called "prod".
await client.send(
new CreateDeploymentCommand({
restApiId: apiId,
stageName: "prod",
description: "Initial deployment for Claude function calls",
})
);
console.log(`API deployed to https://${apiId}.execute-api.${process.env.AWS_REGION}.amazonaws.com/prod/addUser`);
}
Why each step matters
prod
) gives you a stable URL while you can still create a dev
stage for testing.
Tip:If you forget to settype: "AWS_PROXY"
you’ll see Claude’s arguments disappear in CloudWatch logs.
// scripts/allowApiGateway.ts
import { LambdaClient, AddPermissionCommand } from "@aws-sdk/client-lambda";
const client = new LambdaClient({});
export async function grantApiInvoke(lambdaArn: string, apiArn: string) {
await client.send(
new AddPermissionCommand({
FunctionName: lambdaArn,
StatementId: "APIGatewayInvoke",
Action: "lambda:InvokeFunction",
Principal: "apigateway.amazonaws.com",
SourceArn: `${apiArn}/*/*`,
})
);
console.log("Permission granted for API Gateway to invoke Lambda");
}
Now Claude can call https://{apiId}.execute-api.{region}.amazonaws.com/prod/addUser
and the request will reach the Lambda with the full payload intact.
Claude itself does not have a built‑in identity system, but you can protect the webhook behind an Amazon Cognito User Pool. Claude can be given a short‑lived JWT (JSON Web Token) that the authorizer checks before forwarding the request.
ClaudeWebhookPool
ClaudeWebhookClient
(no secret)
// scripts/addCognitoAuthorizer.ts
import {
APIGatewayClient,
CreateAuthorizerCommand,
UpdateMethodCommand,
} from "@aws-sdk/client-api-gateway";
const client = new APIGatewayClient({});
export async function attachCognitoAuthorizer(
apiId: string,
resourceId: string,
userPoolArn: string
) {
// 1️⃣ Create the authorizer object.
const authorizer = await client.send(
new CreateAuthorizerCommand({
restApiId: apiId,
name: "CognitoAuthorizer",
type: "COGNITO_USER_POOLS",
providerARNs: [userPoolArn],
identitySource: "method.request.header.Authorization", // JWT header
})
);
// 2️⃣ Update the POST method to require the authorizer.
await client.send(
new UpdateMethodCommand({
restApiId: apiId,
resourceId,
httpMethod: "POST",
patchOperations: [
{
op: "replace",
path: "/authorizationType",
value: "COGNITO_USER_POOLS",
},
{
op: "replace",
path: "/authorizerId",
value: authorizer.id!,
},
],
})
);
console.log("Cognito authorizer attached to /addUser POST");
}
cognito-idp:InitiateAuth
.
Authorization: Bearer <jwt>
header when Claude sends the request.
In plain English:The API Gateway now acts like a security guard. Only callers that show a valid badge (JWT) are allowed to walk through the door to the kitchen (Lambda).
Key points to remember
satisfies
helps you catch schema mismatches early and keeps DynamoDB calls correct. By wiring Claude’s function calls through API Gateway, you get a production‑ready, secure bridge that can evolve without breaking existing integrations. Happy coding!
Transparency noticeThis article was written with the help of an AI system —
[Groq](GPT OSS 120B).
Published:2026-08-27 ·Primary focus:APIGatewayAll code blocks are intended to be correct and runnable, but please verify them
against the official docs for the tools mentioned before using in production.
Find an error? Drop a comment — corrections are always welcome.