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 configurecompleted 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/completionsendpoint works if you adjust the URL. - A basic Lambda execution IAM role (e.g.,
lambda-basic-execution) withlogs:CreateLogGroup,logs:CreateLogStream, andlogs: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:
- External system POSTs JSON to API Gateway HTTP API.
- API Gateway maps the request to a Lambda invoke.
- Lambda sends the user message plus tool definitions to the LLM.
- If the LLM emits a
tool_call, Lambda executes the local stub and calls the LLM a second time with the result. - 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_callsmessage in the conversation history, or it errors. - Trusting arguments without validation. Always
json.loadsand 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.