InstaWebhook
August 28, 2026By InstaWebhook TeamWebhook Reliability

Handling Long-Running Reasoning-Model Callbacks: An Async Webhook Architecture Guide (2026)

Handling Long-Running Reasoning-Model Callbacks: An Async Webhook Architecture Guide (2026) Reasoning models don't just answer — they plan, second-guess themselves, call tools, and...

Handling Long Running Reasoning Model Callbacks An Async Webhook Architecture Guide 2026

Handling Long-Running Reasoning-Model Callbacks: An Async Webhook Architecture Guide (2026)

Reasoning models don't just answer — they plan, second-guess themselves, call tools, and revise before they ever emit a visible token. That shift, from "generate text" to "think, then generate," has quietly broken a lot of API integrations that were built for the old latency profile.

This guide covers why synchronous HTTP and SSE connections fall over during multi-minute reasoning calls, what OpenAI, Anthropic, and DeepSeek actually support today for async delivery (their approaches differ more than most tutorials admit), and how to build a receiver that won't fall down under retries, bursts, or a bad deploy.

Why reasoning calls break synchronous connections

A conventional chat completion returns in a few seconds. A reasoning model — OpenAI's GPT-5-series reasoning models, Anthropic's Claude models with extended thinking, or DeepSeek's reasoner models — can spend anywhere from tens of seconds to several minutes working through a problem internally before it produces output. Agentic and deep-research workloads stretch that further, into the tens of minutes.

Keeping an HTTP connection open for that long runs straight into infrastructure limits that were never designed for it:

LayerDocumented limit (2026)What happens at the limit
AWS API Gateway (REST API)29 seconds by default; can be raised above 29s for Regional and private REST APIs via a Service Quotas increase request (edge-optimized REST APIs cannot be raised)HTTP 504
AWS API Gateway (HTTP API)30 seconds, configurable up to 30sHTTP 504
Cloudflare (Free/Pro/Business)100 seconds, fixed — not configurable outside EnterpriseHTTP 524
Cloudflare (Enterprise)Extendable per-route
Vercel FunctionsHistorically 5–60s on Hobby and up to 900s on Pro with manual config. With Fluid Compute (Vercel's newer execution model), Pro/Enterprise can run up to 800s generally available, with an 1800s (30-minute) ceiling in beta; Hobby can reach 300s with Fluid Compute enabledFUNCTION_INVOCATION_TIMEOUT (504)
NGINX (proxy_read_timeout default)60 seconds, but this is just a config default you control on your own origin504
Mobile/edge networksNo fixed number — carrier and Wi-Fi/5G handoffs can silently drop idle socketsDropped TCP connection, no error surfaced

The practical takeaway: even the most generous of these (Vercel's beta 30-minute ceiling) is a ceiling you have to explicitly configure, not a default, and several of them (Cloudflare on non-Enterprise plans, edge-optimized API Gateway) simply cannot be raised at all. A five-minute reasoning call will outlive the default timeout on almost every layer between your model call and your user, and a client switching networks mid-request will kill the connection regardless of what any server-side timeout allows.

Server-Sent Events don't fix this on their own, either — SSE still holds one connection open for the full duration, so it inherits all the same proxy and mobile-network fragility. It's a better experience while the connection is alive, but it isn't a substitute for a durable delivery mechanism.

The async callback pattern

The fix is to stop treating the model call as something a live request waits on, and instead treat it as a background job that reports back when it's done:

Code example
[ Your backend ] --1. start job--> [ LLM provider ]
        |                                  |
        |                          2. reasons for 30s–10min+
        |                                  |
[ Your webhook receiver ] <--3. POST callback-- [ LLM provider ]
        |
4. ACK fast, queue the heavy work, update DB / notify user
  1. Your backend kicks off the job and gets an immediate acknowledgment plus a job/response ID — no open connection.
  2. The provider does the reasoning work on its own infrastructure, for as long as it takes.
  3. When it's done, the provider (or your own polling worker, for providers that don't push) delivers the result.
  4. Your receiver does the absolute minimum synchronously — verify, queue, return 2xx — and does everything expensive in the background.

Where this gets interesting is step 3: the three major reasoning-model providers don't implement this the same way, and a lot of blog content treats them as interchangeable. They aren't.

What each provider actually supports today

OpenAI: native background mode + webhooks

OpenAI's Responses API supports background: true, which runs the request asynchronously and lets you poll the response object for status instead of holding a connection open:

Code example
import OpenAI from "openai";
const client = new OpenAI();

const resp = await client.responses.create({
  model: "gpt-5.6",              // check platform.openai.com for current model IDs
  reasoning: { effort: "high" },
  input: userPrompt,
  background: true,
});

console.log(resp.status); // "queued" -> poll, or wait for the webhook

Separately, you register a webhook endpoint in the OpenAI dashboard subscribed to the response.completed event (and others, like batch.completed). OpenAI signs each delivery per the Standard Webhooks specification and POSTs it to that endpoint — you don't pass a webhook_url inline in the request body; the subscription lives in the dashboard. OpenAI's Deep Research endpoints follow the same background-mode-plus-webhook pattern, which matters because deep-research-style agentic runs can take tens of minutes.

Anthropic: streaming for real-time, polling for batch — no per-call webhook

This is the one most guides get wrong. Anthropic's Messages API is request/response with optional streaming (stream=true) for incremental output on a single call — there's no webhook attached to an individual messages.create request; streaming is Anthropic's answer to "don't hold a blind connection open."

For genuinely asynchronous, no-connection-required processing, Anthropic offers the Message Batches API (POST /v1/messages/batches), which processes up to 10,000 requests at 50% of standard pricing with results typically available within 24 hours. As of today, the Batches API is poll-based — you check processing_status on the batch object until it's ended, then retrieve results. It does not currently deliver a completion webhook; at least one third-party SDK (Vercel's AI SDK) explicitly documents that passing a webhook URL to Anthropic batch calls returns an "unsupported" response rather than registering a callback. If you need Claude results pushed to you rather than polled for, you build that polling-to-webhook bridge yourself.

