Slack Events API with a Hooklistener CLI Tunnel

Last updated December 27, 20254 min read
TL;DR
  1. Build a local endpoint for Slack events at POST /slack/events.
  2. Authenticate the CLI: hooklistener login.
  3. Start a tunnel to your local port: hooklistener tunnel --port 3000.
  4. Paste {tunnel_url}/slack/events into Slack Event Subscriptions and complete the challenge.
  5. Verify signatures for real events using your Signing Secret.

Slack’s Events API requires a public HTTPS Request URL and performs a URL verification handshake before it will save your Event Subscriptions settings.

Hooklistener’s CLI tunnel gives you that HTTPS URL while still delivering requests to your local server (e.g. localhost:3000) so you can iterate quickly without deploying.

By integrating Hooklistener with Slack, you can:

  • Develop on localhost — point Slack at a temporary HTTPS endpoint while your code runs locally.
  • Validate URL verification fast — respond to Slack’s url_verification challenge without staging infrastructure.
  • Debug delivery behavior — see request/response status and tune timeouts/retries during development.

Info:Slack “Incoming Webhooks” are URLs Slack gives you to POST messages into Slack. This guide is for the Events API Request URL, where Slack POSTs events to you.

1. Start your local Slack endpoint

Your goal is to run a local HTTP server that can receive Slack’s POSTs at POST /slack/events and respond quickly (especially during verification). Slack treats responses taking longer than ~3 seconds as failures.

  • Create a small server listening on a port (example: 3000).
  • Add a POST /slack/events route.
  • Log the inbound body so you can see what Slack is sending.

Example (Node + Express) that handles URL verification and logs event callbacks:

import crypto from "crypto";
import express from "express";

const app = express();

// IMPORTANT: capture raw body for signature verification (Slack requires raw payload)
app.use("/slack/events", express.raw({ type: "*/*" }));

function verifySlackRequest(req) {
  const signingSecret = process.env.SLACK_SIGNING_SECRET;
  if (!signingSecret) return false;

  const timestamp = req.header("X-Slack-Request-Timestamp");
  const signature = req.header("X-Slack-Signature");
  if (!timestamp || !signature) return false;

  // Replay protection (5 minutes)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > 60 * 5) return false;

  const rawBody = req.body.toString("utf8");
  const baseString = `v0:${timestamp}:${rawBody}`;
  const mySig =
    "v0=" +
    crypto.createHmac("sha256", signingSecret).update(baseString, "utf8").digest("hex");

  // constant-time compare
  return crypto.timingSafeEqual(Buffer.from(mySig), Buffer.from(signature));
}

app.post("/slack/events", (req, res) => {
  const rawBody = req.body.toString("utf8");
  const payload = JSON.parse(rawBody);

  // URL verification handshake
  if (payload.type === "url_verification") {
    return res.status(200).json({ challenge: payload.challenge });
  }

  // For real events, verify signature
  if (!verifySlackRequest(req)) {
    return res.status(401).send("invalid signature");
  }

  // Acknowledge quickly, process async if needed
  console.log("Slack event:", payload);
  return res.status(200).send("ok");
});

app.listen(3000, () => console.log("Listening on http://localhost:3000"));

You can validate by sending a local request:

curl -i http://localhost:3000/slack/events   -H "content-type: application/json"   -d '{"type":"url_verification","challenge":"test"}'

You should see a 200 with {"challenge":"test"}.

2. Authenticate the Hooklistener CLI

Your goal is to authenticate the CLI so it can create a tunnel for your account. Hooklistener uses a device-code style login.

Run:

hooklistener login

Follow the printed instructions to authorize in the browser (typically at https://hooklistener.com/cli).

3. Start a tunnel to localhost

Your goal is to expose your local server (localhost:3000) as a public HTTPS URL that Slack can reach.

Start your local server (from section 1).

In a second terminal, run:

hooklistener tunnel --port 3000

Copy the public HTTPS URL the CLI prints (example pattern: https://{subdomain}.hook.events).

Tip: Slack will hit whatever path you configure. If your local route is /slack/events, your Slack Request URL should be {tunnel_url}/slack/events.

4. Configure Slack Event Subscriptions

Your goal is to set the Events API Request URL to your tunnel URL and pass Slack’s verification handshake.

  1. Open your Slack app configuration at api.slack.com/apps.
  2. Select your app.
  3. Go to Event Subscriptions.
  4. Toggle Enable Events to On.
  5. In Request URL, paste: {tunnel_url}/slack/events

Slack will immediately POST a JSON payload with type: "url_verification" and a challenge.

You can validate by watching your server logs and confirming Slack shows a green check after your app returns HTTP 200 with the challenge.

Info:Slack verifies your HTTPS endpoint and will fail verification if it can’t reach your URL, can’t validate SSL, gets redirected too many times, or doesn’t receive a timely 2xx response.

5. Handle real events (after verification)

Your goal is to accept event_callback payloads and ACK fast.

  • Subscribe to at least one event under Subscribe to bot events (for example, message-related events).
  • Install the app to a workspace if Slack prompts you.

Ensure your handler:

  • Responds with a 2xx quickly (within a few seconds).
  • Does longer work asynchronously (queue, background job, etc.).

Watch for retry headers if you intentionally return non-2xx while debugging:

  • x-slack-retry-num
  • x-slack-retry-reason

6. Secure your endpoint (signatures and replays)

Slack recommends verifying requests using your app’s Signing Secret and the X-Slack-Signature + X-Slack-Request-Timestamp headers.

  1. In Slack app settings, copy your Signing Secret from Basic Information.
  2. Set it in your environment:
    export SLACK_SIGNING_SECRET="{your signing secret}"
  3. Implement signature verification using the raw request body and HMAC SHA-256, and reject requests older than ~5 minutes (replay protection).

Verify that:

  • url_verification still works (Slack may send it during configuration).
  • Non-Slack requests without valid headers get 401.

Info:Your tunnel URL is publicly reachable. Treat it like a secret webhook endpoint: verify Slack signatures and avoid pasting it in public places.

7. Test and debug locally

Inspecting requests

Use the Hooklistener CLI/TUI request log to confirm Slack is reaching your tunnel and to see status codes and durations.

If Slack says “timeout,” confirm your handler responds quickly (ACK first, process later).

Replaying the same payload

If you want to re-run a received event through your code:

  1. Copy the JSON body from your app logs.
  2. Replay it to your tunnel URL:
curl -i https://{subdomain}.hook.events/slack/events   -H "content-type: application/json"   -d '{...paste the payload...}'

Note: This won’t include Slack’s signature headers unless you generate them, so it’s best used for testing parsing/dispatch logic after you’ve temporarily bypassed auth in a dev-only branch.

8. Troubleshoot verification and delivery

Slack can’t verify the URL

  • Confirm your Request URL is {tunnel_url}/slack/events (paths are forwarded as-is).
  • Confirm your server returns 200 with the challenge for type: "url_verification".
  • Confirm you’re responding fast enough (Slack treats >3s as a failure).

You see retries

Look at x-slack-retry-num / x-slack-retry-reason and fix the underlying status/timeout.

Tunnel shows connection resets / WebSocket protocol errors

The CLI maintains a persistent connection to the edge; network changes (VPN, sleep, flaky Wi-Fi) can interrupt it. Restart the tunnel after stabilizing connectivity.

Once your local flow is solid, you can deploy the same handler behind your production HTTPS endpoint and keep the signature verification exactly the same.