zudo-slack-wisdom
GitHub repository

Type to search...

to open search from anywhere

Events API on Workers

The url_verification handshake, acking fast, retries, and event dedupe for an Events API endpoint running on a Worker

Registering a Request URL

Event Subscriptions live under your app's settings on api.slack.com. Once enabled, you point Slack at a single Request URL -- a route on your Worker -- and Slack starts sending both the handshake below and, once you subscribe to event types, live traffic to it. Request URLs are case-sensitive.

The url_verification Handshake

Before Slack activates a Request URL, it POSTs a one-time challenge to confirm you control the endpoint:

{
  "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl",
  "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P",
  "type": "url_verification"
}

Your Worker must answer with HTTP 200 and echo the challenge value back. Plaintext, form-encoded (challenge=...), and JSON ({"challenge": "..."}) responses are all accepted (reference). A minimal handler branches on type before doing anything else:

const body = await request.json<{ type: string; challenge?: string }>();

if (body.type === "url_verification") {
  return Response.json({ challenge: body.challenge });
}

Verify the signature first

The handshake request is signed the same way every other event is. Route it through signature verification before this branch runs -- skipping that here means anyone who guesses your Request URL can probe it. SeeVerifying Requests for theX-Slack-Signature / X-Slack-Request-Timestamp check (source).

Ack Fast, Do the Work in waitUntil

Once subscribed, every matching event arrives as its own POST, and Slack expects a 2xx response within 3 seconds (source). On a Worker that means returning the response before any Slack Web API call, D1 write, or AI summarization finishes -- hand that work to ctx.waitUntil() instead of awaiting it inline before responding. See The 3-Second Ack for the pattern.

Retries and Event Dedupe

Missing the 3-second window doesn't drop the event. Slack retries up to three times: the first retry lands nearly immediately, the second after about 1 minute, and the third (and final) after about 5 minutes (source) -- so a single slow or flaky response can result in the same event landing on your endpoint up to four times total (the original delivery plus three retries).

Each retry carries two headers (source):

  • X-Slack-Retry-Num -- the attempt number: 1, 2, or 3.

  • X-Slack-Retry-Reason -- why Slack is retrying: http_timeout (no 2xx within 3 seconds), connection_failed, ssl_error, http_error, too_many_redirects, or unknown_error.

Don't use the retry headers to dedupe, though -- they only tell you this delivery is a retry, not whether you already processed the underlying event. Every event envelope carries a stable event_id; key your dedupe check on that (a short-TTL KV or D1 row is enough) and return 2xx for an event_id you've already handled instead of erroring, since an error just spends another retry without changing the outcome.

Delivery Identity vs. Domain Identity

The event_id dedupe above suppresses one thing: the same delivery envelope landing on your endpoint twice. It says nothing about the underlying fact the envelope describes. A user removing and re-adding the same reaction mints a fresh event_id for each reaction_added event -- two envelopes, one action worth reacting to once. And in a hybrid push-plus-poll design, the same fact can show up a third way: a polling sweep that reads current state observes it with no event at all.

Layer a second key underneath the envelope key. A UNIQUE constraint on event_id suppresses redelivery of one envelope; a second UNIQUE constraint on the fact itself -- e.g. (channel, message_ts, reaction, user) for a reaction -- collapses distinct envelopes and poll-path observations into a single side effect:

CREATE TABLE reaction_dedupe (
  event_id TEXT PRIMARY KEY,
  channel TEXT NOT NULL,
  message_ts TEXT NOT NULL,
  reaction TEXT NOT NULL,
  user_id TEXT NOT NULL,
  UNIQUE (channel, message_ts, reaction, user_id)
);

An INSERT ... ON CONFLICT DO NOTHING against this table only succeeds once per (channel, message_ts, reaction, user_id) tuple, regardless of which event_id it arrived under -- or whether it arrived under an event at all.

A Durable Ledger for Must-Eventually-Happen Side Effects

Dedupe-and-skip is enough when losing a duplicate is harmless. Some side effects are the opposite: dropping the first attempt is the failure you can't tolerate, because the work has to happen eventually even if the immediate delivery attempt fails outright.

Upgrade to a durable ledger: in one D1 insert, atomically record the event_id and the work to be done (INSERT ... ON CONFLICT (event_id) DO NOTHING), ack 200, then attempt the delivery as background work. A failed or interrupted attempt leaves that ledger row undelivered -- it doesn't vanish -- and a cron sweep reconciles the table on a schedule, retrying whatever is still marked undelivered. The immediate waitUntil() attempt is an optimization for the common case; the cron is the correctness backstop for the case where it doesn't land. The fact-identity key from above still applies here, so a cron retry that fires alongside a fresh event for the same underlying fact collapses into the one ledger row instead of doing the work twice.

Be honest about what this does and doesn't close: the external call succeeding and the ledger row being marked delivered are two separate operations, not one atomic one. A crash between them leaves the row looking undelivered when the side effect actually already happened, and the cron retries it -- so this is an at-least-once guarantee for the outbound side effect, not exactly-once. Design the side effect itself to tolerate a duplicate call (or dedupe it downstream) rather than assuming the ledger alone makes it safe to run once. See The 3-Second Ack for the fuller outbox-pattern treatment of this same ledger-plus-cron shape.

Filter your bot's own events by bot_id, not subtype

A bot installed with granular (modern xoxb) permissions posts messages that carry bot_id but no subtype at all -- a filter that only checks subtype === "bot_message" lets those posts straight through, and a bot that reads the channel it writes to ends up triggering itself in a loop. Treat bot_id presence as the primary signal that a message is the bot's own; keep the subtype check only as a fallback for classic-token apps that still set it. Apply the same rule when reading messages back through conversations.history or conversations.replies, not just on the live event stream.

The bot's own reactions.add calls come back too, through the samereaction_added subscription, but reaction events carry no bot_id to filter on -- keep the bot's own user ID in config (separate from the token) and compare it against the event's user field instead.

Subscription Scopes vs. Event Types

Event access rides on the same OAuth scope system as everything else (source): each event type in the event reference lists which scope grants it -- files:read for file_created, reactions:read for reaction_added, and so on. The scope is what actually authorizes delivery -- request only the scopes your event handlers actually consume, per the minimal-scope principle in Tokens, Scopes & OAuth.

Team-level events also carry a per-app ceiling of 30,000 deliveries per workspace per rolling 60 minutes; exceeding it produces an app_rate_limited event instead of the traffic you subscribed to (source).

Revision History

CreatedUpdated