Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

Slack answers 200 OK when your message did not go out

Here is a complete, successful HTTP exchange with Slack: POST https://slack.com/api/chat.postMessage Authorization: Bearer xoxb-… HTTP/1.1 200 OK {"ok":false,"error":"not_in_channel"} The message did not go any

Here is a complete, successful HTTP exchange with Slack:

POST https://slack.com/api/chat.postMessage
Authorization: Bearer xoxb-…

HTTP/1.1 200 OK
{"ok":false,"error":"not_in_channel"}

The message did not go anywhere. The status line says it did.

If your code does what most HTTP code does — if (res.ok) return; — every failure Slack can describe to you looks exactly like success. We just built Slack, Teams, Discord and Google Chat delivery into Pulsenote, and almost none of the work was sending. It was reading the answer.

Why the Web API at all

The quick way to post to Slack used to be an incoming-webhook URL: paste it, POST some JSON, done. Slack is retiring the old Incoming WebHooks integration, and the way in that it points you to now is a small app in your own workspace: create it at api.slack.com/apps, give it the chat:write scope, install it, and you get a bot token starting with xoxb-. With that token you call chat.postMessage.

The webhook endpoint answers with HTTP status codes, like a normal service. The Web API does not. Apart from rate limits and outages, it answers 200 and puts the verdict in the body. So the first rule of our Slack sender is in its doc comment:

/** The Web API answers 200 with `ok: false` for most errors; HTTP status only for 429 and outages. */
export function classifySlackApi(res: HttpResult): DestinationDelivery {
  if (res.status === 429 || res.status >= 500) {
    return classifyResponse(DestinationType.SLACK, res);
  }
  // …parse the body, then decide on `ok` and `error`
}

Three outcomes, not two

Once you are reading the answer properly, "worked / didn't" turns out to be too coarse. A failed delivery means one of three things, and each needs a different response:

export type DestinationDelivery =
  | { status: 'sent' }
  /** The URL will never work again — the destination must be marked broken. */
  | { status: 'broken'; reason: string }
  /** Did not go out. `retry` says whether trying the same bytes later can help. */
  | { status: 'failed'; reason: string; retry: boolean; retryAfterS?: number };

Try again later. A 429, a 5xx, a timeout, Slack's own ratelimited code. The platform is having a bad minute; the same request will work later.

Give up on this message. Any other 4xx. The receiver looked at these bytes and said no. Sending the identical bytes again will be refused again, and it tells you nothing about whether the destination still works for the next message. The comment in our code says exactly that, because the temptation to retry it is strong.

The destination is dead. The app was uninstalled, the token revoked, the channel archived, the bot never invited. Every future message will fail the same way. Retrying is pointless; what you need is a person to fix something — and they need to be told what.

That last group is where Slack's error codes earn their keep. They are specific enough to turn into instructions:

const SLACK_APP_BROKEN: Record<string, string> = {
  token_revoked:
    'The app was removed from the workspace or its token was revoked.',
  missing_scope:
    'The app is missing the chat:write scope. Add it under OAuth & Permissions and reinstall.',
  not_in_channel:
    'The app is not in that channel. Invite it there with /invite @your-app, or add the chat:write.public scope.',
  is_archived: 'That Slack channel is archived.',
  // …and a few more
};

When one of these comes back, we mark the destination broken with that sentence as the reason, and any further send to it is refused straight away with a 422 that carries it, instead of joining a queue that will never drain. A successful test send clears the flag.

For the platforms that do use status codes — Teams, Discord, Google Chat, a plain webhook — the same rule reads 404 and 410 as "this URL is gone".

Check the token before you keep it

The cheapest place to find a bad token is the form where someone pastes it. Slack has a method for exactly this, auth.test, which costs nothing and returns the workspace name when the token is good. We call it before saving, so a copy-paste mistake fails in front of the person who made it, not at 3 a.m. on the first real alert.

We also refuse a token starting with xoxp- with a specific message. It is a user token, it works, and it posts as that person — which is not what anyone wanted for an order notification.

Every platform has its own trap

Slack's 200 is the most surprising, but it is not the only one.

Discord pings people. A message containing @everyone that you forwarded from user input will notify the whole server. Every Discord message we send carries allowed_mentions: { parse: [] }, which turns that off. Discord also stops at 2,000 characters per message, so we cut there rather than have the whole message refused.

Teams changed its front door. The Office 365 connector URLs on *.webhook.office.com are being retired by Microsoft; incoming webhooks now come from Workflows, on Power Automate hosts. We only accept the new shape, because a URL that works today and stops on Microsoft's schedule is a support ticket with a date on it. Teams also wants an Adaptive Card wrapped in a message envelope, not a bare text field.

Your own endpoint cannot tell a retry from a second message unless you give it something to compare. Every webhook body carries the message id, and every request is signed (X-Pulsenote-Signature: sha256=…, an HMAC of the raw body).

Retries belong in the database

The retry schedule — 10 seconds, 1 minute, 5 minutes, 30 minutes, five attempts in all — is stored on the message row, not in a timer:

/**
 * The retry schedule lives on the row, not in memory, so a deploy in the
 * middle of a Slack outage loses nothing.
 */

That is the failure that in-process retry loops hide: they work in every test and lose messages exactly when the platform is down and you ship a fix for it. When the platform sends Retry-After, we wait that long instead (capped at ten minutes, so a bad header cannot park a message for a day).

"Sent" is still not "delivered"

One more thing we were careful about. When Slack says ok: true, our record says SENT — and never DELIVERED. For email, "delivered" means a receiving mail server accepted the message, and our abuse checks and deliverability warnings are computed from it. A chat message counted as an email delivery would quietly make those rates look healthier than they are. Two facts, one word again, in a new place.

What to do on Monday

  • If you call the Slack Web API, read ok in the body. The status code will not tell you.
  • Sort failures into retry, drop this message and destination dead. Only the first deserves a retry; only the last deserves a human.
  • Turn the platform's error code into a sentence a person can act on, and show it where they will see it.
  • Validate credentials when they are entered, not when they are first used.
  • Turn off mentions for any text you did not write yourself.
  • Keep the retry schedule somewhere that survives a restart.

If you would rather not write those six things, this is what Pulsenote destinations do — the same API key and the same log as your email. But the list is worth having either way: the hard part of sending a chat message was never the sending.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.