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 11 of 14 · ~3 min

REST API Design in Express

Resource-oriented URLs

The internal support API names resources — nouns — and leaves the verb entirely to the HTTP method sitting next to it:

GET    /internal/orders            → list orders
GET    /internal/orders/:orderId   → get one order
PATCH  /internal/orders/:orderId   → correct a support-flagged field
GET    /internal/orders/:orderId/events → this order's raw webhook history (nested resource)

Something like GET /internal/orders/replay/ord_4471 smuggles a second verb into a path whose method has already claimed that job — and it's not merely inelegant, it actively undermines caching, idempotency assumptions, and every piece of tooling that trusts a GET to behave like one.

Splitting routes with express.Router()

Once billhook has webhook ingestion, order lookups, and event history all living in one server.js, that file stops being pleasant to work in. express.Router() is a self-contained, mountable mini-application — its own routes and middleware, attached to the main app at a path prefix:

// routes/orders.js
const router = require("express").Router();

router.get("/", listOrders);
router.get("/:orderId", getOrder);
router.get("/:orderId/events", getOrderEvents);

module.exports = router;

// server.js
app.use("/internal/orders", require("./routes/orders"));

Every path inside orders.js is written relative to wherever it gets mounted — router.get("/:orderId", ...) becomes /internal/orders/:orderId purely because of the app.use line, with nothing inside the router file needing to know or care about that prefix. This is the ordinary way any Express app past a handful of routes stays organized, and it costs nothing beyond one require per resource.

Status codes that actually get exercised

CodeMeaningbillhook's use of it
200 OKSuccess, with a bodyA successful GET, or a webhook Meridian has already delivered and billhook is choosing to acknowledge again rather than reprocess
201 CreatedA new resource now existsNot really applicable here — billhook never lets an external caller create an order directly
204 No ContentSuccess, deliberately emptyA successful PATCH where there's nothing meaningful to hand back
400 Bad RequestThe request is malformed or fails validationA webhook body that doesn't parse, or is missing a required field
401 UnauthorizedNo valid identity presented at allA support-API request with no session token — this is about authentication, despite the name suggesting otherwise
403 ForbiddenWho they are isn't in question — what they're trying to do isA support agent without the "billing" role hitting a billing-only route
404 Not FoundNothing exists at this URLGET /internal/orders/ord_9999 for an order that was never created
409 ConflictThe request conflicts with current stateA PATCH that assumes a status the order has already moved past
500 Internal Server ErrorSomething broke on billhook's end, not the caller'sA TypeError nobody caught, Postgres refusing new connections

Confuse these two and you're not alone — it's the single most common mix-up in this table. A 401 is Node/Express-speak for "you haven't convinced me you're anyone at all" — absent, malformed, or expired credentials. A 403 says the opposite: identity's confirmed, billhook knows exactly which support agent this is, and the answer to what they're trying to do is still no.

Validating a webhook before it touches business logic

Check shape and types at the boundary, so nothing downstream has to defensively re-check what it's already trusting:

app.post("/webhooks/meridian", verifyMeridianSignature, (req, res, next) => {
  const payload = JSON.parse(req.body);

  if (typeof payload.orderId !== "string" || payload.orderId.length === 0) {
    return res.status(400).json({ error: "orderId is required and must be a string" });
  }
  if (typeof payload.amountCents !== "number" || payload.amountCents <= 0) {
    return res.status(400).json({ error: "amountCents must be a positive number" });
  }

  saveOrder(payload).then((order) => res.status(200).json({ received: true })).catch(next);
});

Hand-writing checks like this is fine for a handful of fields; past that, billhook would lean on a schema-validation library instead — Zod and Joi are the usual picks — so the shape of a valid webhook lives in one declarative place, instead of scattered if statements that quietly drift out of sync with what Meridian's payloads actually look like.