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.
| Sender | Waits for your 2xx | After a failed delivery |
|---|---|---|
| Slack | 3 seconds | 3 retries: immediately, after 1 min, after 5 min. Event subscriptions disabled if over 95% of attempts fail within 60 min |
| LINE | 2 seconds (a later answer is logged as request_timeout) | Redelivery is off by default. May suspend webhook sending if your server keeps failing |
| Shopify | 5 seconds (1 second to connect) | 8 retries over 4 hours. A subscription created through the Admin API is deleted after 8 consecutive failures |
| HubSpot | 5 seconds, for the whole batch of up to 100 notifications | Up to 10 attempts spread over 24 hours |
| Paddle | 5 seconds | Live: 60 attempts over 3 days |
| GitHub | 10 seconds | None. You can redeliver by hand for 3 days |
| Twilio | 15 seconds by default, configurable | 1 retry by default, configurable from 0 to 5 |
| Stripe | Not published. Its docs say to return 2xx before any slow logic | Exponential 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_verificationchallenge 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_urlSlack 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.