← Webhook localhost by framework
Express webhook localhost
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:
node --watch server.jsnode --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.
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
- HTTP Endpoint, Local port
{findFrameworkArticle("express").localPort}. mercur author the macOS app.- Start the agent:
mercur start ep-webhooks- GitHub Settings → Webhooks payload URL
https://ep-webhooks.mercur.sh/webhooks/github, content typeapplication/json, secret matchingGITHUB_WEBHOOK_SECRET. GitHub sendspingimmediately.
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: pingX-GitHub-Delivery: <uuid>X-Hub-Signature-256: sha256=…when a secret is setHost: ep-webhooks.mercur.sh(public host, forwarded as-is)Content-Type: application/json- JSON with
zenandhook_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
- Test GitHub webhooks locally — payload URL,
ping, Recent Deliveries - Create a Telegram bot with webhooks — Express-style Node server; secret token is not HMAC
- Test Stripe webhooks locally — if you add Stripe, use
express.rawon/webhooks/stripetoo;constructEventcannot usereq.bodyafterexpress.json()
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/githubbut the route is{findFrameworkArticle("express").webhookPath}. - 502 —
node --watchcrashed on syntax error;curl -i http://127.0.0.1:3000/webhooks/githubfirst. - 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
- Next.js webhook localhost
- FastAPI webhook localhost
- Django webhook localhost
- Webhook signature verification failed
- Webhook 400
- Webhook 404
- Webhook 502 localhost
- Test GitHub webhooks locally
- Create a Telegram bot with webhooks
- How to test webhooks
- Expose a local service
- CLI agent: forward localhost with npm
- Replay a stored request
- Online webhook inspector