Skip to main content
CodeOath
← All posts

Node.js95 min total · 14 parts

Node.js Fundamentals: The Runtime, the Event Loop, and Building Real APIs

Part 6 of 14 · ~5 min

Express Fundamentals

Routing and route parameters

const express = require("express");
const app = express();

app.get("/internal/orders/:orderId", (req, res) => {
  const { orderId } = req.params; // path segment, extracted automatically
  res.json({ orderId });
});

app.get("/internal/orders", (req, res) => {
  const { status } = req.query; // query string: /internal/orders?status=captured
  res.json({ filteringBy: status ?? "all" });
});

Both req.params and req.query come pre-parsed — named segments in one, the query string broken into key/value pairs in the other — sparing billhook from doing the same manual slicing of req.url the raw-http version above was stuck with.

The middleware pattern

An Express middleware function always takes the same three arguments: (req, res, next). Calling next() is what passes control to whatever comes after it — omit that call, and nothing further down the stack ever executes; somebody in the chain still needs to actually respond, or the request just sits there forever, with no error anywhere to explain why.

app.use((req, res, next) => {
  console.log(`${req.method} ${req.path}`);
  next(); // skip this and nothing below ever runs
});

app.use(express.json()); // parses JSON bodies into req.body, for every route below this line

app.get("/internal/health", (req, res) => res.json({ ok: true }));

Registration order is execution order — express.json() only touches routes declared after it, which is why logging and body-parsing middleware conventionally sit at the very top, above every route.

The gotcha Ines actually hit: a body can only be read once

app.use(express.json()) above is exactly what the internal support API wants — parse the body, hand handlers a req.body object, move on. It is exactly the wrong thing for POST /webhooks/meridian, and the reason isn't obvious until you've watched it fail: Meridian signs each webhook by computing an HMAC over the exact raw bytes of the request body and sending it as a header. Verifying that signature means hashing those same exact bytes on billhook's side and comparing. But express.json(), sitting in front of every route, has already read the request's stream to completion and handed the handler a parsed JavaScript object — the raw bytes it read are gone. A request stream can only be drained once; nothing downstream can ask for the bytes a second time, parsed or not, because there's no "rewind" on a stream that already emitted its 'end' event.

const crypto = require("crypto");

app.use(express.json()); // global — parses EVERY route's body, webhook included

app.post("/webhooks/meridian", (req, res) => {
  // req.body is already a parsed object here — the raw bytes express.json()
  // read to produce it are gone, and there is no way to get them back
  const expected = crypto.createHmac("sha256", process.env.MERIDIAN_WEBHOOK_SECRET)
    .update(JSON.stringify(req.body)) // NOT the same bytes Meridian actually signed
    .digest("hex");
  // re-serializing req.body can reorder keys or format numbers differently than
  // Meridian's original JSON did, so this "works" until one payload disagrees
});

The fix is scoping the raw-body middleware to exactly the one route that needs it, and reaching for express.json() everywhere else:

app.post(
  "/webhooks/meridian",
  express.raw({ type: "application/json" }), // req.body is a Buffer of the RAW bytes here
  (req, res, next) => {
    const signature = req.get("X-Meridian-Signature");
    const expected = crypto.createHmac("sha256", process.env.MERIDIAN_WEBHOOK_SECRET)
      .update(req.body) // the actual bytes Meridian hashed to produce the signature
      .digest("hex");

    const isValid = signature?.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
    if (!isValid) return res.status(400).json({ error: "invalid signature" });

    const payload = JSON.parse(req.body); // parse only after the signature checks out
    next();
  },
  handleMeridianWebhook
);

app.use(express.json()); // fine for every OTHER route, which never needs the raw bytes

express.raw({ type: "application/json" }) scoped to just this one route means the webhook path gets a Buffer instead of a parsed object, verifies against it, and only then parses it — while every other route further down keeps using ordinary express.json() without any of this ceremony. (crypto.timingSafeEqual instead of === matters too: a plain string comparison on a secret-derived value can leak timing information about how many leading bytes matched, which is a real, if narrow, attack surface for signature checks like this one.)

Error-handling middleware: the four-argument signature

How Express tells an error handler apart from a normal middleware function has nothing to do with naming or where it sits — it's arity, plain and simple. Declare a middleware function that accepts exactly four parameters (err, req, res, next), and Express registers it as an error handler; it only ever gets invoked once something upstream passes a value into next(err), or a synchronous handler throws outright.

app.get("/internal/orders/:orderId", (req, res, next) => {
  fetchOrder(req.params.orderId)
    .then((order) => {
      if (!order) {
        const err = new Error("Order not found");
        err.status = 404;
        return next(err);
      }
      res.json(order);
    })
    .catch(next);
});

// Must be registered LAST — Express matches error handlers by arity, and
// anything registered after this one is unreachable on an error path anyway
app.use((err, req, res, next) => {
  console.error(err);
  res.status(err.status || 500).json({ error: err.message || "Internal server error" });
});

The ordering isn't a convention worth following out of politeness — it's structurally required. Express reads the middleware stack from top to bottom, and the moment next(err) gets called anywhere in it, ordinary middleware stops being eligible; Express keeps scanning forward, specifically hunting for the next four-argument function. Put billhook's error handler above even one of its routes, and any error thrown in that route has nothing earlier in the stack left to catch it — it lands on Express's own generic handler, a raw stack trace, instead of the clean JSON response this one produces.