Learn how to build production-ready webhook endpoints that trigger LLM pipelines from external events, process AI jobs in the background, and deliver results asynchronously without timeouts or lost jobs. Complete with Python code, security validation, and idempotency patterns.

Picture this: your company's support team receives a customer complaint via email. Within seconds, an AI pipeline springs to life — it classifies the complaint's urgency, queries your knowledge base for relevant policy documents, drafts a personalized response, and routes it to the right agent. Nobody clicked a button. Nobody ran a script. The whole thing happened because an email server sent a small HTTP notification to your application.
That notification is a webhook, and it's one of the most powerful (and underappreciated) patterns for building AI systems that react to the real world. Unlike traditional integrations where your application periodically asks "has anything changed?", webhooks flip the model: external services tell you the moment something happens. When you combine that with the latency of LLM inference — which can run anywhere from one to thirty seconds — you need a delivery architecture that doesn't keep callers waiting. That's the async part of the equation.
By the end of this lesson, you'll understand webhooks deeply enough to build a production-ready AI pipeline that receives external events, processes them through an LLM, and delivers results back without blocking the caller or losing jobs when things go wrong.
What you'll learn:
You should be comfortable writing Python functions and understand basic HTTP concepts (GET, POST, request bodies, status codes). You don't need to be an expert in web frameworks or distributed systems. If you haven't used an LLM API before, a quick read through Using the OpenAI API with Python will give you everything you need to follow the LLM portions of this lesson.
Let's build the intuition before touching any code.
A webhook is just an HTTP POST request that one server sends to another when a specific event occurs. That's it. There's no special protocol, no persistent connection, no magic. When Stripe processes a payment, it POSTs a JSON payload to a URL you've configured. When GitHub merges a pull request, it POSTs to your CI/CD endpoint. When a user fills out a Typeform survey, Typeform POSTs the responses to wherever you tell it.
The URL that receives these requests is called a webhook endpoint (or sometimes a webhook listener). Your job is to run a web server that exposes this endpoint, processes the incoming payload, and responds quickly.
Key insight
The HTTP response you send back to a webhook sender is not the AI result — it's just an acknowledgment that you received the event. The AI processing happens separately, after you've already replied. This separation is the entire design challenge of async webhook systems.
Contrast this with polling, where your application wakes up every N seconds and asks an API "did anything happen?" Polling wastes resources, introduces latency proportional to your polling interval, and doesn't scale well. Webhooks invert the model: the sender notifies you the instant something happens, and you respond immediately.
Here's where beginners run into trouble. When a webhook arrives, the sender expects a response within a few seconds — often within 30 seconds before it times out and marks the delivery as failed. But LLM inference can take anywhere from 2 to 30+ seconds depending on the model, prompt complexity, and whether you're doing retrieval steps first.
If you try to do all of this in the webhook handler itself — receive the event, call the LLM, format the response, deliver it back — you'll frequently time out. The sender will retry the webhook, you'll process the job twice, and your system becomes unreliable.
The correct architecture has three stages:
200 OKThink of it like a restaurant. When you place an order (webhook arrives), the host immediately says "got it" (200 OK). The kitchen (background worker) prepares your food (LLM processing). When it's ready, the server brings it to your table (async delivery). Nobody makes you stand at the counter while your steak is cooked.
Let's build this in stages. We'll use FastAPI as our web framework — it's the most common choice for Python AI applications because it's async-native, fast, and has excellent request validation.
First, install your dependencies:
pip install fastapi uvicorn openai httpx python-dotenv
Here's a minimal webhook receiver:
# receiver.py
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
import hashlib
import hmac
import os
import json
app = FastAPI()
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "your-secret-here")
def verify_signature(payload_bytes: bytes, signature_header: str) -> bool:
"""Verify that this webhook actually came from who we think it did."""
expected = hmac.new(
WEBHOOK_SECRET.encode(),
payload_bytes,
hashlib.sha256
).hexdigest()
# Use constant-time comparison to prevent timing attacks
return hmac.compare_digest(f"sha256={expected}", signature_header)
@app.post("/webhook/support-ticket")
async def receive_support_ticket(
request: Request,
background_tasks: BackgroundTasks
):
body = await request.body()
# Validate the signature before trusting any content
sig_header = request.headers.get("X-Signature-256", "")
if not verify_signature(body, sig_header):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = json.loads(body)
# Immediately hand off to background processing
background_tasks.add_task(process_ticket_with_llm, payload)
# Return 200 immediately — don't wait for the LLM
return JSONResponse(
content={"status": "accepted", "job_id": payload.get("ticket_id")},
status_code=202
)
A few things to notice here. We're using 202 Accepted rather than 200 OK — this is the semantically correct status code for "I received this and will process it, but haven't finished yet." Many webhook senders treat any 2xx response as success, but 202 communicates intent clearly to humans reading logs.
Warning
Never skip signature verification in production. Without it, anyone who discovers your webhook URL can send arbitrary payloads and trigger your AI pipeline. Most webhook platforms (Stripe, GitHub, Shopify) sign their requests with an HMAC secret that you configure when setting up the webhook. Always validate it before parsing the body.
Different platforms use slightly different signature schemes. GitHub uses X-Hub-Signature-256 with sha256= prefix. Stripe uses Stripe-Signature with a timestamp to prevent replay attacks. Here's a more robust verifier that handles timestamp-based replay protection:
import time
def verify_stripe_style_signature(
payload_bytes: bytes,
signature_header: str,
secret: str,
tolerance_seconds: int = 300
) -> bool:
"""
Verifies signatures that include a timestamp for replay protection.
Header format: t=1614556800,v1=abc123...
"""
parts = dict(item.split("=", 1) for item in signature_header.split(","))
timestamp = int(parts.get("t", 0))
received_sig = parts.get("v1", "")
# Reject events older than tolerance_seconds (5 minutes default)
if abs(time.time() - timestamp) > tolerance_seconds:
return False
# Reconstruct the signed payload
signed_payload = f"{timestamp}.{payload_bytes.decode()}"
expected = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, received_sig)
The timestamp check is important: without it, an attacker could capture a valid webhook payload from your network traffic and replay it hours later.
FastAPI's BackgroundTasks is convenient for simple cases, but it has a significant limitation: if your server restarts while a job is in flight, the job is lost. For production systems, you want a persistent job queue — a separate service that stores jobs durably and hands them to workers.
The most common lightweight option is Redis with the RQ (Redis Queue) library. Here's the pattern:
# jobs.py
from redis import Redis
from rq import Queue
import os
redis_conn = Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379"))
ticket_queue = Queue("support-tickets", connection=redis_conn)
# In your webhook handler, replace background_tasks.add_task with:
# ticket_queue.enqueue(process_ticket_with_llm, payload)
# worker.py — runs as a separate process
from redis import Redis
from rq import Worker
from jobs import ticket_queue, redis_conn
if __name__ == "__main__":
worker = Worker([ticket_queue], connection=redis_conn)
worker.work()
You'd start this with python worker.py in a separate terminal (or container in production). The webhook server enqueues jobs instantly; the worker picks them up and processes them independently.
Tip
For getting started quickly, SQLite with a simple polling loop works fine too. The key architectural principle is that the webhook handler and the LLM processing are separate processes. Don't optimize the queue infrastructure until you actually need it.
Now for the actual AI work. Let's say our support ticket system needs to: classify urgency, summarize the issue, and draft a reply.
# processor.py
import openai
import os
import json
client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
def process_ticket_with_llm(payload: dict) -> dict:
"""
Takes a raw support ticket payload and returns structured AI analysis.
"""
ticket_text = payload.get("message", "")
customer_name = payload.get("customer_name", "Customer")
ticket_id = payload.get("ticket_id")
system_prompt = """You are a customer support AI for a SaaS company.
Analyze the support ticket and respond with JSON containing:
- urgency: "low", "medium", "high", or "critical"
- category: the type of issue (e.g., "billing", "technical", "account")
- summary: one sentence describing the issue
- draft_reply: a professional, empathetic reply draft (2-3 sentences)
- escalate: true if this needs a human agent immediately, false otherwise
Respond ONLY with valid JSON, no markdown fences."""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Ticket from {customer_name}:\n\n{ticket_text}"}
],
temperature=0.3, # Lower temperature for consistent classification
response_format={"type": "json_object"}
)
analysis = json.loads(response.choices[0].message.content)
analysis["ticket_id"] = ticket_id
analysis["processed_at"] = datetime.utcnow().isoformat()
return analysis
Notice we're using response_format={"type": "json_object"} to guarantee structured output. If you want to go deeper on why this matters and how to design robust output schemas, the lesson on Structured Output: Getting JSON, Tables, and Code from LLMs covers this in detail.
Once the LLM has produced its analysis, you need to get the result back to whoever needs it. You have three main options:
Option 1: Callback URL
The webhook sender includes a callback_url in their payload. After processing, you POST your results there.
import httpx
async def deliver_result_via_callback(analysis: dict, callback_url: str):
"""POST the AI result back to the sender's callback endpoint."""
async with httpx.AsyncClient() as client:
try:
response = await client.post(
callback_url,
json=analysis,
timeout=10.0,
headers={
"Content-Type": "application/json",
"X-Source": "support-ai-pipeline"
}
)
response.raise_for_status()
print(f"Delivered result for ticket {analysis['ticket_id']}")
except httpx.HTTPError as e:
print(f"Delivery failed: {e}. Will retry...")
# In production: re-enqueue with backoff
raise
Option 2: Database Update + Polling Store the result in a database keyed by job ID. The caller polls a status endpoint you expose.
@app.get("/jobs/{job_id}")
async def get_job_status(job_id: str):
result = await db.fetch_result(job_id)
if result is None:
return {"status": "processing", "job_id": job_id}
return {"status": "complete", "job_id": job_id, "result": result}
Option 3: WebSockets or Server-Sent Events If you need to push results to a browser in real-time, this is the right pattern. The lesson on Building Streaming AI Interfaces: Real-Time Response Delivery covers this approach thoroughly.
Key insight
The callback URL pattern is the most common in B2B integrations. The polling pattern is simplest to implement and debug. Real-time streaming is best for user-facing interfaces. Pick based on who's consuming the results.
Webhook senders retry failed deliveries. If your server returns a 500 error, or if a network hiccup causes the sender to not receive your 200 OK, you'll get the same event again — sometimes multiple times. Your system needs to handle this gracefully.
Idempotency means that processing the same event twice produces the same result as processing it once. The key is tracking which events you've already processed.
import hashlib
from collections import defaultdict
# In production, use Redis or a database for this
processed_event_ids = set()
@app.post("/webhook/support-ticket")
async def receive_support_ticket(
request: Request,
background_tasks: BackgroundTasks
):
body = await request.body()
# Verify signature first (always)
sig_header = request.headers.get("X-Signature-256", "")
if not verify_signature(body, sig_header):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = json.loads(body)
# Check idempotency using the event's unique ID
event_id = payload.get("event_id") or hashlib.sha256(body).hexdigest()
if event_id in processed_event_ids:
# Return 200 so the sender stops retrying, but don't reprocess
return JSONResponse(
content={"status": "already_processed", "event_id": event_id},
status_code=200
)
processed_event_ids.add(event_id)
background_tasks.add_task(process_ticket_with_llm, payload)
return JSONResponse(
content={"status": "accepted", "event_id": event_id},
status_code=202
)
Warning
The in-memory processed_event_ids set above will lose all data on server restart. In production, store seen event IDs in Redis with a TTL (time-to-live) of at least 24 hours. Most webhook senders stop retrying after a few hours.
Here's the complete flow in one diagram (described in text):
POST /webhook/support-ticket202 Accepted immediatelyThe full processor.py function that ties the LLM call to delivery:
from datetime import datetime
import json
import httpx
import openai
import os
client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
def process_ticket_with_llm(payload: dict):
"""Full pipeline: LLM analysis + async delivery."""
ticket_id = payload.get("ticket_id", "unknown")
callback_url = payload.get("callback_url")
try:
# 1. Run LLM analysis
analysis = analyze_ticket(payload)
# 2. Store result regardless of delivery method
store_result(ticket_id, analysis)
# 3. Attempt callback delivery if URL was provided
if callback_url:
deliver_result(analysis, callback_url)
except openai.RateLimitError:
# Re-enqueue with exponential backoff
print(f"Rate limited on ticket {ticket_id}. Re-queuing...")
raise # RQ will retry automatically
except Exception as e:
print(f"Failed to process ticket {ticket_id}: {e}")
raise
def analyze_ticket(payload: dict) -> dict:
ticket_text = payload.get("message", "")
customer_name = payload.get("customer_name", "Customer")
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"From {customer_name}: {ticket_text}"}
],
response_format={"type": "json_object"},
temperature=0.3
)
result = json.loads(response.choices[0].message.content)
result["ticket_id"] = payload.get("ticket_id")
result["processed_at"] = datetime.utcnow().isoformat()
return result
def deliver_result(analysis: dict, callback_url: str):
with httpx.Client() as http:
response = http.post(callback_url, json=analysis, timeout=10.0)
response.raise_for_status()
def store_result(ticket_id: str, analysis: dict):
# Simplified — use a real DB in production
with open(f"results/{ticket_id}.json", "w") as f:
json.dump(analysis, f)
For guidance on handling rate limits and timeouts robustly in the LLM call itself, Handling LLM Errors, Timeouts, and Rate Limits in Python Applications is the right next read.
You need a public URL to receive webhooks during development. Two great tools for this:
ngrok: Run ngrok http 8000 and it gives you a temporary public URL like https://abc123.ngrok.io that tunnels to your local server.
Webhook.site: Go to webhook.site in your browser and it gives you a disposable URL that logs all incoming requests — useful for inspecting what senders are actually sending before you write your parser.
To test your endpoint locally without a real sender:
# Start your server
uvicorn receiver:app --reload --port 8000
# In another terminal, simulate a webhook POST
curl -X POST http://localhost:8000/webhook/support-ticket \
-H "Content-Type: application/json" \
-H "X-Signature-256: sha256=REPLACE_WITH_COMPUTED_SIG" \
-d '{
"ticket_id": "TKT-001",
"customer_name": "Maria Chen",
"message": "I was charged twice for my subscription this month. This is urgent as I need the refund to cover other expenses.",
"callback_url": "https://webhook.site/your-unique-id"
}'
To generate the correct signature for testing:
import hmac, hashlib, json
secret = "your-secret-here"
payload = '{"ticket_id": "TKT-001", "customer_name": "Maria Chen", ...}'
sig = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
print(f"sha256={sig}")
Build a webhook endpoint that receives GitHub pull request events and automatically generates a code review summary using an LLM.
What you'll build:
POST /webhook/github-prX-Hub-Signature-256Steps:
payload["pull_request"]["number"], the title at payload["pull_request"]["title"], and the changed files list comes from a separate API call (for simplicity, use the payload["pull_request"]["changed_files"] count instead)Stretch goal: Instead of storing to a file, POST the result back to a callback_url field you include in your test payload, using webhook.site to capture and inspect the delivery.
Mistake 1: Doing LLM work in the request handler Symptoms: Webhook sender reports failures, duplicate jobs appear, timeouts in logs. Fix: Always hand off to a background task or queue before returning.
Mistake 2: Not verifying signatures Symptoms: Unexpected behavior, strange payloads arriving, potential security incident. Fix: Always validate HMAC signatures before parsing body content. Make it the first thing you do.
Mistake 3: Returning non-2xx for duplicate events
Symptoms: Exponentially growing job queue as the sender keeps retrying an event you've already processed. Fix: Return 200 OK for duplicate event IDs, even if you don't reprocess them.
Mistake 4: Ignoring delivery failures Symptoms: Lost results, customer complaints, silent failures. Fix: Log all delivery attempts, implement retry logic with exponential backoff, store results in a DB independently of delivery so they can be retrieved later.
Mistake 5: Parsing the body before verifying the signature
Symptoms: Potential for certain classes of injection attacks. Fix: Verify the HMAC against the raw bytes before calling json.loads(). The signature is computed over the raw body, not the parsed object.
Tip
Add an X-Request-Id header to every outbound callback delivery. This gives downstream systems a stable identifier to use for their own idempotency checks, and it makes tracing much easier when you're debugging why a job was processed twice.
Note
If you're building on top of an orchestration framework like LangChain, the same webhook patterns apply — your webhook handler calls the chain just like it would call the LLM directly. See Building AI Workflows with LangChain and LlamaIndex for how to structure those chains.
Webhooks turn your LLM application from a passive tool into an active participant in your infrastructure. The key mental shift is accepting that HTTP responses and AI results are completely separate concerns: respond to the webhook immediately, process asynchronously, deliver results through a callback.
The three principles that make this reliable in production are:
Where to go from here depends on what you're building. If your AI pipeline needs to make decisions and take actions — not just analyze and reply — Function Calling and Tool Use with LLMs: Building Intelligent Agents shows you how to wire LLMs to external tools and APIs. If you're concerned about what happens when your LLM pipeline fails partway through a multi-step job, Agentic Loop Design: Building Reliable Multi-Step LLM Pipelines with Retry Logic, State Management, and Human-in-the-Loop Escalation covers the patterns you need. And when you're ready to put your webhook system into production, Deploying LLM Applications: API Design and Infrastructure will guide you through the infrastructure decisions.
Webhooks are the glue between the world's events and your AI's capabilities. Once you have this pattern working, you'll find uses for it everywhere.