How to Receive Slack Webhooks in Express

Raw-body middleware ordering for HMAC-friendly handlers

Express is still the default Node.js HTTP framework and powers an enormous amount of production webhook infrastructure. Its body-parsing middleware order is the single most common cause of 'signature mismatch' tickets — fix the ordering once and verification 'just works' across every provider. This guide walks through the Express setup for Slack webhooks end to end: capturing the raw body, verifying the signature, handling retries idempotently, and iterating locally without redeploying. Cross-reference the Slack Webhooks overview for the event catalog and sample payload.

Slack Official Webhook Docs

1. Set Up the Express Endpoint

The endpoint needs to do three things, in this order: read the raw body, verify the signature against those exact bytes, and only then parse the JSON for your business logic.

// server.ts
import express from "express";

const app = express();

// 1. Mount raw-body parser on the webhook route ONLY, BEFORE express.json().
//    The "verify" callback hands you the unparsed Buffer to keep alongside req.body.
app.post(
  "/api/webhooks/:service",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body as Buffer; // Buffer, not parsed JSON
    const signature = req.header("x-signature-header") ?? "";

    // 1. Verify HMAC over rawBody.toString("utf8")
    // 2. Parse JSON only after verification passes
    // 3. Process the event idempotently (use the event id as your key)

    const event = JSON.parse(rawBody.toString("utf8"));
    console.log("Verified webhook:", event.type ?? event);

    res.status(200).send("ok");
  },
);

// 2. JSON middleware comes AFTER the webhook route, so it doesn't consume
//    your webhook bodies before your handler sees them.
app.use(express.json());

app.listen(3000);
Raw body, every time
If you mount express.json() before your webhook route — or globally — every body becomes a parsed object and the original bytes are gone. The fix is to either put express.raw() on the webhook route ahead of express.json(), or to use express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } }) globally so you keep both representations. Pick one pattern and stick to it.

2. Verify the Slack Signature

Signing details
Algorithm
HMAC-SHA256
Header
X-Slack-Signature
Encoding
hex
Prefix
v0=

Slack signs the string `v0:{X-Slack-Request-Timestamp}:{raw_request_body}`. You must read both the timestamp and signature headers.

Node.js verification

import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post(
  '/webhooks/slack',
  express.raw({ type: 'application/x-www-form-urlencoded' }),
  (req, res) => {
    const timestamp = req.headers['x-slack-request-timestamp'] as string;
    const signature = req.headers['x-slack-signature'] as string;

    // Reject requests older than 5 minutes (replay protection).
    const fiveMinutes = 60 * 5;
    if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10)) > fiveMinutes) {
      return res.status(401).send('stale request');
    }

    const baseString = `v0:${timestamp}:${req.body.toString('utf8')}`;
    const expected =
      'v0=' +
      crypto
        .createHmac('sha256', process.env.SLACK_SIGNING_SECRET!)
        .update(baseString)
        .digest('hex');

    if (
      signature.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
    ) {
      return res.status(401).send('invalid signature');
    }

    res.json({ ok: true });
  },
);

Wire this verification call into the Express handler from section 1. The pattern is identical across Express versions: read raw body, verify, parse JSON, dispatch.

Watch out: The 5-minute timestamp window is critical — without it, an attacker who replays a captured request can pass signature verification indefinitely. Slack's docs say to reject anything outside ±5 minutes.

See Slack's official signing docs for the canonical reference, or the cross-service signature verification guide for the same pattern in Ruby and other languages.

3. Make the Handler Idempotent

Slack can — and will — send the same event twice. Network blips, your server returning a 5xx mid-processing, deploy windows: any of these triggers a retry, and your handler will see the same event id again. Build for that on day one rather than chasing duplicate-charge bugs in production.

The simplest pattern is a unique constraint on the event id in your database. The handler does the work inside a transaction, and the insert into the events table is the last step — if a retry arrives, the unique-constraint violation tells you the event already committed and you can return 200 without re-running the side effects.

Pattern in any framework:

  1. Read raw body, verify signature.
  2. Begin transaction.
  3. Apply business logic (charge, fulfil, notify, etc.).
  4. Insert event id into processed_events with a unique constraint.
  5. Commit. Return 200.
  6. On unique-constraint violation, return 200 — the event was already processed by a prior delivery.

4. Slack Retry Behaviour

Retry policy
Max attempts
3
Total window
~36 minutes total
Backoff
1 min, 5 min, 30 min
Retries on
Non-2xx responses, timeouts (3s for slash commands)
Stops on
Any 2xx response within 3s

Combine the retry numbers above with the idempotency pattern in section 3: aim to acknowledge fast (return 200 under the timeout) and let the idempotency table absorb any duplicates from in-flight retries. The full pattern, including dead-letter queues and replay-from-capture, lives in the Webhook Retry Strategies guide.

5. Test Locally Without Deploying

The fastest iteration loop for any webhook handler is: capture a real Slack event with HookRay, then replay that captured request against your local Express server until the verification + business logic both pass. No need to retrigger the event in Slack, no need to redeploy.

  1. Get a free webhook URL at hookray.com — no signup.
  2. Paste the URL into your Slackdashboard's webhook settings.
  3. Trigger a test event. HookRay shows the headers, raw body, and parsed payload in real time.
  4. Use HookRay's replay feature to send the captured request against http://localhost:3000/api/webhooks/slack (or wherever your Express app is listening) — iterate on your code without re-poking the Slack dashboard.

Deploying the Express Handler

Express works equally well on a long-running container (Render, Fly.io, ECS, GCP Cloud Run) or a serverless adapter (Vercel's Express adapter, AWS Lambda + serverless-http). For webhooks specifically, prefer a long-running runtime — cold starts on first request can push past the sender's retry timeout.

Need a host that boots quickly enough to absorb webhook bursts? DigitalOcean droplets stay warm, support raw-body proxies cleanly, and avoid the cold-start traps of some serverless runtimes.

Capture a real Slack webhook in 30 seconds

Free webhook URL, real-time payload inspection, one-click replay. No signup required.

Start Testing — Free

Same Slack Webhook in Other Frameworks