← Webhook localhost by framework

Express webhook localhost

Express webhooks fail HMAC when a global express.json() runs first. Mount express.raw({ type: "application/json" }) on the webhook path so req.body stays the Buffer GitHub signed.

Local run command

Express has no framework-wide default port. The generator and most samples use process.env.PORT || 3000. Mercur’s agent always dials 127.0.0.1, not ::1, so bind IPv4 in listen:

Run
node --watch server.js

node --watch restarts on save (Node 18+). Set PORT=3000 if you already use that env in production. Endpoint Local port must be 3000. app.listen(3000) without a host can land on IPv6-only in some Node builds and then Mercur returns 502.

Webhook route

Mount GitHub on {findFrameworkArticle("express").webhookPath} with express.raw on that path only. A global app.use(express.json()) above it means req.body is already an object and X-Hub-Signature-256 will never match.

GitHub payload URL: https://ep-webhooks.mercur.sh/webhooks/github.

server.js
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();
const PORT = Number(process.env.PORT) || 3000;

function githubSignature256(secret, rawBody) {
  const digest = createHmac("sha256", secret).update(rawBody).digest("hex");
  return `sha256=${digest}`;
}

function signaturesMatch(expected, actual) {
  const left = Buffer.from(expected);
  const right = Buffer.from(actual);
  if (left.length !== right.length) return false;
  return timingSafeEqual(left, right);
}

// Do not app.use(express.json()) before this route. JSON parsing changes the bytes GitHub signed.
app.post("/webhooks/github", express.raw({ type: "application/json" }), (req, res) => {
  const secret = process.env.GITHUB_WEBHOOK_SECRET;
  if (!secret) {
    res.status(500).send("GITHUB_WEBHOOK_SECRET is not set");
    return;
  }
  const rawBody = req.body; // Buffer
  const actual = req.headers["x-hub-signature-256"] ?? "";
  const expected = githubSignature256(secret, rawBody);
  if (!signaturesMatch(expected, actual)) {
    res.status(401).send("invalid signature");
    return;
  }
  const event = JSON.parse(rawBody.toString("utf8"));
  res.status(202).json({ ok: true, event: req.headers["x-github-event"], delivery: req.headers["x-github-delivery"] });
  void event;
});

app.listen(PORT, "127.0.0.1", () => {
  console.log(`listening on 127.0.0.1:${PORT}`);
});

Telegram’s sample in Mercur’s blog uses a different path (/telegram/webhook) and a header equality check, not HMAC. Do not reuse githubSignature256 for Telegram. See Create a Telegram bot with webhooks.

Raw body and signatures

GitHub documents X-Hub-Signature-256 as sha256= plus hex HMAC-SHA256 of the raw body. The header is absent if you never set a secret in the repo webhook. X-Hub-Signature is the legacy SHA-1 header — do not mix algorithms.

express.raw({ type: "application/json" }) leaves req.body as a Buffer. Hash that Buffer, then JSON.parse. The verify option on express.json() can stash req.rawBody if you refuse to split the middleware; the snippet above is the smaller footgun.

Compare with timingSafeEqual after checking Buffer lengths. Details: webhook signature verification failed.

Invalid JSON after a valid HMAC still becomes a 400 from JSON.parse unless you catch it — webhook 400.

Connect localhost

  1. HTTP Endpoint, Local port {findFrameworkArticle("express").localPort}.
  2. mercur auth or the macOS app.
  3. Start the agent:
Run
mercur start ep-webhooks
  1. GitHub Settings → Webhooks payload URL https://ep-webhooks.mercur.sh/webhooks/github, content type application/json, secret matching GITHUB_WEBHOOK_SECRET. GitHub sends ping immediately.

Forwarding needs a connected agent — the macOS app or the npm package @mercur_dev/cli.

Expected incoming request

A GitHub ping (created when you save the webhook) should be:

  • POST {findFrameworkArticle("express").webhookPath}
  • X-GitHub-Event: ping
  • X-GitHub-Delivery: <uuid>
  • X-Hub-Signature-256: sha256=… when a secret is set
  • Host: ep-webhooks.mercur.sh (public host, forwarded as-is)
  • Content-Type: application/json
  • JSON with zen and hook_id

Express does not consult ALLOWED_HOSTS. The Host header is still the Mercur hostname if you log req.headers.host.

Guest inspector returns 204 and never runs server.js. Use it to see X-GitHub-Event first if you are not sure GitHub is posting.

Inspect and replay

Compare GitHub Recent Deliveries with Mercur history: same delivery id, same X-GitHub-Event. Bodies larger than 256 KB are omitted and cannot be replayed.

After you move express.json() off this path, Retry the stored ping or push. Needs proxy mode and a connected agent. Guest inspector has no Retry.

Inspect the request Connect localhost

Provider examples

Framework troubleshooting

  • HMAC fails, GitHub 200 on a different URL — this process parsed JSON globally. Comment out app.use(express.json()) or move it below the webhook route.
  • 404 — payload URL is / or /github but the route is {findFrameworkArticle("express").webhookPath}.
  • 502node --watch crashed on syntax error; curl -i http://127.0.0.1:3000/webhooks/github first.
  • EADDRINUSE — another Node process on 3000. Mercur still points at the dead port until you change Local port.
  • Telegram 400 — CSRF or cookie middleware on all POSTs. GitHub and Telegram are not browsers.

Related