Code example
import anthropic
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request

client = anthropic.Anthropic()

batch = client.messages.batches.create(
    requests=[
        Request(
            custom_id="job-1",
            params=MessageCreateParamsNonStreaming(
                model="claude-sonnet-5",
                max_tokens=4096,
                messages=[{"role": "user", "content": prompt}],
            ),
        )
    ]
)

# No webhook — poll until ended, then pull results:
status = client.messages.batches.retrieve(batch.id)

For a single long-running Claude call rather than a batch, the SDKs cap non-streaming requests at an expected 10-minute ceiling and will tell you to switch to streaming if you're hitting timeout_error (HTTP 504) — streaming, plus a TCP keep-alive, is the supported way to survive idle-connection drops on a single request.

DeepSeek: synchronous only — you build the async layer yourself

DeepSeek's API (the current generation as of August 2026 is the V4 family — deepseek-v4-pro and deepseek-v4-flash; the older deepseek-chat/deepseek-reasoner names were scheduled for discontinuation in July 2026) is OpenAI-SDK-compatible for chat completions, with stream=True/False and a reasoning_effort parameter for its thinking mode. There's no documented native background-job or webhook mechanism. If you want async, push-based delivery for DeepSeek reasoning calls, you're responsible for the whole wrapper: put the synchronous call behind your own worker (Celery, RQ, a Lambda, whatever), and have that worker fire a webhook to your own downstream systems when it finishes.

The upshot: "the reasoning model will call your webhook" is true for OpenAI, only true in a limited (batch, poll-based) sense for Anthropic, and not true at all for DeepSeek unless you build it. Design your integration layer per-provider rather than assuming one abstraction covers all three — and re-check the docs before you ship, because this is an area every provider is actively iterating on.

Building a receiver that survives retries, bursts, and bad deploys

Whichever provider is calling you back, the receiving side has the same requirements. The core rule is acknowledge first, process later — most providers' outbound webhook calls time out in single-digit seconds, and a slow receiver gets marked as a failed delivery and retried, which is how duplicate processing starts.

Code example
import hmac, hashlib, time, json
from fastapi import FastAPI, Request, HTTPException, Header, BackgroundTasks
import redis

app = FastAPI()
r = redis.Redis(host="localhost", port=6379, db=0)

WEBHOOK_SECRET = "whsec_..."           # from your provider's dashboard
MAX_CLOCK_SKEW = 300                    # seconds, guards against replay

def verify_signature(body: bytes, sig: str, ts: str) -> bool:
    if not sig or not ts:
        return False
    if abs(int(time.time()) - int(ts)) > MAX_CLOCK_SKEW:
        return False
    signed = f"{ts}.{body.decode()}".encode()
    expected = hmac.new(WEBHOOK_SECRET.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)

