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

Claude Function Calling with Lambda Function URLs: Building a Secure, Zero‑Config AI Endpoint

A developer demonstrates how to build a secure, zero-config AI endpoint by pairing AWS Lambda Function URLs with Claude's function-calling feature. The approach eliminates the need for API Gateway, reducing latency and complexity for single-function AI services. The provided Node.js handler integrates with Claude's API to validate payments and logs audit data to S3.

read11 min views1 publishedAug 28, 2026

When you need a fast LLM endpoint, the first instinct is to spin up API Gateway—but Lambda Function URLs let you skip that extra hop entirely. Pair them with Claude’s function‑calling feature and you get a self‑contained AI service that’s ready in minutes. Yet most engineers still cling to the old, noisy stack.

Imagine you’re sending a postcard. Using API Gateway is like handing the postcard to a postal clerk who stamps it, checks the address, and then hands it to the carrier. It works, but it adds a tiny delay and another place where something can go wrong.

Lambda Function URLs are the front‑door key: the postcard goes straight from your hand to the mailbox (the Lambda). There’s no middle‑man to configure, no extra cost for a separate service, and the latency is a few milliseconds lower.

In plain English:A Lambda Function URL is the quickest, simplest way to expose a Lambda over HTTPS. If you only need one function (your Claude proxy) you can skip API Gateway entirely.

Key takeaway:For a single‑function AI service, Function URLs give you fewer moving parts, lower cost, and a clearer mental model.

Claude’s function‑calling feature lets the model suggest a structured action (a tool call) instead of returning free‑form text. Think of it like a customer asking a clerk for a receipt; the clerk hands back a neatly formatted paper rather than a scribbled note. This structure makes it safe to execute code—your Lambda can trust the JSON shape and act on it.

Below is a complete Lambda handler written for Node.js 22 (the current LTS version). It:

fetch

request to Claude’s /v1/chat/completions

endpoint, describing a single tool called validatePayment

.

// file: src/index.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

// The S3 bucket where we store audit logs.
// Replace with your bucket name (must already exist).
const AUDIT_BUCKET = process.env.AUDIT_BUCKET ?? "my-ai-audit-bucket";

// Create a single S3 client – it reuses HTTP connections automatically.
const s3 = new S3Client({});

/**
 * Lambda entry point. The runtime passes an `event` object that contains
 * the raw HTTP request when the function is invoked via a Function URL.
 */
export const handler = async (event: any) => {
  try {
    // ---------- 1️⃣ Parse the incoming payment request ----------
    // `event.body` is a JSON string because Function URLs forward the raw body.
    const requestPayload = JSON.parse(event.body);
    // Example shape we expect:
    // { "orderId": "12345", "amountCents": 1999, "currency": "USD", "cardToken": "tok_abc" }

    // ---------- 2️⃣ Call Claude with a tool definition ----------
    const claudeResponse = await callClaude(requestPayload);

    // ---------- 3️⃣ Extract the tool call (validatePayment) ----------
    const toolResult = extractToolResult(claudeResponse);

    // ---------- 4️⃣ Persist request + response for audit ----------
    await persistAudit(event.body, JSON.stringify(claudeResponse));

    // ---------- 5️⃣ Return the tool result to the caller ----------
    return {
      statusCode: 200,
      headers: {
        "Content-Type": "application/json",
        // Simple CORS header – adjust the origin as needed.
        "Access-Control-Allow-Origin": "*",
      },
      body: JSON.stringify(toolResult),
    };
  } catch (err: any) {
    // ---------- Error handling ----------
    const status = err.statusCode ?? 500;
    return {
      statusCode: status,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ error: err.message ?? "Internal error" }),
    };
  }
};

/**
 * Calls Claude’s chat completion endpoint with a single tool called
 * `validatePayment`. The tool tells Claude how to format a call that we
 * can safely execute.
 */
