- 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

On this page
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
textalongsideblocks. Slack uses it for notifications, and mobile notifications "exclusively usemessage.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-Afterheader.- A successful post returns HTTP 200 with the plain text body
ok. Errors are plain text too, such asinvalid_payloadorno_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:
- Go to your Slack apps page and create a new app from scratch (or open an existing one) for your workspace.
- Open Incoming Webhooks in the app settings and switch on Activate Incoming Webhooks.
- Click Add New Webhook to Workspace, pick the channel and click Authorize.
- 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 & 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 &, < and > when you mean them literally, and not to encode the rest of the text:
export function escapeSlack(text) {
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
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 <!channel> launch & 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 withblocks. - 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_serviceand 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
- Cover photo: Vintage doorbells on a textured wall by Nilo Velez on the WordPress Photo Directory, CC0.


