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
| Code | Meaning | billhook's use of it |
|---|---|---|
200 OK | Success, with a body | A successful GET, or a webhook Meridian has already delivered and billhook is choosing to acknowledge again rather than reprocess |
201 Created | A new resource now exists | Not really applicable here — billhook never lets an external caller create an order directly |
204 No Content | Success, deliberately empty | A successful PATCH where there's nothing meaningful to hand back |
400 Bad Request | The request is malformed or fails validation | A webhook body that doesn't parse, or is missing a required field |
401 Unauthorized | No valid identity presented at all | A support-API request with no session token — this is about authentication, despite the name suggesting otherwise |
403 Forbidden | Who they are isn't in question — what they're trying to do is | A support agent without the "billing" role hitting a billing-only route |
404 Not Found | Nothing exists at this URL | GET /internal/orders/ord_9999 for an order that was never created |
409 Conflict | The request conflicts with current state | A PATCH that assumes a status the order has already moved past |
500 Internal Server Error | Something broke on billhook's end, not the caller's | A 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.