DEV Community

Dinesh_gowtham
Dinesh_gowtham

Posted on

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

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.


What is Claude Function Calling?

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

  • Webhook – a URL that accepts an HTTP request and performs an action. Think of it as a doorbell that tells you someone is at the front door.
  • Payload – the body of the request; in Claude’s case it’s a JSON object that contains name, email, etc.
  • Lambda URL – a direct HTTPS endpoint that AWS creates for a Lambda function. It’s like handing the doorbell to the kitchen without a receptionist.

Claude expects the response to follow a tiny schema:

{
  "status": "success",
  "result": { "userId": "1234" }
}
Enter fullscreen mode Exit fullscreen mode

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.


Why API Gateway Beats Direct Lambda URLs

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

The hidden gotcha: non‑proxy integration

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‑Safe API Gateway Integration with TypeScript

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.

1. Define the expected Claude payload

// 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>;
Enter fullscreen mode Exit fullscreen mode

2. Lambda handler that validates and writes to DynamoDB

// 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 },
    }),
  };
};
Enter fullscreen mode Exit fullscreen mode

What the code does, step by step

  1. Parse the incoming JSON string.
  2. Validate it with Zod – if anything is off, we return a 400 error that Claude can read.
  3. Construct a DynamoDB PutItemCommand object. Using satisfies tells TypeScript, “this object must match the shape the SDK expects”; if you miss a required field, the compiler screams.
  4. Send the command to DynamoDB.
  5. Return a JSON response that Claude interprets as a successful function call.

Key takeaway: Combining Zod (runtime) and satisfies (compile‑time) gives you confidence that the data Claude sends is exactly what DynamoDB expects.


Wiring Claude’s Function Calls to Your Lambda

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.

1️⃣ Create a REST API (v1) with the AWS SDK

// 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`);
}
Enter fullscreen mode Exit fullscreen mode

Why each step matters

  • AWS_PROXY tells API Gateway to forward the request exactly as Claude sent it (including nested JSON). Without it, the arguments vanish.
  • Deployment stage (prod) gives you a stable URL while you can still create a dev stage for testing.
  • AuthorizationType: NONE is a placeholder – we’ll replace it with a Cognito authorizer later.

Tip: If you forget to set type: "AWS_PROXY" you’ll see Claude’s arguments disappear in CloudWatch logs.

2️⃣ Enable the Lambda permission for API Gateway

// 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");
}
Enter fullscreen mode Exit fullscreen mode

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.


Securing the Endpoint with Cognito Authorizer

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.

1️⃣ Create a User Pool (outside code – use console or CloudFormation)

  • User Pool name: ClaudeWebhookPool
  • App client: ClaudeWebhookClient (no secret)

2️⃣ Add a Cognito Authorizer to the API

// 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");
}
Enter fullscreen mode Exit fullscreen mode

3️⃣ How Claude gets the JWT

  1. Create a short‑lived client‑credential flow using an IAM role that has cognito-idp:InitiateAuth.
  2. Pass the token in the 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).


The Takeaway

Key points to remember

  • Claude function calling is a JSON‑based contract; you need a reliable HTTP endpoint that preserves the payload.
  • API Gateway gives you authentication, throttling, observability, and versioning that a plain Lambda URL lacks.
  • Enable Lambda proxy integration (or a custom mapping template) or Claude’s arguments will disappear.
  • Type‑safe code with Zod + satisfies helps you catch schema mismatches early and keeps DynamoDB calls correct.
  • Cognito authorizer turns the endpoint into a gated service, protecting it from accidental or malicious calls.
  • Deploy in stages (dev → prod) and use CloudWatch access logs to see the exact request Claude sent.

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 notice

This article was written with the help of an AI system — Groq (GPT OSS 120B).

Published: 2026-08-27 · Primary focus: APIGateway

All 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.

Top comments (0)