Your bot works perfectly in development. In production it answers sometimes. Users report that it "usually" replies, or that it replied three times to one message, or that it went quiet for a whole afternoon and came back on its own.
All three are the same bug. Slack gave you three seconds, your handler took four, and everything after that is Slack's retry policy interacting with a handler that was not written to expect it.
TL;DR
- "Your app should respond to the event request with an HTTP 2xx within three seconds"
- Slack retries three times: nearly immediately, after 1 minute, then after 5 minutes
- A non-idempotent handler turns those retries into duplicate messages people can see
x-slack-no-retry: 1on a non-200 response stops the retries for events you know you cannot handle- Fail more than 95% of deliveries in a 60-minute window and Slack disables your event subscriptions and emails the app owner
Three seconds is not a lot of anything
Look at what a plausible handler does between receiving message.channels and being ready to reply. It verifies the signature. It looks up which workspace this is. It reads some state from your database. Then, if the bot is doing anything interesting in 2026, it calls a model.
The first three are maybe 200ms on a good day. The fourth is 30 seconds.
This is the same time-scale mismatch that shows up with payment webhooks, except the budget is roughly seven times tighter than Stripe's (every sender's budget is in our webhook provider reference) and the consequence is visible rather than silent. Nobody watches a payment_intent.succeeded fail. Everybody watches a bot not answer.
The general shape of the fix is covered in Webhooks for AI Agents, but Slack has a specific escape hatch that makes it much less painful than most.
The trick Slack gives you and Stripe does not
Slack separates acknowledging the event from responding to the user. You return 200 immediately, then post your actual reply later using chat.postMessage, or by hitting the response_url that interactive payloads carry.
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);
// URL verification handshake, only during setup
if (body.type === "url_verification") {
return Response.json({ challenge: body.challenge });
}
await queue.publish(body); // durable, ~10ms
return new Response(null, { status: 200 }); // well inside 3s
}
The worker then takes as long as it needs and calls chat.postMessage when it has something to say. From the user's side this reads as the bot thinking, which is a normal thing for a bot to do. From Slack's side the event was acknowledged in 40 milliseconds.
If the work will take more than a second or two, post an immediate placeholder and update it. A message that says "working on it" and then edits itself is a much better experience than three seconds of nothing followed by a timeout.
What the retries actually do to you
Slack's schedule is short and specific: "The first retry will be sent nearly immediately. The second retry will be attempted after 1 minute. The third and final retry will be sent after 5 minutes."
Now consider a handler that does the work first and returns 200 last, and takes four seconds. Slack times out at three. Your handler finishes at four and posts a message. Slack retries nearly immediately, your handler runs again, posts again. A minute later, a third message. Five minutes later, a fourth.
The user asked one question and got four answers, spread over six minutes, which looks less like a slow bot and more like a broken one.
The fix is the same unique-constraint pattern as everywhere else, keyed on Slack's event_id:
const { rowCount } = await db.query(
`INSERT INTO processed_events (event_id, provider)
VALUES ($1, 'slack') ON CONFLICT (event_id) DO NOTHING`,
[body.event_id],
);
if (rowCount === 0) return new Response(null, { status: 200 });
Note the 200 on the duplicate path. Returning an error there tells Slack to retry the thing you just successfully ignored. More on the concurrency case, where two retries land on two instances in the same millisecond, in You Received the Same Webhook Twice.
The header nobody uses
Some events are never going to succeed. The workspace uninstalled your app, the channel was archived, the payload references an object you deleted. Retrying those wastes three deliveries and, more importantly, pushes your failure rate toward the threshold that gets your subscriptions turned off.
Slack lets you opt out per response: "Provide us this HTTP header and value as part of your non-200 OK response: x-slack-no-retry: 1".
if (!workspace) {
return new Response("unknown workspace", {
status: 410,
headers: { "x-slack-no-retry": "1" },
});
}
Use it for terminal conditions only. A database timeout is not terminal and you want that retry.
The failure threshold
This is the part that turns a slow afternoon into an outage.
"If your application enters any combination of these failure conditions for more than 95% of delivery attempts within 60 minutes, your application's event subscriptions will be temporarily disabled."
Slack emails the app's creator and owner when this happens. Which is useful only if that address belongs to somebody still at the company and still reading it. This is the same trap as Shopify's emergency developer email, described in Shopify Deleted Your Webhook Subscription: the one warning you get is routed to an address chosen during setup and never revisited.
Worth internalising: 95% within an hour is not a slow decay you will notice. A deploy that breaks signature verification hits 100% instantly, and an hour later the subscriptions are off. The bot does not error. It simply stops receiving anything, which from the inside looks exactly like a quiet channel.
Debugging a bot that answers sometimes
Check these in order.
The response time, not the error rate. A handler at 2.8 seconds passes every test you write and fails whenever the database is slightly busy. Measure the p99 rather than the mean, because the p99 is what Slack times out on.
Cold starts, if you are on serverless. Two seconds of cold start out of a three-second budget leaves one second for everything else. Receiving Webhooks on Vercel Without Losing Them covers keeping the route's import graph small, which buys back more of the budget than anything else you can do here.
Whether the retries are yours. A duplicate message six minutes after the first is Slack's third retry, not a bug in your posting logic. The timing tells you which.
Whether the subscription is still enabled. If everything went quiet at once and nothing in your logs changed, check the app's Event Subscriptions page before you debug anything else.
Where AnyHook fits
Three seconds is a transport constraint, and transport is what a relay replaces.
Point the Slack Request URL at in.anyhook.net/you/slack. AnyHook verifies and persists at the edge and returns 200 in under 50ms, every time, whether your handler is warm, cold, or on fire. Slack's three-second clock is satisfied by an edge worker that only ever does two things, so the retry sequence never starts and the 95% threshold never gets close.
- Your handler gets 60 to 300 seconds depending on plan, instead of three
- Every event is persisted before delivery, so a deploy window is a replay rather than a set of questions your users ask
- Delivery to your endpoint retries on exponential backoff for far longer than Slack's six-minute sequence
- Failure alerts fire at 1, 5, and 20 consecutive failures, well before anything reaches a threshold Slack acts on
The url_verification handshake still has to be answered by something that knows your signing secret, so keep that branch in your own handler.
Takeaway
Slack's three seconds is the tightest budget any major sender enforces, and the only one where missing it is visible to the people using your product. Acknowledge first and reply second, always. Dedupe on event_id before you do anything a human would see twice. Send x-slack-no-retry: 1 when you know the event is dead. And go check which address is on the app as owner, because that is where the notice arrives when your subscriptions get switched off.