n4nAI

Building an AWS Lambda webhook handler for LLM function calling

Step-by-step tutorial for building an AWS Lambda webhook handler that uses LLM function calling with Python, API Gateway, and deployable code.

n4n Team4 min read797 words

Audio narration

Coming soon — every post will get a voice note here.

This tutorial walks through building a production-ready aws lambda webhook llm function calling handler that accepts an HTTP POST from any source, invokes a model with tool schemas, and executes the returned call inside the Lambda runtime. You will deploy a Python function behind API Gateway and verify the round trip with curl. The pattern is useful when you want a stateless, autoscaling endpoint that lets an LLM trigger real code without standing up a long-running service.

Prerequisites

  • AWS CLI v2 installed and aws configure completed with a role that can create Lambda and API Gateway resources.
  • Python 3.11+ locally for unit testing the handler logic.
  • An OpenAI-compatible API key. We use n4n.ai’s single endpoint that fronts 240+ models and auto-falls back on provider errors; any compliant /v1/chat/completions endpoint works if you adjust the URL.
  • A basic Lambda execution IAM role (e.g., lambda-basic-execution) with logs:CreateLogGroup, logs:CreateLogStream, and logs:PutLogEvents.

If you have not used function calling before: the model returns a structured JSON object naming a function and its arguments instead of free text. Your code runs the function and feeds the result back. That is the entire mechanism.

Architecture overview

The flow is deliberately boring:

  1. External system POSTs JSON to API Gateway HTTP API.
  2. API Gateway maps the request to a Lambda invoke.
  3. Lambda sends the user message plus tool definitions to the LLM.
  4. If the LLM emits a tool_call, Lambda executes the local stub and calls the LLM a second time with the result.
  5. Lambda returns the synthesized answer as JSON.

The aws lambda webhook llm function calling design keeps business logic in the function runtime, not in the prompt. That matters for auditability and correctness.

Step 1: Define the tool contract

Start with the schema the model will see. Keep descriptions precise; the model relies on them to decide when to call.

{
  "type": "function",
  "function": {
    "name": "lookup_inventory",
    "description": "Get current stock count for a SKU",
    "parameters": {
      "type": "object",
      "properties": {
        "sku": {"type": "string", "description": "Product SKU, e.g. ABC-123"}
      },
      "required": ["sku"]
    }
  }
}

Do not overload a single tool with optional parameters that span use cases. Multiple small tools outperform one Swiss-army function.

Step 2: Implement the Lambda handler

We use urllib from the standard library to avoid packaging requests. The handler below is complete and runnable.

import json
import os
import urllib.request

ENDPOINT = os.environ.get("LLM_ENDPOINT", "https://api.n4n.ai/v1/chat/completions")
API_KEY = os.environ["LLM_API_KEY"]
MODEL = os.environ.get("LLM_MODEL", "gpt-4o-mini")

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "lookup_inventory",
            "description": "Get current stock count for a SKU",
            "parameters": {
                "type": "object",
                "properties": {"sku": {"type": "string"}},
                "required": ["sku"],
            },
        },
    }
]

def lookup_inventory(sku: str) -> int:
    # Stub: replace with DynamoDB GetItem or an internal API call
    return 42 if sku == "ABC-123" else 0

def call_llm(messages, tools=TOOLS):
    payload = json.dumps({
        "model": MODEL,
        "messages": messages,
        "tools": tools,
        "tool_choice": "auto",
    }).encode()
    req = urllib.request.Request(ENDPOINT, data=payload, headers={
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
    })
    with urllib.request.urlopen(req, timeout=10) as resp:
        return json.loads(resp.read())

def lambda_handler(event, context):
    body = json.loads(event.get("body", "{}"))
    user_msg = body.get("message", "")
    if not user_msg:
        return {"statusCode": 400, "body": json.dumps({"error": "missing message"})}

    messages = [{"role": "user", "content": user_msg}]
    first = call_llm(messages)
    msg = first["choices"][0]["message"]

    if msg.get("tool_calls"):
        for tc in msg["tool_calls"]:
            if tc["function"]["name"] == "lookup_inventory":
                args = json.loads(tc["function"]["arguments"])
                result = lookup_inventory(args["sku"])
                messages.append(msg)
                messages.append({
                    "role": "tool",
                    "tool_call_id": tc["id"],
                    "content": json.dumps({"stock": result}),
                })
        final = call_llm(messages)
        answer = final["choices"][0]["message"]["content"]
    else:
        answer = msg["content"]

    return {"statusCode": 200, "body": json.dumps({"answer": answer})}

