• Alerts
  • Node.js
  • Getting started

Slack webhook in Node.js: send alerts with Block Kit

Send alerts with a Slack webhook in Node.js: create the webhook, format Block Kit messages, escape user text, handle 429 rate limits and every error code.

Author
Extenify Team
Published
Reading time
13 min read
A column of four old cream-colored doorbell buttons with bell symbols on a brown wooden door frame beside a peeling white wall

A Slack webhook in Node.js is the quickest way to get an alert in front of your team: a failed job, a new signup, a 1-star review or a competitor's new release. You create an incoming webhook for one channel, keep its URL secret and send it JSON with fetch. There is no bot to host, no OAuth flow to build and no dependency to install.

Getting the first "Hello, world" into a channel takes five minutes. The work is in everything after: messages that look good on desktop and still produce a readable notification on a phone, review text that contains <!channel> and pings everyone, a burst of alerts that runs into a 429, and errors such as invalid_payload or no_service that you should never retry. This guide covers all of it with Node.js 18 or newer and no packages, based on Slack's incoming webhooks guide, its rate limits and its Block Kit reference, all read in October 2026. We tested the code against a local mock server and against Slack's real endpoint.

Key takeaways

  • The webhook URL is the credential. Slack says it "contains a secret" and "actively searches out and revokes leaked secrets." Keep it in an environment variable or secret store.
  • One webhook posts to one channel. You cannot override the channel, username or icon from the payload.
  • Always send a top-level text alongside blocks. Slack uses it for notifications, and mobile notifications "exclusively use message.text."
  • Escape &, < and > in any text you did not write, such as review text. It also stops a review from injecting <!channel>.
  • Limits: about one message per second per channel, 50 blocks per message, 3,000 characters per section, 150 for a header. A 429 comes with a Retry-After header.
  • A successful post returns HTTP 200 with the plain text body ok. Errors are plain text too, such as invalid_payload or no_team.

Step 1: Create a Slack incoming webhook

Incoming webhooks belong to a Slack app, so you need one, even if it only ever posts messages:

  1. Go to your Slack apps page and create a new app from scratch (or open an existing one) for your workspace.
  2. Open Incoming Webhooks in the app settings and switch on Activate Incoming Webhooks.
  3. Click Add New Webhook to Workspace, pick the channel and click Authorize.
  4. Copy the URL under Webhook URLs for Your Workspace. It starts with https://hooks.slack.com/services/.

Each URL is tied to the channel you picked. Slack's docs say you "cannot override the default channel", and the username and icon always come from the app's settings. To post to three channels, create three webhooks and store three URLs.

Treat the URL like a password. Anyone who has it can post to your channel, and it often ends up in places it should not: a committed .env file, a CI log, a screenshot in a bug report. Slack revokes URLs it finds leaked, which is safer for you but means an alert pipeline that silently stops working. Keep it in your platform's secret store and read it from process.env.SLACK_WEBHOOK_URL.

Step 2: Send your first Slack webhook message from Node.js

Node.js 18 and newer ship fetch, so the smallest working sender is a single POST with a JSON body:

const res = await fetch(process.env.SLACK_WEBHOOK_URL, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ text: 'Hello from Node.js' }),
});
console.log(res.status, await res.text()); // 200 ok

The same request from a terminal is useful when you want to check a URL without running your app:

curl -X POST -H 'Content-Type: application/json' \
  --data '{"text":"Hello from curl"}' "$SLACK_WEBHOOK_URL"

Slack answers a successful post with HTTP 200 and a plain text ok. Read the body as text, not JSON: the error responses are plain words too, as we found when we posted to a deliberately fake webhook URL and got back HTTP 404 with the body no_team.

If you would rather use a library, Slack publishes @slack/webhook (version 8.0.2 at the time of writing). For one POST with retries, plain fetch is enough, and it keeps an alert script free of dependencies.

Format alerts with Block Kit