async function callClaude(paymentPayload: any) {
  // Claude API key should be stored in Secrets Manager / Parameter Store.
  const CLAUDE_API_KEY = process.env.CLAUDE_API_KEY!;
  const CLAUDE_URL = "https://api.anthropic.com/v1/chat/completions";

  // The tool definition – this tells Claude the JSON schema it must return.
  const tools = [
    {
      name: "validatePayment",
      description: "Check a credit‑card payment request for validity",
      input_schema: {
        type: "object",
        properties: {
          orderId: { type: "string" },
          amountCents: { type: "integer" },
          currency: { type: "string" },
          cardToken: { type: "string" },
        },
        required: ["orderId", "amountCents", "currency", "cardToken"],
      },
    },
  ];

  // Build the request payload for Claude.
  const body = {
    model: "claude-3-5-sonnet-20240620",
    max_tokens: 1024,
    messages: [
      { role: "user", content: "Validate this payment request." },
      { role: "assistant", tool_calls: [] }, // placeholder for tool call
    ],
    tools, // pass our tool definition
    // We also give Claude the raw payment data so it can decide whether to call the tool.
    // (Claude can also ask follow‑up questions; we keep it simple here.)
    // In a real app you might embed the payload in a system message.
    // For demo purposes we send it as part of the user message.
    // Example:
    // "Here is the payload: { ... }"
  };

  // Add the actual payload to the user content.
  (body.messages[0] as any).content = `Payment payload: ${JSON.stringify(
    paymentPayload,
  )}`;

  const response = await fetch(CLAUDE_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-api-key": CLAUDE_API_KEY,
      // Claude expects an explicit version header.
      "anthropic-version": "2023-06-01",
    },
    body: JSON.stringify(body),
  });

  if (!response.ok) {
    // Propagate HTTP errors as JavaScript exceptions.
    const errText = await response.text();
    const error = new Error(`Claude API error ${response.status}: ${errText}`);
    (error as any).statusCode = response.status;
    throw error;
  }

  // Claude returns a JSON structure that includes `tool_calls` when it wants to invoke a function.
  return await response.json();
}

/**
 * Pulls the JSON payload out of Claude’s tool call response.
 * Throws a ValidationError if the schema is not respected.
 */
function extractToolResult(claudeResponse: any) {
  // The path to tool calls can differ by model version; here we use the standard shape.
  const toolCalls = claudeResponse?.choices?.[0]?.message?.tool_calls;
  if (!toolCalls || toolCalls.length === 0) {
    throw new Error("Claude did not return a tool call");
  }

  const call = toolCalls[0];
  if (call.name !== "validatePayment") {
    throw new Error(`Unexpected tool name: ${call.name}`);
  }

  // `function_arguments` is a JSON string – parse it.
  let args: any;
  try {
    args = JSON.parse(call.function_arguments);
  } catch {
    throw new Error("Failed to parse Claude's tool arguments");
  }

  // Very light validation – in production you would use a JSON schema validator.
  const required = ["orderId", "amountCents", "currency", "cardToken"];
  for (const key of required) {
    if (!(key in args)) {
      throw new Error(`Missing required field ${key}`);
    }
  }

  // Return the clean, validated object.
  return { validated: true, details: args };
}

/**
 * Writes both the raw inbound request and Claude’s raw reply to S3.
 * Using a timestamped key makes it easy to browse audit logs later.
 */
async function persistAudit(rawRequest: string, rawResponse: string) {
  const timestamp = new Date().toISOString();
  const requestKey = `audit/${timestamp}_request.json`;
  const responseKey = `audit/${timestamp}_claude.json`;

  // Put the request object.
  await s3.send(
    new PutObjectCommand({
      Bucket: AUDIT_BUCKET,
      Key: requestKey,
      Body: rawRequest,
      ContentType: "application/json",
    }),
  );

  // Put the Claude response object.
  await s3.send(
    new PutObjectCommand({
      Bucket: AUDIT_BUCKET,
      Key: responseKey,
      Body: rawResponse,
      ContentType: "application/json",
    }),
  );
}

Explanation of the most important lines

Line What it does
event.body
The HTTP payload sent by the caller (the payment request).
fetch(CLAUDE_URL, …)
Calls Claude over HTTPS using the built‑in fetch API (no extra library needed).
tools
Describes a tool – a function that Claude can ask you to run. The schema tells Claude exactly what keys and types to send back.
tool_calls
The place in Claude’s response where the model tells you “I want to run validatePayment with these arguments”.
PutObjectCommand
An AWS SDK command that stores an object (a file) in an S3 bucket.
process.env.CLAUDE_API_KEY
Pulls the secret API key from the Lambda’s environment variables – never hard‑code secrets.

Tip:When you first test locally, setprocess.env.CLAUDE_API_KEY

in a.env

file and use thedotenv

package. In production you should store the key in AWS Secrets Manager and grant the Lambda read access via an IAM role.

Lambda Function URLs default to a 6 KB request body limit. Claude’s tool‑call JSON can easily exceed that when you include rich data (e.g., a whole order object). If you forget to raise MaximumPayloadSize

(via the console or aws lambda update-function-url-config

), the runtime silently truncates the body and you’ll see mysterious “invalid JSON” errors.

aws lambda update-function-url-config \
  --function-name MyClaudeProxy \
  --auth-type NONE \
  --max-payload-size 64KB

In plain English:Think of the default limit as a tiny mailbox slot; you need to ask AWS for a bigger slot before you start dropping larger letters in.

An LLM can be instructed to generate code or manipulate data. If anyone on the internet can hit your endpoint, they could flood Claude with malicious prompts, rack up usage charges, or even exfiltrate data from your S3 bucket. IAM authentication gives you a strong, AWS‑native gatekeeper without adding a separate auth layer.

When you create the Function URL, set --auth-type AWS_IAM