The first LLM response when the model chooses the tool looks like this:

{
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": null,
        "tool_calls": [
          {
            "id": "call_abc",
            "type": "function",
            "function": {
              "name": "lookup_inventory",
              "arguments": "{\"sku\":\"ABC-123\"}"
            }
          }
        ]
      }
    }
  ],
  "usage": {"prompt_tokens": 54, "completion_tokens": 12, "total_tokens": 66}
}

Note the usage block. If you are on a gateway that does per-token metering, those numbers match your bill exactly—no client-side estimation required.

Step 3: Package and deploy with SAM

AWS SAM is the least-friction way to wire Lambda to an HTTP endpoint. Create template.yaml:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Parameters:
  LLMKey:
    Type: String
    NoEcho: true
Resources:
  WebhookFn:
    Type: AWS::Serverless::Function
    Properties:
      Handler: app.lambda_handler
      Runtime: python3.11
      Timeout: 30
      MemorySize: 256
      Environment:
        Variables:
          LLM_API_KEY: !Ref LLMKey
          LLM_MODEL: gpt-4o-mini
      Events:
        Api:
          Type: HttpApi

Deploy:

sam build
sam deploy --guided

When prompted, paste your API key as LLMKey. On success, SAM prints an ApiUrl output similar to:

Key                 Value
------------------  --------------------------------------------------
ApiUrl              https://abcd1234.execute-api.us-east-1.amazonaws.com/

That URL is your webhook target.

Step 4: Test the webhook

Run a local check before touching the network:

if __name__ == "__main__":
    evt = {"body": json.dumps({"message": "How many ABC-123 do we have?"})}
    print(lambda_handler(evt, None))

Expected local output:

{'statusCode': 200, 'body': '{"answer": "We have 42 units of ABC-123 in stock."}'}

Now hit the deployed endpoint:

curl -X POST https://abcd1234.execute-api.us-east-1.amazonaws.com/ \
  -H 'Content-Type: application/json' \
  -d '{"message":"How many ABC-123 do we have?"}'

Expected remote response:

{"answer":"We have 42 units of ABC-123 in stock."}

If you send a non-tool message like {"message":"hello"}, the handler skips the tool path and returns the model’s text directly.

Step 5: Hardening for production

Idempotency

Webhooks get retried. Extract event["requestContext"]["requestId"] or a client idempotency_key and write it to DynamoDB with a TTL before calling the LLM. Skip execution if the key exists.

Error isolation

Wrap call_llm in try/except for urllib.error.HTTPError and urllib.error.URLError. Return a 502 with the error detail rather than letting Lambda throw a raw stack trace to the caller.

Cold starts

The aws lambda webhook llm function calling workload is I/O bound. A 256 MB function is enough; bumping memory mainly speeds CPU-bound JSON parsing. Avoid importing heavy SDKs unless you actually use them.

Model routing

If you front the call with a gateway that honors client routing directives, you can pass a header like x-model-prefer to shift between providers without code changes. That keeps the Lambda untouched during provider incidents.

Step 6: Observability and cost

Emit usage to CloudWatch as a custom metric. In practice, the LLM call dominates latency—typically 300–1200 ms for small models—while Lambda overhead is sub-100 ms after warmup. API Gateway HTTP API costs fractions of a cent per million requests; the LLM tokens are the real line item.

Structure logs as JSON so you can query tool_name and tokens in CloudWatch Logs Insights:

import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger()
# inside lambda_handler after final call:
logger.info(json.dumps({"tool": "lookup_inventory", "tokens": final.get("usage", {}).get("total_tokens")}))

Common pitfalls

  • Forgetting to echo the assistant message with tool_calls back in the second call. The API requires the original tool_calls message in the conversation history, or it errors.
  • Trusting arguments without validation. Always json.loads and check types; a malformed SKU should return a clean 400, not a crash.
  • Setting Lambda timeout equal to API Gateway timeout. Keep Lambda at 30s and API Gateway default 30s, but make the LLM client timeout 10s so you can fail fast and retry upstream if needed.

The aws lambda webhook llm function calling approach is not exotic. It is a web server with one extra round trip to a structured-output model. Treat the tool execution as normal code, log it like normal code, and the rest is plumbing.

Tagsaws-lambdawebhooksfunction-callingserverless

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All aws lambda serverless llm integration posts →