A plain text message is fine for a deploy notice. For alerts that people need to read and act on, Block Kit gives you headers, labeled fields and a footer line. Incoming webhooks accept the same blocks array as the Web API.

Here is a store review alert built from four blocks: a header, a section with three short fields, the review text as a quote, and a context line with a link.

// alert-blocks.js: turn one new review into a Block Kit message.
import { escapeSlack } from './slack.js';

const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 3)}...` : text);

export function reviewAlert({ appName, store, rating, version, country, text, url }) {
  const headline = `${appName}: new ${rating}/5 review on ${store}`;
  return {
    // Shown in notifications and anywhere blocks cannot be rendered.
    text: escapeSlack(headline),
    blocks: [
      { type: 'header', text: { type: 'plain_text', text: clip(headline, 150) } },
      {
        type: 'section',
        fields: [
          { type: 'mrkdwn', text: `*Rating*\n${rating}/5` },
          { type: 'mrkdwn', text: `*Version*\n${escapeSlack(version)}` },
          { type: 'mrkdwn', text: `*Country*\n${escapeSlack(country.toUpperCase())}` },
        ],
      },
      {
        type: 'section',
        text: { type: 'mrkdwn', text: `>${escapeSlack(clip(text, 2800)).replace(/\n/g, '\n>')}` },
      },
      { type: 'context', elements: [{ type: 'mrkdwn', text: `<${url}|Open the store listing>` }] },
    ],
  };
}

A few limits shape this code. Slack's reference gives them as:

Element Limit
Blocks in one message 50
header text (plain_text) 150 characters
section text 3,000 characters
section fields 10 items, 2,000 characters each
Top-level text Keep it under 4,000; Slack truncates past 40,000

The clip on the review text runs before escaping, so an entity such as &amp; is never cut in half, and it leaves headroom for the quote markers and the extra characters escaping adds. Write the rating as 1/5, not as star glyphs: it is searchable in Slack, survives every client and reads correctly in a screen reader.

Always send a top-level text

When a message has blocks, Slack uses the top-level text as the fallback, and the formatting guide is explicit about notifications: desktop notifications take text from the top-level field first, and "mobile notifications exclusively use message.text." An alert sent with blocks and no text produces a vague notification on the phone of the person who most needs to see it. Put the whole point of the alert in that one line.

Escape user text and stop accidental pings

Slack treats &, < and > as control characters. Its formatting guide says to replace them with &amp;, &lt; and &gt; when you mean them literally, and not to encode the rest of the text:

export function escapeSlack(text) {
  return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

This matters most for text that comes from outside, such as a review, a support ticket or a page title. Slack's special mentions use the same angle-bracket syntax: <!here> notifies the active members of a channel and <!channel> notifies all of them. A review that contains <!channel>, posted unescaped, pings everyone in the channel. Escaped, it shows up as harmless text. In our test, the review "Crashes on <!channel> launch & loses data" went out as Crashes on &lt;!channel&gt; launch &amp; loses data, which Slack displays exactly as the user wrote it.

The same angle brackets are how you write links on purpose: <https://example.com|label> shows "label" as a link. Build those from your own trusted values, as the context block above does, and never from user text.

Handle Slack rate limits and retries

Slack's rate limits page lists incoming webhooks at one per second, with "short bursts >1 allowed", and it also says "apps may post no more than one message per second per channel", across every way of posting. Exceed it for long and you get "HTTP 429 Too Many Requests" with a Retry-After header holding "the number of seconds until you can retry."

A sender for alerts should do three things: space its own posts, honor Retry-After on a 429, and never retry an error that will fail again. This version does all three:

// slack.js: post to a Slack incoming webhook with spacing, retries and clear errors.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export class SlackWebhookError extends Error {
  constructor(status, body, retryable) {
    super(`Slack webhook failed: HTTP ${status} ${body}`);
    this.status = status;
    this.body = body;
    this.retryable = retryable;
  }
}

let lastPostAt = 0;

export async function postToSlack(webhookUrl, payload, { maxAttempts = 4 } = {}) {
  for (let attempt = 1; ; attempt++) {
    // Stay at about one message per second per channel.
    const wait = lastPostAt + 1100 - Date.now();
    if (wait > 0) await sleep(wait);
    lastPostAt = Date.now();

    let res;
    try {
      res = await fetch(webhookUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
        signal: AbortSignal.timeout(10_000),
      });
    } catch (err) {
      if (attempt >= maxAttempts) throw err;
      await sleep(1000 * 2 ** attempt); // network error or timeout
      continue;
    }

    const body = (await res.text()).trim();
    if (res.ok) return body; // "ok"

    const retryable = res.status === 429 || res.status >= 500;
    if (!retryable || attempt >= maxAttempts) {
      throw new SlackWebhookError(res.status, body, retryable);
    }
    const retryAfter = Number(res.headers.get('retry-after'));
    await sleep(retryAfter > 0 ? retryAfter * 1000 : 1000 * 2 ** attempt);
  }
}

We ran it against a local mock server that answered the first request with a 429 and Retry-After: 1, then with ok. The helper waited, retried once and resolved in about 1.1 seconds. A second mock that always returned 400 invalid_payload failed on the first attempt with retryable: false, which is the behavior you want: retrying a malformed message only adds noise.

The spacing in this helper is per process. If several workers post to the same channel, put the alerts on a queue with one consumer, or send them through a single scheduled job. Our guide to running a Node.js cron job once and on time shows that pattern.

Slack webhook errors and what to do

Incoming webhooks return "more expressive errors" than the Web API, with real HTTP status codes such as 400, 403 and 404 and a short plain text reason. These are the ones Slack documents, with what we recommend doing about each:

Body Meaning What to do
invalid_payload Malformed JSON or badly escaped text Fix the payload. Slack: do not retry "without correction."
no_text No text field Always send text, even with blocks.
too_many_attachments More than 100 attachments Send fewer, or split the message.
channel_is_archived The channel was archived Alert the owner, create a new webhook.
channel_not_found, user_not_found Target no longer exists Create a new webhook.
action_prohibited An admin restricted posting Do not retry; ask the admin.
posting_to_general_channel_denied Posting to #general is restricted Use another channel.
no_service, no_active_hooks The webhook was disabled, removed or revoked Stop calling it; create a new one.
invalid_token, no_service_id, no_team, team_disabled The URL is wrong, expired or its workspace is gone Check the stored URL; replace it.

Only a 429 and 5xx responses are worth retrying. Everything above is permanent until a person changes something, so log it loudly and send a fallback alert somewhere else, such as email, rather than looping. When a webhook starts returning no_service the day after you shared a screenshot of your config, a leaked URL that Slack revoked is the likely cause.

Threads, edits and when a webhook is not enough

Webhooks can reply in a thread: Slack's docs say to pass the parent message's ts as thread_ts. The catch is that a webhook response is only ok and never returns the ts of the message you just posted, so you would have to look it up through another API. Webhooks also cannot edit or delete a message or upload a file.

If you need any of that, use a bot token and chat.postMessage, which returns the message ts and accepts the same blocks. For one-way alerts, a webhook is simpler and has less to go wrong.

There is also a no-code variant. Slack's Workflow Builder can start a workflow from a webhook on paid plans, taking up to 20 flat variables instead of a full message; Slack's help article on workflows that start outside of Slack covers it. It suits teams that want non-developers to own the message format, but it does not accept Block Kit from your code.

Design Slack alerts people keep reading

Most alert channels die from volume, not from bugs. A few rules keep a channel useful:

  • One alert per decision, not per event. Ten new reviews in an hour is one message with ten lines, not ten messages. Batch on a schedule and you also stay far below the rate limit.
  • Put the change in the first line. "Rating fell from 4.6 to 4.3 on Google Play" beats "Daily report".
  • Separate urgent from routine. Send 1-star reviews and failed releases to an alerts channel, and the daily digest to a quieter one, each with its own webhook.
  • Link to the source. Every alert should end with a link to the listing, the dashboard or the log line.

Our guide to store change alerts your team will actually read goes deeper on thresholds and routing, and the App Store review alerts walkthrough shows how to fetch new reviews from Google Play and the App Store before they reach Slack. If the alert is driven by comparing two snapshots, JSON diff in Node.js shows how to turn the changes into lines like the one above.

Slack webhook checklist

  • One Slack app, one incoming webhook per destination channel.
  • Webhook URLs stored as secrets, never committed or logged.
  • Every message has a meaningful top-level text, even with blocks.
  • User-supplied text escaped (&, <, >) and clipped before it goes into a block.
  • Headers under 150 characters, sections under 3,000, at most 50 blocks.
  • Posts spaced to about one per second per channel; 429 handled with Retry-After.
  • Permanent errors (invalid_payload, no_service and the rest) logged and escalated, not retried.
  • A fallback channel, such as email, when Slack delivery fails.

Get store alerts in Slack without writing the plumbing

If the alerts you want are about app and extension stores, Extenify already sends them. It is an app store rating alerts and Chrome extension analytics tool that checks your listings and your competitors' every day on the Chrome Web Store, Edge Add-ons, Firefox Add-ons, Google Play and the App Store, and posts what changed to Slack, email, Discord, Telegram or Microsoft Teams: rating drops, new low-star reviews, new versions, install swings and rank changes. You paste a Slack webhook URL, confirm it with the six-digit code Extenify posts to the channel, and the formatting, escaping and spacing are handled for you. Every plan starts with a free trial; see the pricing page.

Prefer to build your own? The same pipeline works for other channels: see our guides to the Discord webhook in Node.js, the Microsoft Teams webhook in Node.js and Telegram bot notifications. For an App Store signal worth alerting on, our guide to the App Store download count shows how to record any app's rating count and chart position each day.

Frequently asked questions

How do I send a message to a Slack webhook in Node.js?

POST JSON to the webhook URL with fetch: set Content-Type: application/json and send at least {"text": "..."}. Node.js 18 and newer need no packages. A successful post returns HTTP 200 with the body ok.

What is the Slack webhook rate limit?

Slack lists incoming webhooks at one message per second, with short bursts allowed, and limits apps to one message per second per channel overall. Above that you get HTTP 429 with a Retry-After header in seconds.

Why does my Slack webhook return invalid_payload?

Slack returns it when the request is malformed or the text is not escaped correctly. Build the body with JSON.stringify, escape &, < and > in user text, and do not retry until the payload is fixed.

Can one Slack webhook post to several channels?

No. An incoming webhook is bound to the channel chosen when it was created, and the payload cannot override it. Create one webhook per channel, or use chat.postMessage with a bot token if you need to pick the channel at send time.

How do I mention someone or a channel from a webhook?

Use Slack's syntax in the text: <@U012AB3CD> for a user ID and <!here> or <!channel> for the channel. Use them sparingly and only in text you wrote, and escape everything that comes from users so they cannot add mentions of their own.

Can a Slack webhook reply in a thread?

Yes, if you pass the parent message's ts as thread_ts. Webhooks do not return the ts of messages they post, though, so for threaded alerts chat.postMessage is usually simpler.

Summary

A Slack webhook in Node.js is a single fetch call to a secret URL, and that is the easy part. Make the alerts dependable by always sending a top-level text for notifications, formatting with Block Kit inside its limits, escaping every piece of user text, spacing posts to about one per second, retrying only 429s and server errors with Retry-After, and treating invalid_payload, no_service and the other documented errors as signals for a person, not for a retry loop.

Image credits

Put every store in one dashboard tonight.

Sign in with Google, paste one store link, and your unified view is live in under a minute. Then 7 days of Pro free, no credit card required, and you never touch your app's code.

Start free trial