def handle_completed_job(payload: dict):
    job_id = payload.get("id") or payload.get("data", {}).get("id")
    # Idempotency: first writer wins, everyone else is a duplicate delivery
    if not r.setnx(f"seen:{job_id}", 1):
        return
    r.expire(f"seen:{job_id}", 86400)
    # ... persist result, notify the user over WebSocket/push, etc.

@app.post("/webhooks/llm-completions")
async def receive(
    request: Request,
    background_tasks: BackgroundTasks,
    signature: str = Header(None, alias="Webhook-Signature"),
    timestamp: str = Header(None, alias="Webhook-Timestamp"),
):
    body = await request.body()
    if not verify_signature(body, signature, timestamp):
        raise HTTPException(status_code=401, detail="bad signature")
    try:
        payload = json.loads(body)
    except json.JSONDecodeError:
        raise HTTPException(status_code=400, detail="bad json")

    background_tasks.add_task(handle_completed_job, payload)
    return {"status": "accepted"}

Four failure modes worth designing around explicitly:

  1. Duplicate deliveries. A slow 200 OK, a network blip, or your own retry logic on the provider side means the same completion can arrive twice. The SETNX-based idempotency key above is the cheapest fix — key it on the provider's job/response ID, not on your own generated ID.
  2. Deploys during in-flight jobs. If a job takes four minutes and your receiver redeploys in the middle of that window, the callback can land on a dead instance. Provider-side retry-with-backoff covers you if the provider retries (OpenAI's webhook system does, following Standard Webhooks conventions); for providers you're polling instead (Anthropic batches, DIY DeepSeek wrappers), make sure your poller is itself resumable after a restart.
  3. Oversized payloads. Reasoning traces and tool-call logs can turn a small JSON body into several megabytes. Parsing that synchronously in a single-threaded event loop stalls everything else on that worker — keep body parsing on the fast path and shove the heavy lifting into the background task, as above.
  4. Unverified signatures. A public POST endpoint with no signature check is an open door for spoofed "completions." Every major provider that supports webhooks signs its payloads (HMAC, following or adapted from the Standard Webhooks spec) — verify before you trust anything in the body.

Do you need a managed ingestion buffer?

Once you're past the basics, three problems tend to force the same next step: bursts (500 jobs finish within the same minute and hammer your app), deploy-window drops (your receiver is down for 20 seconds and a provider's retry budget runs out before you're back), and needing a durable, replayable log of what was delivered.

Rather than building a queue, a retry scheduler, and a dead-letter store from scratch, a lot of teams put a dedicated webhook-infrastructure layer in front of their own receiver: it absorbs the burst, retries deliveries to your app on your app's schedule (not the provider's), and holds a durable, replayable copy of everything so a bad deploy doesn't mean lost payloads. This is a live product category in 2026 — options include self-hosted, open-source gateways (e.g., Convoy) and managed inbound-ingestion services (e.g., Hookdeck), alongside sending-side platforms (e.g., Svix) if you're relaying results onward to your own customers' webhooks. None of these are required — SQS/Redis plus your own worker gets you the same properties if you'd rather own the infrastructure — but it's worth evaluating before reimplementing retry/backoff/DLQ logic from scratch.

Quick comparison

ApproachMax practical latencyResilience to drops/deploysNotes
Short pollingUnboundedLow — depends on poll frequencySimple, wasteful at scale
Long polling / SSEBounded by the shortest proxy timeout in the path (~30–100s typically)Fragile — one hop with a short timeout kills itGood UX while connected; not durable
Direct webhook receiver, no bufferUnbounded on the provider sideModerate — vulnerable to deploy-window drops without provider retriesFine for low volume, internal tools
Webhook + durable queue/bufferUnboundedHigh — bursts and downtime are absorbedRecommended for production reasoning-model workloads

FAQ

How do I tell the user their 3-minute job is done? Keep the webhook receiver focused on ingesting the provider's result; have it push a small event over WebSocket/SSE/push notification to the actual browser tab once processing finishes. Two different "real-time" mechanisms, two different jobs.

Can I mix streaming and webhooks? Yes — stream partial "thinking" indicators to an open client for UX, while also registering (or polling toward) a durable callback as the source of truth. If the tab closes mid-stream, the durable path still delivers the result to your backend.

What's the real ceiling on async job duration? For OpenAI background mode and Anthropic batches, effectively hours — you're bounded by the provider's own processing limits (e.g., Anthropic batches target completion within 24 hours), not by any client connection.

Do these model names and limits stay accurate? No — provider APIs, model IDs, and cloud-platform timeout defaults change frequently. Treat the specific numbers and model names above as a snapshot from August 2026 and verify against current provider docs before shipping.


Sources