🛡️ Portarus: the gateway for AI agents Discover more →
Engineering

How to Stop Duplicate Slack Bot Events

Ack fast, remember what you ran, and your bot answers once.

Orlando Quijada · · 5 min read
A Slack event delivered three times by retries, collapsed into a single processed event by deduplication on event_id before it reaches the AI bot
  • ~18% of Slack events we see is a retry of one already handled.
  • Slow handlers miss the 3s ack and trigger up to 3× runs.
  • Instant ack plus event_id dedup cuts duplicate runs to near 0.

The problem

  • Slack retries any event you don't ack with 200 in 3 seconds — and your LLM call is slower than that.
  • Each retry is a full run of your agent, with the same event_id.
  • You pay the token cost for every retry, up to three per message.

The solution

Answer Slack first, run the agent after — and only once.

Return the 200 the instant the event lands, then hand the work off. Keep a short memory of the event_ids you've already handled so any repeat gets dropped.

  1. Ack with 200 within 3 seconds, before any LLM call.
  2. Remember each event_id for a short window — a few minutes.
  3. Seen that id already? Drop it. New? Run the agent.

Key on the immutable event_id, never on ts — ts isn't unique per delivery.

Result: one event → one agent run, however many retries Slack sends.

See the full fix
A closer look

Your Slack bot answers the same message two or three times, and you never wrote a loop. The cause is almost always the Events API retrying delivery, and each retry is a full run of your AI agent. Here is why it happens and how to stop it.

Why your Slack bot fires twice

Slack expects a fast 200 and retries the event — up to three times with backoff — when it doesn’t get one in time. If your handler calls an LLM before responding, you blow past the three-second window, Slack assumes failure, and it sends the exact same event again. Every retry carries the same stable event_id, so your agent sees what looks like three separate messages and bills three times for one user intent.

The retries announce themselves with two headers — X-Slack-Retry-Num (which attempt this is) and X-Slack-Retry-Reason (usually http_timeout) — but the payload is otherwise identical to the first delivery.

There are two ways to stop it: build the defenses yourself, or put a gateway in front that does it for you. This post walks the full manual path first — so you understand exactly what’s involved — then lets you compare the two at the end.

The two sources of duplicates

Not all duplicates are the same, and knowing the source tells you which defense you need.

SourceWhat causes itHow you catch it
Timeout retriesSlack re-sends after a slow ackFast 200, then same event_id
Non-timeout repeatsProcess restart or multi-instance deliverySame event_id

Timeout retries are the common case, and a fast acknowledgement prevents most of them outright. The rest — a crash mid-task, the same event reaching two instances behind a load balancer — slip past the ack and are caught only by remembering the event_id.

The manual fix (Option 1)

Two defenses, layered. Each one is something you build and maintain yourself.

Fix 1: acknowledge before you process

The direct cause is a slow acknowledgement, so respond first and think later. Return 200 to Slack the instant the event arrives, then hand the work to a background task or queue — the ack lands long before your agent finishes.

On a gateway, the instant ack is the default — nothing to wire into your handler.

Fix 2: deduplicate on event_id

To be safe regardless of why a duplicate arrives, remember every event_id you’ve already handled and drop repeats. Read the id from the payload (not ts, which can repeat across events), check a short-lived store, and proceed only if it’s new. The window only needs to cover Slack’s retry schedule — a few minutes is plenty.

Because the check keys on the immutable event_id, it catches duplicates from retries, restarts, and multi-instance delivery alike — which fast-acking on its own cannot.

On a gateway, the id store and the window are built in — not code you host or tune.

The catch: you have to keep it running

None of this is one-and-done. You host the id store, keep the ack path faster than three seconds as your handler grows, and make sure every instance shares the same memory — then repeat the whole setup on every channel you add. For a single Slack app it’s fine. Across a fleet of channels, the maintenance quietly becomes its own job.

Place a gateway layer (Option 2) — faster

Instead of building the two defenses into every app, you point your channels at one layer that already does all of it — instant ack, id dedup, a shared window — before anything reaches your agent.

  • One layer, every channel. Set it up once; it covers Slack, WhatsApp, webhooks and the rest.
  • Nothing to maintain. No id store to host, no ack budget to defend.
  • Tune, don’t code. The window is a setting, not a code path.
  • The 3-second contract is always met. The gateway acks instantly, no matter how slow your LLM call is.

You trade a little control of the internals for a lot less work. For most teams running more than one channel, that’s the trade worth making.

Option 1 (DIY) vs Option 2 (gateway)

Neither is wrong — one trades time for control, the other control for time. The honest side-by-side:

 Do it yourselfUse a gateway
Fast ack (3s contract) ~Wire async per handler ✓Instant, built in
event_id store & window ✕You build & host ✓Built in
Restart / multi-instance ~Needs a shared store ✓Handled for you
Every channel ~Repeat setup each time ✓One layer, all channels
Ongoing maintenance ✕Yours, forever ✓Handled for you
Control of internals ✓Full ~Abstracted away

This is the Slack-specific case of a general problem — see how to stop duplicate messages across any channel for the platform-agnostic version.

If option 2 sounds more like you, here’s the short version of how to set it up: let a gateway handle the ack and the dedup →.

Either way — go make your bot answer once per message.

Thanks for reading. 👋

Orlando. -

Found this helpful? Share it with someone who needs it

OQ

Orlando Quijada

Technologist with 15+ years of experience. Developer, SaaS tester, and AI collaboration & optimization specialist. Currently contributing at portarus.com.

Written on

What to read next