. The runtime will then require a signed request (SigV4). A typical front‑end can obtain temporary credentials from Amazon Cognito or from an EC2 instance role.

aws lambda create-function-url-config \
  --function-name MyClaudeProxy \
  --auth-type AWS_IAM \
  --cors '{"AllowOrigins":["https://myapp.example.com"],"AllowMethods":["POST"],"AllowHeaders":["Authorization","Content-Type"]}'

https://myapp.example.com

may call the endpoint, and only POST

with the listed headers are allowed.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "lambda:InvokeFunctionUrl",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:MyClaudeProxy"
    }
  ]
}

Attach this policy to the role that your front‑end (or another Lambda) assumes.

Key takeaway:IAM auth + a tight CORS rule gives you a “door with a lock and a peephole” – only callers with proper AWS credentials can open it, and browsers can’t be tricked into sending data from another site.

Node 22 introduced native ES modules (.mjs

) by default. If you still use require('esm')

to force CommonJS, the Lambda layer that bundles @aws-sdk/client-lambda

silently fails to load, producing a cryptic “Cannot find module” error at cold start. The fix is to switch the file extension to .js

and add "type": "module"

in package.json

, or keep everything CommonJS by naming the file .cjs

.

Financial operations (like payment validation) are often subject to compliance rules. Keeping a tamper‑evident log of both the caller’s request and Claude’s exact reply helps you answer “who did what, when”.

Sometimes you must redact sensitive fields (e.g., cardToken

) before the log is stored long‑term. S3 Object Lambda lets you attach a small Lambda that transforms the object as it’s being written.

aws s3control create-access-point-for-object-lambda \
  --name audit-redact-ap \
  --region us-east-1 \
  --configuration '{
    "SupportingAccessPoint": "arn:aws:s3:us-east-1:123456789012:accesspoint/my-audit-bucket-ap",
    "TransformationConfigurations": [{
      "Actions": ["GetObject", "PutObject"],
      "ContentTransformation": {
        "AwsLambda": {
          "FunctionArn": "arn:aws:lambda:us-east-1:123456789012:function:RedactCardToken"
        }
      }
    }]
  }'

Your RedactCardToken

Lambda receives the raw object, removes cardToken

, and returns the sanitized version to S3.

Analogy:Think of an S3 Object Lambda as a security guard at a mailroom who opens every envelope, removes any classified documents, and then reseals it before it goes into storage.

// file: redact.ts
export const handler = async (event: any) => {
  const original = JSON.parse(event.getObjectContext.inputS3Url);
  // Delete the sensitive field
  delete original.cardToken;
  return {
    statusCode: 200,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(original),
  };
};

Tip:Because the redaction Lambda runs for every put, keep it tiny (no external SDKs) to avoid extra latency.

The Serverless Application Model (SAM) CLI can invoke a Function URL locally, letting you see the full request/response cycle without deploying.

sam local invoke MyClaudeProxy \
  -e events/payment-request.json \
  --env-vars env.json

events/payment-request.json

might contain:

{
  "body": "{\"orderId\":\"A100\",\"amountCents\":2500,\"currency\":\"USD\",\"cardToken\":\"tok_123\"}"
}

If Claude returns a tool_calls

array but the Lambda logs Claude did not return a tool call

, the most common cause is the 6 KB limit mentioned earlier. Check CloudWatch logs for a line like:

2026-08-28T12:34:56.789Z    ERROR   JSON Parse error: Unexpected end of JSON input

That indicates the incoming body was cut off. Verify the Function URL’s MaximumPayloadSize

setting.

Claude may respond with 400 Bad Request

if the tool schema is malformed. Your callClaude

helper already throws an error that bubbles up to the top‑level catch

. To surface a friendlier message to the caller:

if (response.status >= 400 && response.status < 500) {
  const errMsg = await response.text();
  const err = new Error(`Invalid request: ${errMsg}`);
  (err as any).statusCode = response.status;
  throw err;
}

Key takeaway:Centralizing error handling lets you map Claude’s HTTP errors to your own API’s error model, keeping the front‑end experience consistent.

If you later decide to stream Claude’s response (useful for very large payloads), you must set Content-Type: application/octet-stream

and add Transfer-Encoding: chunked

headers. Omitting those causes the runtime to buffer the entire response, which defeats the purpose of streaming and can hit the 6 KB limit again.

What you now have in your toolbox

MaximumPayloadSize

early in the setup.By stitching these pieces together, you can spin up a production‑grade, self‑contained AI endpoint in under ten minutes—perfect for payment validation, order verification, or any other task where you want the model’s reasoning plus the safety of a typed contract. Happy coding!

Transparency noticeThis article was written with the help of an AI system —

[Groq](GPT OSS 120B).

Published:2026-08-28 ·Primary focus:LambdaAll 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 @aws lambda 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:11min 2026-08-28 ·