WebhooksOctober 14, 20269 min read

Your Webhook Handler Calls an LLM. The Sender Hangs Up First.

Slack waits 3 s, Shopify 5 s, GitHub 10 s. An LLM call takes longer, so the sender hangs up and retries. Answer first and work after, or let a relay hold the event.

IN THE HANDLER · THE SENDER WAITS ON YOUR MODELSlackverify + LLM call8sSlack hangs up at 3sretry → it runs twiceBEHIND A RELAY · THE SENDER'S CLOCK STOPS THERESlackAnyHookstored, then 200queueretrieshandlerLLM call, up to 60sthe sender gets its 200 in about a second; your model gets the time it needs
Inside the handler, the sender waits on your model call. Behind a relay, it waits on nothing but the relay.

Webhook senders wait seconds, not minutes: Slack gives your handler 3 seconds to answer, Shopify 5, GitHub 10, and a handler that calls an LLM usually needs longer, so the sender records a failure, retries or gives up, and on Slack and Shopify eventually switches the webhook off. The fix is to answer 200 as soon as the request is verified and make the model call after the response, either in background work you run yourself or behind a relay that answers the sender for you.

Everything below is the long version of those two sentences: how long each sender actually waits, what it does when you miss, four short ways to answer first and work after (with the catch in each), and what changes when a relay holds the event instead.

TL;DR

  • The sender's clock covers your whole handler: signature check, database reads, and the model call
  • Miss it and the sender retries. A handler that already did the work does it again: two replies in Slack, two orders tagged, two emails
  • Miss it often enough and Slack disables your event subscriptions, Shopify deletes the webhook. GitHub never retries at all
  • Answer first, work after: after() in Next.js, ctx.waitUntil() on Cloudflare (30 seconds at most), a background task in Express or FastAPI
  • None of those keeps the event if the background work dies. A durable version needs storage, a worker and a retry schedule, which is a queue
  • A relay is the queue you do not write: AnyHook answers the sender in about a second and gives your handler 60 seconds on Free, with retries

How long each sender waits

These come from each vendor's own documentation, collected with sources in our webhook provider reference.

SenderWaits for your 2xxAfter a failed delivery
Slack3 seconds3 retries: immediately, after 1 min, after 5 min. Event subscriptions disabled if over 95% of attempts fail within 60 min
LINE2 seconds (a later answer is logged as request_timeout)Redelivery is off by default. May suspend webhook sending if your server keeps failing
Shopify5 seconds (1 second to connect)8 retries over 4 hours. A subscription created through the Admin API is deleted after 8 consecutive failures
HubSpot5 seconds, for the whole batch of up to 100 notificationsUp to 10 attempts spread over 24 hours
Paddle5 secondsLive: 60 attempts over 3 days
GitHub10 secondsNone. You can redeliver by hand for 3 days
Twilio15 seconds by default, configurable1 retry by default, configurable from 0 to 5
StripeNot published. Its docs say to return 2xx before any slow logicExponential backoff for up to 3 days in live mode

Your real deadline is the tightest sender you receive from. A bot on Slack has 3 seconds, and a cold start can spend most of that before your first line runs.

Why an LLM call breaks it

A model call is the slow step in almost every handler written in 2026. A short completion takes seconds; one with a tool call, a long answer or a retry inside your SDK takes tens of seconds. Add a cold start and the sender has hung up before the first token arrives.

What happens next depends on the sender, and none of it is good:

  • Slack retries. Your handler gets the same event again while the first run is still waiting on the model. Both runs finish and both post. Users see the bot answer twice
  • Shopify retries, then deletes. Eight consecutive failures over four hours and a subscription created through the Admin API is gone. Orders keep coming in and your app stops hearing about them
  • GitHub does nothing. The delivery is marked failed and that is the end of it, unless someone redelivers it by hand within three days
  • LINE has redelivery switched off unless you turned it on, so a timeout is a message your bot never answered

The handler did not fail. It was slower than a clock it could not see.

The fix: answer first, work after

Verify the request, answer 200, then call the model. Every platform has a way to keep running after the response. Each also has a catch, listed under the code.

Next.js (App Router)

// app/api/slack/events/route.ts
import { after } from "next/server";

export async function POST(req: Request) {
  const raw = await req.text();
  if (!verifySlackSignature(raw, req.headers)) {
    return new Response("bad signature", { status: 401 });
  }
  const body = JSON.parse(raw);
  if (body.type === "url_verification") {
    return Response.json({ challenge: body.challenge });
  }

  after(async () => {
    const answer = await askModel(body.event.text); // the slow part
    await postToSlack(body.event.channel, answer);
  });

  return new Response(null, { status: 200 }); // well inside 3 seconds
}

The catch: after() runs for the route's maximum duration and no longer. If the model call throws, or the instance is recycled, the event is gone. Slack already has its 200 and will not send it again, and nothing recorded that it ever existed.

Cloudflare Workers

export default {
  async fetch(req: Request, env: Env, ctx: ExecutionContext) {
    const raw = await req.text();
    if (!(await verify(raw, req.headers, env))) {
      return new Response("bad signature", { status: 401 });
    }
    ctx.waitUntil(handle(JSON.parse(raw), env)); // runs after the response
    return new Response(null, { status: 200 });
  },
};

The catch: waitUntil gets at most 30 seconds after the response, shared by everything you passed to it, and then it is cancelled. A slow model call does not fit. Cloudflare's own answer for longer work is a Queue with a consumer Worker, which is the right one. Our Workers guide covers the rest of the platform's traps.

Express or any long-running Node server

