cd /news/developer-tools/claude-function-calling-with-api-gat… · home topics developer-tools article
[ARTICLE · art-113019] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Claude Function Calling with API Gateway: Build a Secure Serverless LLM Endpoint

A developer detailed how to build a secure serverless endpoint for Claude function calling using AWS API Gateway and Lambda, contrasting it with simpler Lambda URLs. The post highlights the need for Lambda proxy integration to preserve JSON payloads and recommends Zod for type safety in TypeScript, positioning API Gateway as the production-grade choice for authentication, throttling, observability, and versioning.

read8 min views2 publishedAug 27, 2026

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @claude 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/claude-function-call…] indexed:0 read:8min 2026-08-27 ·