To send form submissions to Slack, post the form to a server you control, then have that server send JSON to a Slack incoming webhook. Never put the webhook URL in browser code. Use a text fallback plus Block Kit blocks, validate the form first, and retry Slack 429 responses only after Retry-After.
I build Form Plume, so take this recommendation with that bias in mind. I prefer the direct Node route here because it exposes the security boundary and the failure handling. If you would rather not own that relay, Form Plume has a native Slack connection on Pro. The Form Plume integrations overview is the live commercial page until a dedicated /integrations/slack route ships. If you prefer a generic route today, Free includes one signed webhook.
The browser should never call Slack directly
A Slack incoming-webhook URL contains a secret. Slack says not to publish it and actively revokes leaked webhook secrets. If you put that URL in HTML or a JavaScript bundle, anyone who opens DevTools can copy it and post into your channel.
Use this flow instead:
browser form
|
v
your POST /contact route
|-- validate fields
|-- apply spam and rate controls
|-- store or queue the submission
v
Slack incoming webhookThe Slack call belongs after server-side validation. Browser validation is useful for feedback, but MDN warns that client validation can be bypassed, so the receiver must validate every request again. Bound input sizes, reject malformed requests, and decide whether the message is legitimate before notifying the channel.
Create the Slack webhook and store it as a secret
In Slack, create an app, enable Incoming Webhooks, choose Add New Webhook to Workspace, and select the destination channel. Slack gives you a URL shaped like this:
https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXXDo not commit the real value. Put it in an environment variable:
export SLACK_WEBHOOK_URL='https://hooks.slack.com/services/REPLACE/ME'A webhook is tied to the channel selected during authorization. If you need separate sales and support channels, use separate connections rather than asking the browser to choose an arbitrary webhook URL.
Use a server relay with a Block Kit payload
Start with the browser side. Save this as index.html:
<form action="http://localhost:3000/contact" method="POST">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required>
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<button type="submit">Send</button>
</form>Under the browser's simple-request rules, a native HTML form can submit this URL-encoded request cross-origin without giving browser JavaScript access to the response. If you replace native submission with fetch() from another origin, configure CORS deliberately on the relay before reading the response.
This Node 22 example uses only built-in APIs. It accepts URL-encoded form submissions at /contact, applies basic field limits, and sends a Block Kit message to Slack. It is a runnable demonstration, not a complete production form backend: add durable storage, authentication where appropriate, abuse controls, and structured logging for your application.
// server.mjs
import { createServer } from "node:http";
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
const port = Number(process.env.PORT ?? 3000);
const maxBodyBytes = 64 * 1024;
if (!webhookUrl?.startsWith("https://hooks.slack.com/")) {
throw new Error("Set SLACK_WEBHOOK_URL to a Slack incoming webhook URL");
}
function field(form, name, maxLength) {
const value = (form.get(name) ?? "").trim();
if (!value || value.length > maxLength) return null;
return value;
}
function validEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
function slackPayload({ name, email, message }) {
const fallback =
`New form submission\nName: ${name}\nEmail: ${email}\nMessage: ${message}`;
return {
text: fallback,
mrkdwn: false,
blocks: [
{
type: "header",
text: { type: "plain_text", text: "New form submission" },
},
{
type: "section",
fields: [
{ type: "plain_text", text: `Name\n${name}` },
{ type: "plain_text", text: `Email\n${email}` },
],
},
{
type: "section",
text: { type: "plain_text", text: `Message\n${message}` },
},
],
};
}
const wait = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
async function postToSlack(payload) {
for (let attempt = 1; attempt <= 3; attempt += 1) {
let response;
try {
response = await fetch(webhookUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10_000),
});
} catch (error) {
if (attempt === 3) throw error;
await wait(250 * 2 ** (attempt - 1));
continue;
}
const detail = (await response.text()).slice(0, 500);
if (response.ok) return;
if (response.status === 429 && attempt < 3) {
const seconds = Number(response.headers.get("retry-after") ?? "1");
await wait(seconds * 1000);
continue;
}
if (response.status >= 500 && attempt < 3) {
await wait(250 * 2 ** (attempt - 1));
continue;
}
throw new Error(`Slack returned ${response.status}: ${detail}`);
}
}
createServer(async (request, response) => {
try {
if (request.method !== "POST" || request.url !== "/contact") {
response.writeHead(404).end();
return;
}
const contentType = request.headers["content-type"] ?? "";
if (!contentType.startsWith("application/x-www-form-urlencoded")) {
response.writeHead(415).end("Use application/x-www-form-urlencoded");
return;
}
const chunks = [];
let size = 0;
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
size += chunk.length;
if (size > maxBodyBytes) {
response.writeHead(413, { connection: "close" }).end("Form body is too large");
request.resume();
return;
}
chunks.push(chunk);
}
const form = new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
const submission = {
name: field(form, "name", 100),
email: field(form, "email", 320),
message: field(form, "message", 2500),
};
if (!submission.name || !submission.email || !submission.message ||
!validEmail(submission.email)) {
response.writeHead(422).end("Provide a valid name, email, and message");
return;
}
await postToSlack(slackPayload(submission));
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true }));
} catch (error) {
console.error(error);
response.writeHead(502).end("Could not deliver the Slack notification");
}
}).listen(port, () => console.log(`Listening on http://localhost:${port}`));Run the server and submit a sample request from another terminal:
node server.mjs
curl -i http://localhost:3000/contact \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'name=Ada Lovelace' \
--data-urlencode 'email=ada@example.com' \
--data-urlencode 'message=Please send the project details.'A successful incoming webhook normally returns HTTP 200 with plain-text ok. Slack supports both top-level text and Block Kit blocks. Keep text as a useful fallback for notifications and clients that do not render the blocks.
Use plain text for untrusted form values
The sample deliberately uses Block Kit plain_text objects for names, email addresses, and messages. Public submissions should not control Slack formatting, mentions, or links that look like trusted system instructions.
Keep the top-level text because Slack uses it as a notification fallback and as the default content for screen readers when blocks are present. Slack's mrkdwn: false setting disables markup in the top-level fallback. The payload also omits link_names, which disables automatic top-level mention parsing by default, although regular URLs may still become links.
I keep mrkdwn for static labels or carefully parsed values. If you use it for form values, escape Slack's three control characters &, <, and >, but remember that this does not disable all styling or URL auto-linking. Use plain_text when submissions must not control formatting.
Keep the Slack message short enough to scan. A channel notification should carry the fields someone needs to act, not every hidden field and metadata value. Link back to your source-of-truth dashboard for the complete submission and attachments.
Respect Slack rate limits and Retry-After
Slack documents incoming webhooks at roughly one message per second and allows short bursts without guaranteeing every message will display. When a limit is exceeded, Slack returns 429 Too Many Requests with Retry-After in seconds.
The example retries network failures and 5xx responses with short exponential backoff. For 429, it waits for Slack's Retry-After delay. Every retry path is capped at three attempts.
These retries are at-least-once. If Slack accepts a message but the response is lost, a network retry can post the same notification twice. Include a submission ID in production and make the channel workflow tolerate an occasional duplicate.
The capped retry loop is fine for a small example. I would not keep an HTTP request open like this in production, because Slack's delay becomes your visitor's delay.
Queue the Slack delivery after accepting the form. Persist the submission, enqueue a job, return success to the visitor, and let a worker honor rate limits. This keeps a Slack slowdown from turning into a slow or failed contact form.
Do not retry every 4xx. Slack documents errors such as invalid_payload, channel_is_archived, and no_active_hooks; those need a payload or configuration fix. Network errors, 429, and many 5xx responses are the retryable class.
Keep spam out of the channel
A Slack webhook faithfully posts whatever your receiver sends. It does not know whether a message is a real lead, a backlink pitch, or a bot filling every input.
Run the contact form spam layers before the Slack job. At minimum, validate on the server, add a honeypot field, and rate-limit the endpoint. Quarantine uncertain submissions instead of forwarding them immediately, or the channel becomes a second spam inbox.
This ordering also prevents a filled honeypot from consuming Slack's rate allowance. The bot receives your normal decoy response, while no downstream notification is queued.
Form Plume uses Slack OAuth instead of exposing the webhook
Form Plume's Slack integration reference connects a workspace and channel through OAuth. The incoming-webhook credential is stored encrypted on the backend, so it never reaches the form page. You can choose the message template, send a test, inspect delivery attempts, and retry failed deliveries from the integration log. The default template shortens long field values and places submitted values in Block Kit plain_text objects, so the Form Plume submission remains the complete record.
As of August 2026, Slack is an external app integration available on Pro. Free includes one signed webhook, but not the native Slack connector. Accepted submissions are stored first, then queued for Slack delivery, so a temporary provider failure does not ask the visitor to submit again.
There is one retry detail I would not hide: Form Plume currently uses its own exponential backoff with full jitter for integration jobs and does not yet schedule directly from Slack's Retry-After header. It records Slack's status and response excerpt, then retries every non-2xx response using exponential backoff with full jitter, up to eight total attempts. It currently does not classify Slack 4xx responses as permanent, so errors such as invalid_payload may consume all eight attempts. Slack delivery is at-least-once. If Slack accepts a message but the response is lost, a retry can post the same submission again. The integration overview explains the managed flow, while the delivery log shows what happened to an individual message.
Before switching the integration on:
- Send a real form submission and confirm the correct channel, labels, links, and line breaks.
- Submit obvious spam and confirm it never reaches Slack.
- Revoke or rotate a test webhook and verify the failure appears in delivery logs.
- Trigger a small burst in a test channel, inspect the attempts, and confirm your channel workflow tolerates an occasional duplicate.