app.post("/webhooks/slack", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifySlackSignature(req.body, req.headers)) return res.sendStatus(401);
  res.sendStatus(200); // answer first
  setImmediate(() => handle(JSON.parse(req.body)).catch(console.error));
});

The catch: this is fine until the process restarts. A deploy, a crash or an out-of-memory kill mid-call loses whatever was in flight. On a serverless host the process may also be frozen the moment the response is sent.

Python (FastAPI)

from fastapi import BackgroundTasks, FastAPI, Request, Response

app = FastAPI()

@app.post("/webhooks/line")
async def line_webhook(request: Request, background: BackgroundTasks):
    raw = await request.body()
    if not verify_line_signature(raw, request.headers.get("x-line-signature")):
        return Response(status_code=401)
    background.add_task(handle_events, raw)  # runs after the response is sent
    return Response(status_code=200)

The catch is the same as Express: the task lives in the process. Restart it and the task is gone, with no retry and no record.

What the four have in common

Each one answers 200 for an event that, at that moment, exists only in memory. The sender stops caring, and if your background work fails there is no second chance and nothing to look at afterwards.

The durable version is a known shape: write the event down before you answer, then let a worker process it and retry it when it fails, and keep enough of a record to see what failed and send it again. That is a queue with a dead-letter list and a replay button. It is not hard to build. It is one more thing to run, monitor and be paged for, and it is not the thing your bot is for. Our Vercel guide shows the persist-then-answer version if you want to own it.

If you would rather not run a queue

Put a relay in front of the handler. You change the webhook URL at the sender to your AnyHook URL and nothing else.

  • The sender gets its 200 in about a second. The event is stored before that 200 goes out
  • Your handler then gets the event with a budget of 60 seconds on Free, 120 seconds on Pro and 5 minutes on Scale. A model call fits inside it, so the handler can do its work inline and answer when it is done
  • If your handler fails or runs out of time, AnyHook retries on its own schedule: 5 min, 30 min, 3 h, 18 h, then daily, up to 3 attempts on Free, 5 on Pro and 10 on Scale
  • Every event is kept in a log you can search (3 days on Free, longer on paid plans), and any of them can be replayed after you fix a bug
  • AnyHook answers Slack's url_verification challenge for you, so the URL saves on the first try

Delivery is at-least-once. A handler that finished its work but answered after the budget ran out will see the event again, so make it idempotent: skip anything whose X-AnyHook-Delivery header (the event id, the same on every retry) you have already handled. The sender's own id, such as Slack's event_id or GitHub's X-GitHub-Delivery, is forwarded too. Duplicate events and idempotency has the pattern.

What a relay cannot do is answer for you when the sender needs a real response body:

  • Slack slash commands and interactive buttons expect your reply in the response or a quick acknowledgment. Answer them from your own endpoint with an empty 200, then post the real answer to the response_url Slack sends, which takes up to five messages within 30 minutes
  • Discord interactions need an initial response within 3 seconds. Reply with a deferred response (type 5), then edit it with the model's answer when it arrives; the interaction token stays valid for 15 minutes
  • Twilio voice and messaging webhooks expect TwiML back. Answer with TwiML right away and send the slow answer through the REST API

Event webhooks, the kind that only need a 2xx, are what a relay is for: Slack's Events API, LINE, Shopify, GitHub, HubSpot, Stripe and most others.

Check your handler's time

You do not have to guess which side of the line your handler is on. The webhook timeout checker sends your endpoint a test request, times it, and puts the result next to each sender's deadline. It runs twice, so a slow first request after a quiet spell shows up as the cold start it is.

FAQ

Why does Slack say my bot timed out, or why did it reply twice? Slack waits 3 seconds for a 2xx. If your handler calls a model before answering, it takes longer, Slack counts a failure and sends the event again, and both runs post a reply. Answer first and post the reply afterwards with chat.postMessage, and drop events you have already seen.

Can I just return 200 and keep working on Vercel? Yes, with after() from next/server (or waitUntil), and the work runs for the route's maximum duration. It is not durable: if the work fails or the instance is recycled, the event is lost and the sender will not resend it. Fine for a notification, wrong for anything a user would notice missing.

How long does LINE wait for a webhook response? Two seconds. LINE counts a webhook your bot server has not answered within 2 seconds as a request_timeout in its error statistics, and recommends doing the work asynchronously. Webhook redelivery is off by default; turn it on in your channel's Messaging API settings.

Does putting a queue in front make my bot reply slower? No. The relay forwards the event as soon as it is stored, so your handler starts at about the moment it would have anyway. What changes is that the sender is no longer holding the connection while the model thinks. Retries add time only when a delivery failed, and without them that event would have been lost.

What if my handler takes longer than 60 seconds? On Free, AnyHook waits 60 seconds for your handler to answer; Pro waits 120 seconds and Scale 5 minutes. For work longer than that, answer 202 as soon as you have queued the job and finish it on your side. The relay counts the 202 as delivered.

Does this work for Discord bots and Slack slash commands? Not through a relay. Both need a specific response body within 3 seconds, which only your own code can give. Use Discord's deferred response and Slack's response_url as described above. Slack's Events API, which only needs a 2xx, works behind a relay.

Will my handler ever see the same event twice? It can, with or without a relay: senders retry, and so does AnyHook when your handler fails. Treat every handler as at-least-once and skip ids you have already handled.

All postsOctober 14, 2026 · 9 min

Stop losing webhooks.

Change one URL. Get retries, an event log and one-click replay. Or see it first: sign up and run the 30-second outage drill, and watch an event survive your server being down. No server needed.