n4nAI

Building a webhook receiver for async LLM completions

Step-by-step tutorial for building a secure webhook receiver for async LLM completions using Python and FastAPI, with runnable code and verification.

n4n Team3 min read597 words

Audio narration

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

Long-running inference jobs need a reliable way to push results back to your service without blocking the request thread. A webhook receiver for llm completions lets the provider call you when the generation finishes, turning a synchronous API into an event-driven pipeline that scales with your workload instead of your timeout budget.

Prerequisites

  • Python 3.11 or newer
  • fastapi, uvicorn, httpx (install via pip install fastapi uvicorn httpx)
  • ngrok for exposing your local receiver to the internet (free tier is sufficient)
  • A shared HMAC secret agreed with your LLM provider or gateway
  • An OpenAI-compatible LLM endpoint (or a gateway that fronts one)

You should be comfortable with async def, basic HMAC verification, and JSON. No frontend or ORM required for this tutorial.

The payload contract

Define the shape of the callback before writing code. A minimal completion webhook looks like this:

{
  "job_id": "job_8f2c1a",
  "status": "completed",
  "model": "mistralai/mixtral-8x7b-instruct",
  "choices": [
    { "index": 0, "text": "The capital of France is Paris." }
  ],
  "usage": { "prompt_tokens": 12, "completion_tokens": 8, "total_tokens": 20 }
}

If the job failed, status is failed and an error field carries a string. Your webhook receiver for llm completions must accept both shapes without raising.

Step 1: Scaffold the FastAPI receiver

FastAPI gives you async request handling with minimal boilerplate. Create receiver.py:

import asyncio
import json
import logging
from fastapi import FastAPI, Request, Response

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("webhook")

app = FastAPI()

@app.post("/webhooks/llm-completions")
async def llm_webhook(request: Request):
    payload = await request.json()
    logger.info("Received payload: %s", payload.get("job_id"))
    return Response(status_code=202)

Run it:

uvicorn receiver:app --port 8000

Test with a curl:

curl -X POST http://localhost:8000/webhooks/llm-completions \
  -H "content-type: application/json" \
  -d '{"job_id":"test_1","status":"completed","model":"x","choices":[]}'

Expected output in the uvicorn log: INFO:webhook:Received payload: test_1. curl returns an empty body with HTTP 202.

Step 2: Verify the signature

Never trust unsigned callbacks. The provider should send an X-Signature header: HMAC-SHA256 of the raw body keyed by your shared secret, hex-encoded.

import hmac
import hashlib

SECRET = b"your-32-byte-secret"

async def verify_signature(request: Request) -> bool:
    body = await request.body()
    sig = request.headers.get("X-Signature", "")
    expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig, expected)

Wire it into the handler:

@app.post("/webhooks/llm-completions")
async def llm_webhook(request: Request):
    if not await verify_signature(request):
        logger.warning("Bad signature")
        return Response(status_code=401)
    payload = await request.json()
    logger.info("Verified job %s", payload.get("job_id"))
    return Response(status_code=202)

Replay the curl without the header and you get 401. Generate a valid sig with:

SIG=$(python3 -c 'import hmac,hashlib; print(hmac.new(b"your-32-byte-secret", b"{\"job_id\":\"test_1\"}", hashlib.sha256).hexdigest())')
curl -X POST http://localhost:8000/webhooks/llm-completions \
  -H "content-type: application/json" -H "X-Signature: $SIG" \
  -d '{"job_id":"test_1","status":"completed"}'

Now you get 202.

Step 3: Ack fast, process later

A webhook receiver for llm completions should not run downstream work (database writes, fan-out, embeddings) inside the HTTP handler. Return 202 immediately and hand off to a background worker.

queue: asyncio.Queue = asyncio.Queue()

@app.post("/webhooks/llm-completions")
async def llm_webhook(request: Request):
    if not await verify_signature(request):
        return Response(status_code=401)
    body = await request.body()
    await queue.put(body)
    return Response(status_code=202)

@app.on_event("startup")
async def startup():
    asyncio.create_task(worker())

async def worker():
    while True:
        raw = await queue.get()
        payload = json.loads(raw)
        logger.info("Processing job %s status=%s", payload["job_id"], payload["status"])
        # Persist to DB, trigger next step, etc.
        queue.task_done()

This keeps p99 handler latency under 10 ms even if processing takes seconds. Size the queue with asyncio.Queue(maxsize=1000) to apply backpressure under load.

Step 4: Idempotency

Providers retry webhooks on network errors or non-2xx responses. Use job_id + status as a dedupe key. An in-memory set works for a single instance; use Redis in production.

seen = set()

async def worker():
    while True:
        raw = await queue.get()
        payload = json.loads(raw)
        key = (payload["job_id"], payload["status"])
        if key in seen:
            logger.info("Duplicate %s, skipping", key)
            queue.task_done()
            continue
        seen.add(key)
        # process payload
        queue.task_done()

Step 5: Firing the async job

To exercise the receiver you need a caller that emits completions. If you use an OpenRouter-class gateway such as n4n.ai, you get one OpenAI-compatible endpoint covering 240+ models with automatic fallback, so you can submit the initial request without writing provider-specific retry logic. The request includes your public webhook URL:

import httpx

async def submit_job(prompt: str, callback_url: str):
    async with httpx.AsyncClient() as client:
        r = await client.post(
            "https://api.n4n.ai/v1/completions",
            headers={"Authorization": "Bearer $KEY"},
            json={
                "model": "mistralai/mixtral-8x7b-instruct",
                "prompt": prompt,
                "callback_url": callback_url,
                "metadata": {"job_id": "job_8f2c1a"}
            }
        )
        return r.json()

Swap callback_url for your ngrok URL once exposed. The gateway will POST to that URL when inference finishes.

Step 6: Expose locally with ngrok

Start the receiver, then in a second shell:

ngrok http 8000

Copy the https://xxxx.ngrok.io URL. Point your provider or the submit_job call to https://xxxx.ngrok.io/webhooks/llm-completions. Trigger a job and watch the uvicorn log:

INFO:webhook:Verified job job_8f2c1a
INFO:webhook:Processing job job_8f2c1a status=completed

The ngrok inspector shows the inbound POST with a 202 response. That round trip proves the webhook receiver for llm completions works end to end.

Step 7: Failure handling and retries

Return 500 only when you genuinely cannot process. The provider should back off and retry. For transient errors, re-queue with a cap:

async def worker():
    while True:
        raw = await queue.get()
        try:
            payload = json.loads(raw)
            # process, may raise on DB outage
        except Exception as e:
            logger.error("Processing failed: %s", e)
            retry = payload.get("retries", 0) + 1
            if retry < 5:
                payload["retries"] = retry
                await queue.put(json.dumps(payload).encode())
            else:
                logger.critical("Dropping job %s", payload.get("job_id"))
        finally:
            queue.task_done()

Without a retry cap you will create a retry storm during an outage.

Production notes

  • Use Redis SET key NX or Postgres INSERT ... ON CONFLICT for idempotency across multiple workers.
  • Rotate the HMAC secret quarterly and support two active secrets during overlap.
  • Emit metrics: webhook arrivals, signature failures, queue depth, processing lag.
  • Parse the usage block and forward per-token counts to your metering system; if your gateway already does per-token metering, reconcile against it.

A webhook receiver for llm completions is a small surface area with outsized reliability impact. Get the signature check and the fast-ack pattern right, and the rest is straightforward business logic.

Tagswebhooksasynctutorialllm-api

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 webhooks & async jobs for long-running llm tasks posts →