Build an E-Commerce API with Express

Build an Express API with protected orders and event-driven notifications.

Introduction

30 Second Summary

When someone places an online order, the store must decide whether the request is possible. A price or stock mistake can turn one click into a costly problem.

In this project, you will build a local Express REST API for a custom e-commerce store. Node.js events will turn each confirmed order into a separate notification feed.

What You'll Build

From Windows PowerShell, you will submit an order that returns a server-calculated total plus a three-part notification feed.

By the end of this project, you'll have:

  • A product catalog that returns structured JSON for either all products or one requested item.
  • An inventory-safe checkout that calculates each total from catalog prices before changing stock.
  • An event-driven notification feed where you can retrieve order reactions. You can also mark individual notifications as read.
  • Secret Mission: Add validated category and maximum-price filters to the product endpoint with query parameters.

Do I need any experience before starting?

Basic JavaScript knowledge is enough for the coding. Your Windows computer needs permission to install the Node.js and Visual Studio Code tools introduced in Step 1.

Before We Start

Your e-commerce API will receive requests that clients can manipulate. This checkpoint commits you to server-controlled pricing while keeping notification delivery separate from order logic.

Set Up the Express API

Your e-commerce backend needs a dependable local runtime before it can enforce prices or protect inventory. A working health endpoint gives you a fast way to prove that foundation is available.

Your local toolkit uses Node.js with Visual Studio Code. An Express health route then proves the API can answer an HTTP request.

In this step, get ready to:
  • Prepare the required Windows development tools.
  • Initialize the project with Express 5.3.0.
  • Run the local API and verify its health endpoint.
Install Node.js and Visual Studio Code

Node.js runs your JavaScript outside a browser. The project uses version 24.22.1 LTS so your runtime matches every command in the walkthrough.

  • Check the installed Node.js version in Windows PowerShell by running this command:
node --version

What does this command check?

The command asks the active Node.js installation to print its version. This reveals whether the required runtime is already available in your current PowerShell session.

✔️ I see v24.22.1

Your Node.js runtime matches the project. Keep this PowerShell window available for the setup commands ahead.

ⓧ I see an older version

Your current installation needs the project version before you continue.

  • Visit the official Node.js download page.
  • Download the Windows installer for Node.js 24.22.1 LTS.
  • Run the downloaded Windows installer.
  • Complete the installer with its default options.

ⓧ Command not found

Node.js is not available in your current PowerShell session. Install the required runtime before moving on.

  • Visit the official Node.js download page.
  • Download the Windows installer for Node.js 24.22.1 LTS.
  • Run the downloaded Windows installer.
  • Complete the installer with its default options.

Visual Studio Code gives you one place to edit the project files. Its recommended User setup also makes the code command available from PowerShell.

  • Visit the official Visual Studio Code Windows setup page.
  • Download the recommended User setup.
  • Run the downloaded installer.
  • Complete the installer with its recommended options.
  • Close your current PowerShell window.
  • Press the Windows key to open Windows search.
  • Type Windows PowerShell into the search field.
  • Press Enter to open a refreshed PowerShell session.
  • Confirm the required Node.js version in the refreshed session by running this command:
node --version

What should the version show?

You should see v24.22.1. The Node.js installer also includes npm for creating the project and installing Express.

Your development tools are ready. PowerShell can now run the exact Node.js version this API expects.

Still seeing the wrong Node.js version?

  • Close every PowerShell window so no session keeps the previous installation path.
  • Run the Node.js installer again if the installation did not finish.
  • Restart Windows if a refreshed PowerShell session still reports the previous version.

Help me fix my Node.js version. If the issue continues, share the non-secret output in the NextWork community.

Create the Express project

The project needs its own folder plus a package manifest. npm uses that manifest to record the exact Express dependency required by the API.

  • Move PowerShell to your Desktop by running this command:
Set-Location $HOME\Desktop

What does this command do?

The command moves your PowerShell session into the Desktop directory inside your Windows home folder. Your project folder will be easy to find after it is created.

  • Create the ecommerce-api folder by running these commands:
New-Item -ItemType Directory -Name ecommerce-api
Set-Location ecommerce-api

What do these commands do?

  • The first command creates the ecommerce-api directory on your Desktop.
  • The second command moves PowerShell into that directory so every project file lands in the correct place.

Your PowerShell prompt should now end with ecommerce-api. That confirms the next commands will affect the project folder.

Does the folder already exist?

The create command can fail when an ecommerce-api folder already exists on your Desktop. Rename or remove the old folder only if you no longer need its contents.

Help me resolve the existing project folder safely.

  • Initialize npm inside ecommerce-api by running this command:
npm init -y

What does npm initialize?

The command creates package.json without opening an interactive questionnaire. This file records project metadata plus runnable scripts and dependencies.

You should see npm print the new package information. The package.json file now exists inside ecommerce-api.

  • Install the exact Express dependency by running this command:
npm install express@5.3.0 --save-exact

What does this install?

npm downloads Express version 5.3.0 and saves that exact version in package.json. Exact dependency versions keep the project consistent across installations.

You should see npm finish without a final error. The project now has the dependency tree required to run Express.

Did the Express installation fail?

  • Confirm the PowerShell prompt still ends with ecommerce-api.
  • Check that your internet connection can reach the npm package registry.
  • Run the Node.js version check again if PowerShell cannot find npm.

Help me troubleshoot the Express installation. If the issue continues, share the non-secret error output in the NextWork community.

  • Open the current ecommerce-api folder in Visual Studio Code by running this command:
code .

What does this command open?

The code . command opens the current directory in Visual Studio Code. The dot represents the ecommerce-api folder selected in PowerShell.

You should see ecommerce-api as the open folder in Visual Studio Code. Its file list should include package.json.

Did the code command fail?

Close PowerShell before reopening it so the Visual Studio Code installer can refresh the command path. Reinstall Visual Studio Code with the recommended User setup if the command remains unavailable.

Help me make the Visual Studio Code command available.

Configure and verify the health endpoint

The package manifest selects ES modules so Node.js can load the Express import used by the server. Its scripts also give the API consistent development and start commands.

  • Select package.json from the file list in Visual Studio Code.
  • Replace the entire file with this configuration:
{
  "name": "ecommerce-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "node --watch server.js",
    "start": "node server.js"
  },
  "dependencies": {
    "express": "5.3.0"
  }
}

What does this configuration control?

  • The JSON manifest names the project and prevents accidental package publication.
  • The type value tells Node.js to load the project files as ES modules.
  • The dev script runs server.js in watch mode.
  • The dependency entry pins Express to 5.3.0.
  • Press Ctrl+S to save package.json.
  • Check the saved scripts object in the editor.

You should see both dev and start scripts. The Express dependency should show the exact 5.3.0 version.

Is package.json showing an error?

Check every comma and double quote against the reference block. A missing comma or quote makes the package manifest invalid.

Help me find the syntax issue in package.json.

The first server route reports whether the API process can answer requests. It returns a small status response without touching any product or order logic.

  • Select the ecommerce-api folder in the Visual Studio Code file list.
  • Use the new-file control beside the folder name.
  • Name the new file server.js.
  • Build the Express app and health route by pasting this code:
import express from 'express';

const app = express();
const PORT = 3000;

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

What does this code do?

  • The import makes Express available to the server file.
  • The app identifier holds the Express application.
  • The PORT constant keeps the local port fixed at 3000.
  • The /health route returns a status object when a client sends a GET request.
  • Press Ctrl+S to save server.js.
  • Check the saved route in the editor.

The route should return status with the value ok from /health.

  • Place your cursor after the health route's closing });.
  • Press Enter twice to leave one blank line.
  • Add the server listener by pasting this code:
app.listen(PORT, (error) => {
  if (error) {
    throw error;
  }

  console.log(`E-commerce API running at http://localhost:${PORT}`);
});

How does the listener start the API?

The listener binds the Express application to PORT. Its callback throws startup errors and prints the local API address after the server starts.

  • Press Ctrl+S to save server.js.
  • Switch back to the PowerShell window inside ecommerce-api.
  • Start the API in watch mode by running this command:
node --watch server.js

What does watch mode do?

Watch mode keeps server.js running and restarts the process when an imported project file changes. This PowerShell window remains occupied while the API is active.

Press Ctrl+C in that window whenever you need to stop the server.

You should see E-commerce API running at http://localhost:3000 in PowerShell. Keep that window running for the health check.

Did the server fail to start?

  • Confirm that PowerShell is still inside the ecommerce-api folder.
  • Compare the import and listener code with the full-file reference below.
  • Save both package.json and server.js before restarting the command.

Help me diagnose why the Express server does not start.

  • Press the Windows key to open Windows search.
  • Type Windows PowerShell into the search field.
  • Press Enter to open a second PowerShell window.

Before you send the request, what status value do you expect a healthy API to return?

  • Request the health endpoint from the second PowerShell window by running this command:
Invoke-RestMethod http://localhost:3000/health

What should I see?

PowerShell converts the API's response into an object. You should see status with the value ok.

That's the local foundation in place. Your Express server is now answering health checks on port 3000.

Did the health request fail?

  • Confirm the first PowerShell window still shows the running API process.
  • Check that the request uses port 3000 and the /health path.
  • Restart the watch command after saving server.js if the process stopped.

Help me fix the local health request.

Use these references to confirm that both project files match the finished setup.

✔️ Awesome, I've got everything!

Your files are saved. Keep the server running in watch mode for the next step.

ⓧ I'd like to double check the full code

Compare your package.json with this reference:

{
  "name": "ecommerce-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "node --watch server.js",
    "start": "node server.js"
  },
  "dependencies": {
    "express": "5.3.0"
  }
}

Compare your server.js with this reference:

import express from 'express';

const app = express();
const PORT = 3000;

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.listen(PORT, (error) => {
  if (error) {
    throw error;
  }

  console.log(`E-commerce API running at http://localhost:${PORT}`);
});

Your API can now receive local requests. Next, you'll give the storefront a product catalog that clients can retrieve as structured data.

Expose the Product Catalog

Your Express server is already answering its health check. A storefront still cannot display or price products because the API has no catalog.

In this step, you will give the REST API three products with prices and stock levels. You will expose the full catalog plus individual records as JSON.

In this step, get ready to:
  • Define the product catalog in a shared store.
  • Expose every product through a collection route.
  • Return individual products through a parameterized route.
Create the in-memory product store

The catalog needs one shared source of truth before any route can return it. In-memory state keeps that data inside the running Node.js process.

  • Select the new-file icon at the top of the file sidebar.
  • Name the file store.js.
  • Add the catalog plus the empty order state by pasting the code below:
export const products = [
  {
    id: 1,
    name: 'Canvas Backpack',
    category: 'bags',
    price: 69,
    stock: 5,
  },
  {
    id: 2,
    name: 'Desk Lamp',
    category: 'home',
    price: 45,
    stock: 3,
  },
  {
    id: 3,
    name: 'Travel Mug',
    category: 'accessories',
    price: 24,
    stock: 8,
  },
];

export const orders = [];
export const notifications = [];

What does this code do?

  • The products array holds the catalog records returned by the API.
  • Each id gives a product a stable identifier.
  • Each price gives the server control over the catalog price.
  • Each stock value records the available quantity.
  • The orders array starts empty.
  • The notifications array also starts empty.
  • Save store.js.
  • Confirm the file exists by finding store.js in the file sidebar.

Good progress. Your catalog data now has one shared home inside the project.

Seeing a problem in store.js?

  • Check that every product object closes with },.
  • Check that the product array closes with ];.
  • Confirm the filename is exactly store.js.

Help me compare my product store with the required structure.

✔️ Awesome, I've got everything!

Great. Double-check that store.js is saved before you connect it to the server.

ⓧ I'd like to double check the full code

export const products = [
  {
    id: 1,
    name: 'Canvas Backpack',
    category: 'bags',
    price: 69,
    stock: 5,
  },
  {
    id: 2,
    name: 'Desk Lamp',
    category: 'home',
    price: 45,
    stock: 3,
  },
  {
    id: 3,
    name: 'Travel Mug',
    category: 'accessories',
    price: 24,
    stock: 8,
  },
];

export const orders = [];
export const notifications = [];

What should match?

Your file should contain three product objects. The two state arrays should appear after the product catalog.

Connect the catalog route

The server needs access to the shared arrays before a route can return the catalog. JSON middleware also prepares the app to handle JSON request bodies consistently.

  • In server.js select the opening block from import express from 'express'; through const PORT = 3000;.
  • Replace the selected block with the shared server setup below:
import express from 'express';
import { notifications, orders, products } from './store.js';

const app = express();
const PORT = 3000;

app.use(express.json());

What does this setup do?

  • The new import gives server.js access to the shared arrays from store.js.
  • The products identifier now points to the catalog you created.
  • The express.json() middleware prepares matching request bodies for later route handlers.
  • Save server.js.
  • Switch back to the PowerShell window running the server.

The watch process restarts the app. You will see E-commerce API running at http://localhost:3000 again.

Server did not restart?

  • Confirm the import path ends with ./store.js.
  • Confirm store.js is saved inside the ecommerce-api folder.
  • Look for a syntax message that names the line requiring attention.

Help me fix the server restart after importing store.js.

A collection route maps one HTTP request to the entire catalog. The response comes directly from the shared products array.

  • In server.js locate the closing line of the /health route.
  • Add the product collection route below it by pasting this code:
app.get('/products', (req, res) => {
  res.json(products);
});

How does the catalog route work?

  • The app.get() method handles requests sent to /products.
  • The res.json() method sends the product array as JSON.
  • Save server.js.
  • Press the Windows key to open the search bar.
  • Type Windows PowerShell into the search bar.
  • Press Enter to open a second PowerShell window.
  • Request the full catalog by running this command:
Invoke-RestMethod http://localhost:3000/products

What does this request prove?

The Invoke-RestMethod command sends the request to your local route. Windows PowerShell converts the JSON response into objects you can inspect.

You will see three records for Canvas Backpack. Desk Lamp appears as the second record. Travel Mug appears as the third record.

Catalog request did not work?

  • Confirm the server PowerShell window still shows the local API URL.
  • Check that the route path is exactly /products.
  • Confirm the request uses port 3000.

Help me troubleshoot the products endpoint.

Handle individual and missing products

Clients also need a predictable response when they request one product. A shared error helper keeps the missing-product response consistent.

  • In server.js locate app.use(express.json());.
  • Add the shared error helper below that line by pasting this code:
function sendError(res, status, code, message) {
  return res.status(status).json({
    error: {
      code,
      message,
    },
  });
}

Why use a shared error helper?

  • The sendError() function receives the response details from a route.
  • Every error response places code inside an error object.
  • Every error response also places message inside the same object.
  • Save server.js.
  • Switch back to the PowerShell window running the server.

You will see the local API URL again after the watch process reloads the updated file.

Server stopped after adding sendError?

  • Confirm the helper appears before the route definitions.
  • Check that the nested error object closes before the response object.
  • Compare each closing brace with the helper shown above.

Help me fix the shared error helper.

A route parameter captures the requested product ID from the URL. The handler converts that value into a number before searching the catalog.

  • Return to server.js in the editor.
  • Locate the closing line of the /products route.
  • Add the individual-product route below it by pasting this code:
app.get('/products/:id', (req, res) => {
  const product = products.find((item) => item.id === Number(req.params.id));

  if (!product) {
    return sendError(res, 404, 'PRODUCT_NOT_FOUND', 'Product not found.');
  }

  return res.json(product);
});

How does the lookup work?

  • The :id segment captures the value at the end of the request path.
  • The products.find() call searches for a matching numeric product ID.
  • A missing match returns status 404 through the shared helper.
  • A successful match returns the product record as JSON.
  • Save server.js.
  • Switch back to the PowerShell window running the server.

The server reloads once more. You will see the local API URL when the updated routes are ready.

✔️ Awesome, I've got everything!

Your product routes are ready. Keep both PowerShell windows open for the final checks.

ⓧ I'd like to double check the full code

import express from 'express';
import { notifications, orders, products } from './store.js';

const app = express();
const PORT = 3000;

app.use(express.json());

function sendError(res, status, code, message) {
  return res.status(status).json({
    error: {
      code,
      message,
    },
  });
}

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.get('/products', (req, res) => {
  res.json(products);
});

app.get('/products/:id', (req, res) => {
  const product = products.find((item) => item.id === Number(req.params.id));

  if (!product) {
    return sendError(res, 404, 'PRODUCT_NOT_FOUND', 'Product not found.');
  }

  return res.json(product);
});

app.listen(PORT, (error) => {
  if (error) {
    throw error;
  }

  console.log(`E-commerce API running at http://localhost:${PORT}`);
});

What should match?

The shared setup should appear before the routes. The individual-product route should appear after the collection route.

  • Switch to the second PowerShell window.
  • Request product 1 by running this command:
Invoke-RestMethod -Uri http://localhost:3000/products/1

What does this request check?

This request sends 1 through the route parameter. The handler finds the matching product in the catalog.

You will see Canvas Backpack with ID 1. Its category is bags.

Before you try a missing ID, do you think the route returns a product or the shared missing-product error?

  • Request a missing product by running this command:
Invoke-RestMethod -Uri http://localhost:3000/products/999

That failure is expected

PowerShell reports a not-found HTTP response. The response body uses PRODUCT_NOT_FOUND as the error code.

This proves the route distinguishes a missing record from a successful lookup.

Before the final check, how many product records do you expect the collection route to return?

  • Verify the complete catalog by running this command:
Invoke-RestMethod http://localhost:3000/products

What should you see?

PowerShell displays three product records. Each record includes its ID plus its category.

Each record also includes its price plus its stock value. Your catalog is now available through the API.

Individual product check failed?

  • Confirm the route path contains /products/:id.
  • Check that the handler reads req.params.id.
  • Confirm the lookup converts the route value with Number().

Help me troubleshoot the individual-product route.

Your API can now serve the full catalog plus individual products. Next, you will use these products in the first order flow.

Trust the Client and Watch the Order Break

Your product catalog now returns structured data from the running API. A checkout can finally refer to real products.

A naive checkout that accepts a shopper's price gives that shopper control over the total. You will build that temporary HTTP route on purpose before sending a manipulated order through it.

In this step, get ready to:
  • Add a temporary POST /orders route that calculates totals from submitted prices.
  • Store the naive order in the orders array as in-memory state.
  • Send a manipulated price of 1 to expose the incorrect total.
Add the temporary order route

Express already turns incoming JSON bodies into req.body. The temporary handler uses those submitted values directly.

  • In the Explorer sidebar in Visual Studio Code, select server.js.
  • Find the app.listen(PORT, (error) => { line near the bottom of server.js.
  • Insert the temporary handler immediately above that line by pasting this code:
app.post('/orders', (req, res) => {
  const { items } = req.body ?? {};
  const total = items.reduce(
    (sum, item) => sum + item.unitPrice * item.quantity,
    0,
  );
  const order = {
    id: orders.length + 1,
    items,
    total,
  };

  orders.push(order);
  return res.status(201).json(order);
});

What does this code do?

  • The items value comes directly from req.body.
  • The reduce() calculation multiplies each submitted unitPrice by its submitted quantity.
  • The new order receives the next available in-memory ID before entering orders.
  • The response uses HTTP status 201 to confirm that the order was created.
  • Save server.js.
  • Switch back to the PowerShell terminal running node --watch server.js.

You should see the watcher restart the server. The terminal prints E-commerce API running at http://localhost:3000 again.

Did the server stop running?

  • Confirm that the new route sits above app.listen(PORT, (error) => {.
  • Compare every closing brace in the new handler with the reference below.
  • Save server.js again if the watcher has not restarted.

Ask for help checking the route syntax:

✔️ Awesome, I've got everything!

  • Confirm that server.js is saved before continuing.

ⓧ I'd like to double check the full code

import express from 'express';
import { notifications, orders, products } from './store.js';

const app = express();
const PORT = 3000;

app.use(express.json());

function sendError(res, status, code, message) {
  return res.status(status).json({
    error: {
      code,
      message,
    },
  });
}

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.get('/products', (req, res) => {
  res.json(products);
});

app.get('/products/:id', (req, res) => {
  const product = products.find((item) => item.id === Number(req.params.id));

  if (!product) {
    return sendError(res, 404, 'PRODUCT_NOT_FOUND', 'Product not found.');
  }

  return res.json(product);
});

app.post('/orders', (req, res) => {
  const { items } = req.body ?? {};
  const total = items.reduce(
    (sum, item) => sum + item.unitPrice * item.quantity,
    0,
  );
  const order = {
    id: orders.length + 1,
    items,
    total,
  };

  orders.push(order);
  return res.status(201).json(order);
});

app.listen(PORT, (error) => {
  if (error) {
    throw error;
  }

  console.log(`E-commerce API running at http://localhost:${PORT}`);
});

What should match?

The temporary app.post('/orders', ...) handler sits between the product routes and app.listen().

Your existing health route and product routes remain unchanged. The package.json file and store.js file also remain unchanged.

Prepare the manipulated checkout

The request body represents data controlled by a shopper. Setting unitPrice to 1 tests whether the route checks that value against the catalog.

  • Return to the PowerShell terminal you used to test the product routes.
  • Set product 2's submitted unitPrice to 1 by running this command:
$body = @{ items = @(@{ productId = 2; quantity = 1; unitPrice = 1 }) } | ConvertTo-Json -Depth 3

What does this command prepare?

ConvertTo-Json turns the nested PowerShell hashtable into JSON text. The $body variable now describes one Desk Lamp with a submitted price of 1.

Test the checkout boundary

Before you send the request, predict whether the route uses the catalog price of 45 or the submitted unitPrice of 1.

  • Send the order to the running API by running this command:
Invoke-RestMethod -Uri http://localhost:3000/orders -Method Post -Body $body -ContentType 'application/json'

What should I see?

PowerShell displays the newly created order. The response reports a total of 1.

The Desk Lamp catalog price is 45. This route never reads that price.

You have just proved that shopper-controlled prices can rewrite a store's revenue.

Didn't get a total of 1?

  • Confirm that $body uses productId = 2.
  • Confirm that $body uses unitPrice = 1.
  • Check that the watcher terminal still shows the local API URL.

Ask for help tracing the request:

Who should control the total?

Client input can request a product ID or quantity. The server still needs to validate both values.

The server must load prices from its trusted catalog before calculating a charge. This rule keeps money-related values under backend control.

You have captured the exact failure this exercise was designed to reveal. Next, you will move pricing and inventory rules into the server.

Move Pricing and Inventory Rules to the Server

Your last request exposed a serious weakness in the Express API. A shopper supplied a price of 1 for the Desk Lamp. The server accepted it without checking the catalog.

This step moves pricing authority into the server. The route validates the complete JSON request before reducing stock. It then builds the order from catalog prices.

In this step, get ready to:
  • Replace the naive order handler with complete request validation.
  • Calculate confirmed orders from server-controlled catalog prices.
  • Prove that rejected requests leave inventory unchanged.
Replace the naive order handler

A request body contains user-controlled values. Complete validation checks every line before the route changes stock or stores an order.

  • In server.js, select the temporary app.post('/orders' route from its opening line through its closing });.
  • Replace the selected route by pasting this secure handler:
app.post('/orders', (req, res) => {
  const { items } = req.body ?? {};
  if (!Array.isArray(items) || items.length === 0) return sendError(res, 400, 'INVALID_ORDER', 'Order items must be a non-empty array.');
  const seenProductIds = new Set();
  const checkedItems = [];
  for (const item of items) {
    if (!Number.isInteger(item?.productId) || !Number.isInteger(item?.quantity) || item.quantity < 1) return sendError(res, 400, 'INVALID_ITEM', 'Each item needs integer productId and quantity values, with quantity at least 1.');
    if (seenProductIds.has(item.productId)) return sendError(res, 400, 'DUPLICATE_PRODUCT', 'Each product may appear only once per order.');
    seenProductIds.add(item.productId);
    const product = products.find((candidate) => candidate.id === item.productId);
    if (!product) return sendError(res, 404, 'PRODUCT_NOT_FOUND', `Product ${item.productId} was not found.`);
    if (product.stock < item.quantity) return sendError(res, 409, 'INSUFFICIENT_STOCK', `${product.name} has only ${product.stock} units available.`);
    checkedItems.push({ product, quantity: item.quantity });
  }
  const total = checkedItems.reduce((sum, item) => sum + item.product.price * item.quantity, 0);
  for (const item of checkedItems) item.product.stock -= item.quantity;
  const order = { id: orders.length + 1, items: checkedItems.map((item) => ({ productId: item.product.id, name: item.product.name, unitPrice: item.product.price, quantity: item.quantity, lineTotal: item.product.price * item.quantity })), total, status: 'confirmed', createdAt: new Date().toISOString() };
  orders.push(order);
  return res.status(201).json(order);
});

What does this route do?

  • The first check requires items to be a non-empty array.
  • The item checks require integer product IDs. They also require positive integer quantities.
  • The seenProductIds set prevents the same product from appearing twice.
  • The checkedItems array holds catalog products until every requested line passes.
  • The total uses each catalog product's price value. Any client-supplied unitPrice value has no role in the calculation.
  • The stock loop runs only after the validation loop succeeds.
  • Save server.js.
  • Return to the server terminal to confirm the local API URL prints after the automatic restart.

Server not restarting cleanly?

Check that the old app.post('/orders' route was fully replaced. A leftover brace can prevent Node.js from loading the file.

Compare the opening and closing braces in the new handler. Help me find the syntax problem in my order route.

Add confirmed order retrieval

The orders array now stores confirmed orders. A read route makes that in-memory state visible through the API.

  • In server.js, locate the existing product-detail route immediately above app.post('/orders'.
  • Use this current route as the replacement boundary:
app.get('/products/:id', (req, res) => {
  const product = products.find((item) => item.id === Number(req.params.id));

  if (!product) {
    return sendError(res, 404, 'PRODUCT_NOT_FOUND', 'Product not found.');
  }

  return res.json(product);
});

What are you locating?

This reference identifies the exact block that already handles individual products. The order retrieval route belongs directly below it.

  • Replace the located product-detail block with this version:
app.get('/products/:id', (req, res) => {
  const product = products.find((item) => item.id === Number(req.params.id));
  if (!product) return sendError(res, 404, 'PRODUCT_NOT_FOUND', 'Product not found.');
  return res.json(product);
});
app.get('/orders', (req, res) => res.json(orders));

What changed here?

The product-detail route keeps its existing behavior. The new GET /orders route returns the current orders array as JSON.

  • Save server.js.
  • Return to the server terminal to confirm the API restarts at http://localhost:3000.

Order route still unavailable?

Confirm that app.get('/orders' sits above the app.post('/orders' handler. Check that both routes have their own closing });.

Ask for help if the server starts but the order endpoint cannot be reached. Why is my GET /orders route unavailable?

✔️ Awesome, I've got everything!

Your secure order handler is in place. Your new read route exposes confirmed orders.

ⓧ I'd like to double check the full code

import express from 'express';
import { notifications, orders, products } from './store.js';

const app = express();
const PORT = 3000;

app.use(express.json());

function sendError(res, status, code, message) {
  return res.status(status).json({ error: { code, message } });
}

app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.get('/products', (req, res) => res.json(products));
app.get('/products/:id', (req, res) => {
  const product = products.find((item) => item.id === Number(req.params.id));
  if (!product) return sendError(res, 404, 'PRODUCT_NOT_FOUND', 'Product not found.');
  return res.json(product);
});
app.get('/orders', (req, res) => res.json(orders));

app.post('/orders', (req, res) => {
  const { items } = req.body ?? {};
  if (!Array.isArray(items) || items.length === 0) return sendError(res, 400, 'INVALID_ORDER', 'Order items must be a non-empty array.');
  const seenProductIds = new Set();
  const checkedItems = [];
  for (const item of items) {
    if (!Number.isInteger(item?.productId) || !Number.isInteger(item?.quantity) || item.quantity < 1) return sendError(res, 400, 'INVALID_ITEM', 'Each item needs integer productId and quantity values, with quantity at least 1.');
    if (seenProductIds.has(item.productId)) return sendError(res, 400, 'DUPLICATE_PRODUCT', 'Each product may appear only once per order.');
    seenProductIds.add(item.productId);
    const product = products.find((candidate) => candidate.id === item.productId);
    if (!product) return sendError(res, 404, 'PRODUCT_NOT_FOUND', `Product ${item.productId} was not found.`);
    if (product.stock < item.quantity) return sendError(res, 409, 'INSUFFICIENT_STOCK', `${product.name} has only ${product.stock} units available.`);
    checkedItems.push({ product, quantity: item.quantity });
  }
  const total = checkedItems.reduce((sum, item) => sum + item.product.price * item.quantity, 0);
  for (const item of checkedItems) item.product.stock -= item.quantity;
  const order = { id: orders.length + 1, items: checkedItems.map((item) => ({ productId: item.product.id, name: item.product.name, unitPrice: item.product.price, quantity: item.quantity, lineTotal: item.product.price * item.quantity })), total, status: 'confirmed', createdAt: new Date().toISOString() };
  orders.push(order);
  return res.status(201).json(order);
});

app.listen(PORT, (error) => {
  if (error) throw error;
  console.log(`E-commerce API running at http://localhost:${PORT}`);
});

How should the full file look?

The full reference keeps the existing health and product behavior. It adds validated order creation plus order retrieval.

Compare each route in your server.js file with the reference. Resolve any differences before testing the API.

Prove the order rules

Saving the file restarted the Node.js process. That reset the earlier naive order because the project stores data in memory.

  • Switch back to the PowerShell request terminal from the previous step.
  • Recreate the manipulated order body by running:
$body = @{ items = @(@{ productId = 2; quantity = 1; unitPrice = 1 }) } | ConvertTo-Json -Depth 3

What does this value contain?

The request asks for one Desk Lamp. It still claims that the unit price is 1.

The nested object becomes the JSON body stored in $body.

Before you submit this request, do you think the server will use the claimed price or the catalog price?

  • Submit the manipulated order by running:
Invoke-RestMethod -Uri http://localhost:3000/orders -Method Post -Body $body -ContentType 'application/json'

What does this request prove?

The request still sends a client-controlled unitPrice value. The route finds product 2 in the catalog before calculating the line total.

You'll see a confirmed order with unitPrice and total set to 45. That closes the price-manipulation flaw you exposed in the previous step.

  • Press the Up Arrow key until the $body assignment appears.
  • Replace the quantity value of 1 with 99.
  • Press Enter to update $body.

Before you send the updated request, do you expect the order count or stock value to change?

  • Press the Up Arrow key until the order request appears.
  • Press Enter to submit the request.

PowerShell reports an HTTP 409 Conflict response with the INSUFFICIENT_STOCK error code. The impossible order is rejected.

  • Press the Up Arrow key until your earlier product-detail request appears.
  • Set its target URL to http://localhost:3000/products/2.
  • Press Enter to retrieve the Desk Lamp.

You'll see the Desk Lamp stock remains 2. Only the successful one-unit order changed inventory.

  • Press the Up Arrow key until an earlier catalog request appears.
  • Set its target URL to http://localhost:3000/orders.
  • Press Enter to retrieve the stored orders.

You'll see one confirmed order with a total of 45. The rejected request created no second order.

Unexpected total or stock value?

  • Check that the total reads from item.product.price inside checkedItems.
  • Confirm the stock loop appears after the complete validation loop.
  • Check that orders.push(order) appears after the order object is built.

Describe the values you received if the result still differs. Why is my secure order route returning the wrong total or stock?

Your API now controls prices, protects stock, and records only confirmed orders. Next, you will separate follow-up notifications from the order route with events.

Emit Order and Stock Notifications

Your Express API now rejects unsafe orders before stock changes. It records confirmed purchases using catalog prices.

Order creation should stay focused on validating purchases. In this step, a Node.js EventEmitter lets listeners create notifications after a valid order reaches storage.

In this step, get ready to:
  • Create event listeners that write notification records.
  • Emit order and low-stock events after successful order storage.
  • Expose notification records through API routes.
Create the notification event module

An event names something that has already happened. A listener responds without placing its follow-up work inside the route that emitted it.

  • In Visual Studio Code, select the ecommerce-api folder in the Explorer sidebar.
  • Use the new-file control in the Explorer sidebar to create events.js inside ecommerce-api.
  • Build the event module by pasting this code into events.js:
import { EventEmitter } from 'node:events';
import { notifications } from './store.js';

export const commerceEvents = new EventEmitter();

function addNotification(type, message, recipient) {
  notifications.push({
    id: notifications.length + 1,
    type,
    message,
    recipient,
    read: false,
    createdAt: new Date().toISOString(),
  });
}

commerceEvents.on('order.created', (order) => {
  addNotification('order-confirmation', `Order ${order.id} confirmed. Total: $${order.total.toFixed(2)}.`, 'customer');
  addNotification('new-order', `New order ${order.id} is ready for processing.`, 'admin');
});

commerceEvents.on('stock.low', (product) => {
  addNotification('low-stock', `${product.name} has only ${product.stock} units left.`, 'admin');
});

What does this module do?

  • The commerceEvents emitter gives the API one place to publish commerce events.
  • The addNotification() helper gives every notification the same structure.
  • The order.created listener creates a customer confirmation.
  • The same listener creates an administrator alert.
  • The stock.low listener creates an administrator warning.
  • Save events.js.
  • Confirm that events.js appears beside server.js in the Explorer sidebar.

Good progress. Your project now has a dedicated module for turning order events into notification records.

Can't find the event module?

Check that events.js sits directly inside the ecommerce-api folder. Confirm that its name does not contain an extra file extension.

Ask for targeted help: Help me check my events.js file location.

Use this reference to compare your complete event module before connecting it to the order route.

✔️ Awesome, I've got everything!

Your saved events.js now exports the emitter. It also registers both notification listeners.

ⓧ I'd like to double check the full code

import { EventEmitter } from 'node:events';
import { notifications } from './store.js';

export const commerceEvents = new EventEmitter();

function addNotification(type, message, recipient) {
  notifications.push({
    id: notifications.length + 1,
    type,
    message,
    recipient,
    read: false,
    createdAt: new Date().toISOString(),
  });
}

commerceEvents.on('order.created', (order) => {
  addNotification('order-confirmation', `Order ${order.id} confirmed. Total: $${order.total.toFixed(2)}.`, 'customer');
  addNotification('new-order', `New order ${order.id} is ready for processing.`, 'admin');
});

commerceEvents.on('stock.low', (product) => {
  addNotification('low-stock', `${product.name} has only ${product.stock} units left.`, 'admin');
});

What should match?

  • The file exports commerceEvents.
  • The file registers the order.created listener.
  • The file registers the stock.low listener.
  • Every notification starts with read set to false.
Connect events to confirmed orders

A low-stock alert should fire when an order crosses the stock threshold. The route needs a snapshot of the earlier stock value to recognize that crossing.

  • Return to server.js in the editor.
  • Replace the opening imports plus constants with this block:
import express from 'express';
import { commerceEvents } from './events.js';
import { notifications, orders, products } from './store.js';

const app = express();
const PORT = 3000;
const LOW_STOCK_THRESHOLD = 2;

Why add these values?

  • The commerceEvents import connects the order route to the listeners.
  • The LOW_STOCK_THRESHOLD constant defines the warning boundary.
  • The existing notifications import supplies the notification feed added in the next substep.
  • Save server.js.
  • Return to the running PowerShell terminal.

You should see the local API URL after the watch process restarts. This confirms that server.js can load the event module.

Did the server stop after the import?

Check that the import path is exactly ./events.js. Confirm that commerceEvents uses the same capitalization in both files.

Ask for help with the first error shown in PowerShell: Help me debug the event module import.

  • In server.js, find checkedItems.push({ product, quantity: item.quantity }); inside the validation loop.
  • Replace that line with the code below:
    checkedItems.push({ product, quantity: item.quantity, previousStock: product.stock });

Why preserve the earlier stock?

The previousStock value captures inventory before subtraction. That snapshot reveals whether the order moved a product across the threshold.

  • Save server.js.
  • Return to the running PowerShell terminal.

You should see the local API URL again. The updated validation loop now loads without a syntax error.

  • Find orders.push(order); near the end of the order route.
  • Replace the code from that line through the route response with this block:
  orders.push(order);
  commerceEvents.emit('order.created', order);
  for (const item of checkedItems) {
    if (item.previousStock > LOW_STOCK_THRESHOLD && item.product.stock <= LOW_STOCK_THRESHOLD) commerceEvents.emit('stock.low', item.product);
  }
  return res.status(201).json(order);

How does the event sequence work?

  • The route stores the confirmed order first.
  • The order.created event triggers the confirmation records.
  • The loop compares earlier stock with updated stock.
  • The stock.low event fires when stock crosses from above 2 to 2 or below.
  • Save server.js.
  • Return to the running PowerShell terminal.

You should see the local API URL after another restart. The route now publishes events only after a confirmed order reaches storage.

Did the event wiring stop the server?

Confirm that LOW_STOCK_THRESHOLD appears near PORT. Check that previousStock uses the same capitalization in both locations.

Ask for help with the reported line: Help me fix my event wiring error.

Expose and verify the notification feed

The listeners now write records into the shared notification array. Two routes let a client retrieve those records or update one record's read state.

  • Find the app.listen() block at the bottom of server.js.
  • Add these routes directly above app.listen():
app.get('/notifications', (req, res) => res.json(notifications));
app.patch('/notifications/:id/read', (req, res) => {
  const notification = notifications.find((item) => item.id === Number(req.params.id));
  if (!notification) return sendError(res, 404, 'NOTIFICATION_NOT_FOUND', 'Notification not found.');
  notification.read = true;
  return res.json(notification);
});
app.use((req, res) => sendError(res, 404, 'ROUTE_NOT_FOUND', 'Route not found.'));

What do these routes provide?

  • The GET /notifications route returns every notification in memory.
  • The PATCH /notifications/:id/read route finds one record by numeric ID.
  • A found record changes its read value to true.
  • A missing record returns NOTIFICATION_NOT_FOUND through the shared error shape.
  • The final fallback returns ROUTE_NOT_FOUND for an unmatched route.
  • Save server.js.
  • Return to the running PowerShell terminal.

You should see the local API URL after the watch process restarts. The notification endpoints are now available.

Seeing an error after adding the routes?

Confirm that both notification routes sit above app.listen(). Check that notifications remains in the store.js import.

Ask for help with the first reported line: Help me fix my notification route syntax.

Use this reference to compare the completed server.js file before running the final checks.

✔️ Awesome, I've got everything!

Your saved server.js now emits both event types. It also exposes both notification endpoints.

ⓧ I'd like to double check the full code

import express from 'express';
import { commerceEvents } from './events.js';
import { notifications, orders, products } from './store.js';

const app = express();
const PORT = 3000;
const LOW_STOCK_THRESHOLD = 2;

app.use(express.json());

function sendError(res, status, code, message) {
  return res.status(status).json({ error: { code, message } });
}

app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.get('/products', (req, res) => res.json(products));
app.get('/products/:id', (req, res) => {
  const product = products.find((item) => item.id === Number(req.params.id));
  if (!product) return sendError(res, 404, 'PRODUCT_NOT_FOUND', 'Product not found.');
  return res.json(product);
});
app.get('/orders', (req, res) => res.json(orders));

app.post('/orders', (req, res) => {
  const { items } = req.body ?? {};
  if (!Array.isArray(items) || items.length === 0) return sendError(res, 400, 'INVALID_ORDER', 'Order items must be a non-empty array.');
  const seenProductIds = new Set();
  const checkedItems = [];
  for (const item of items) {
    if (!Number.isInteger(item?.productId) || !Number.isInteger(item?.quantity) || item.quantity < 1) return sendError(res, 400, 'INVALID_ITEM', 'Each item needs integer productId and quantity values, with quantity at least 1.');
    if (seenProductIds.has(item.productId)) return sendError(res, 400, 'DUPLICATE_PRODUCT', 'Each product may appear only once per order.');
    seenProductIds.add(item.productId);
    const product = products.find((candidate) => candidate.id === item.productId);
    if (!product) return sendError(res, 404, 'PRODUCT_NOT_FOUND', `Product ${item.productId} was not found.`);
    if (product.stock < item.quantity) return sendError(res, 409, 'INSUFFICIENT_STOCK', `${product.name} has only ${product.stock} units available.`);
    checkedItems.push({ product, quantity: item.quantity, previousStock: product.stock });
  }
  const total = checkedItems.reduce((sum, item) => sum + item.product.price * item.quantity, 0);
  for (const item of checkedItems) item.product.stock -= item.quantity;
  const order = { id: orders.length + 1, items: checkedItems.map((item) => ({ productId: item.product.id, name: item.product.name, unitPrice: item.product.price, quantity: item.quantity, lineTotal: item.product.price * item.quantity })), total, status: 'confirmed', createdAt: new Date().toISOString() };
  orders.push(order);
  commerceEvents.emit('order.created', order);
  for (const item of checkedItems) {
    if (item.previousStock > LOW_STOCK_THRESHOLD && item.product.stock <= LOW_STOCK_THRESHOLD) commerceEvents.emit('stock.low', item.product);
  }
  return res.status(201).json(order);
});

app.get('/notifications', (req, res) => res.json(notifications));
app.patch('/notifications/:id/read', (req, res) => {
  const notification = notifications.find((item) => item.id === Number(req.params.id));
  if (!notification) return sendError(res, 404, 'NOTIFICATION_NOT_FOUND', 'Notification not found.');
  notification.read = true;
  return res.json(notification);
});
app.use((req, res) => sendError(res, 404, 'ROUTE_NOT_FOUND', 'Route not found.'));
app.listen(PORT, (error) => {
  if (error) throw error;
  console.log(`E-commerce API running at http://localhost:${PORT}`);
});

What should match?

  • The file imports commerceEvents.
  • The file defines LOW_STOCK_THRESHOLD as 2.
  • The order route records previousStock.
  • The notification routes appear above the fallback middleware.
  • The fallback middleware appears directly above app.listen().

Restarting the server clears the in-memory orders, stock changes, and notifications from earlier checks. This gives the final request a clean catalog with the Desk Lamp back at stock 3.

  • Stop the watch process by pressing Ctrl+C in the server terminal.
  • Restart the API by running this command:
node --watch server.js

What does this command do?

The watch process starts server.js as the API entry point. It also restarts the server when an imported project file changes.

You should see E-commerce API running at http://localhost:3000 in the server terminal.

Did the server fail to restart?

Read the first reported file name in PowerShell. Check that file for a missing quote or bracket near the reported line.

Ask for help with the first error: Help me diagnose the server restart failure.

  • Return to the request PowerShell terminal from the previous step.
  • Recreate the one-item Desk Lamp request body by running:
$body = @{ items = @(@{ productId = 2; quantity = 1; unitPrice = 1 }) } | ConvertTo-Json -Depth 3

What does this command prepare?

The command creates a JSON request for one Desk Lamp. The included unitPrice remains manipulated so the server-controlled catalog price can prove that it still wins.

Before you submit this order, do you expect the stored total to use the request price or the catalog price?

  • Submit the Desk Lamp order by running:
Invoke-RestMethod -Uri http://localhost:3000/orders -Method Post -Body $body -ContentType 'application/json'

What does this request trigger?

The request validates the order before reducing stock. A confirmed order then emits the order and threshold-crossing events.

You should see a confirmed order with a total of 45. The request price of 1 is ignored.

Before you retrieve the feed, how many notifications do you expect one confirmed Desk Lamp order to create?

  • Retrieve the notification feed by running:
Invoke-RestMethod http://localhost:3000/notifications

What does this request check?

This request reads the shared notification array. It reveals every listener reaction created by the confirmed order.

You should see three records. They include a customer order-confirmation, an administrator new-order, and an administrator low-stock notification.

The low-stock message should report 2 Desk Lamp units. This proves the order crossed the threshold from 3 to 2.

Missing one of the three notifications?

Confirm that the Desk Lamp started with stock 3 after the restart. Check that both event emissions appear after orders.push(order);.

Ask for help comparing the route with its listeners: Help me trace the missing notification.

Before you update notification 1, which field do you expect the API to change?

  • Mark notification 1 as read by running:
Invoke-RestMethod -Uri http://localhost:3000/notifications/1/read -Method Patch

What does this request update?

The patch route finds notification 1 by its numeric route parameter. It updates that record inside the shared in-memory array.

You should see notification 1 with read shown as True in PowerShell. That is the complete event-driven notification workflow working from order creation through read-state updates.

Notification 1 still unread?

Confirm that the route ends with /1/read. Check that the request method is Patch.

Ask for help with the returned response: Help me debug the notification read update.

Secret mission

Filter Products with Query Parameters

A larger catalog needs a safe way to narrow its results. In this mission, you will add validated category and maximum-price filters while preserving every existing API behavior.

Clean Up Your Resources

Clean Up Your Resources

Everything in this project runs locally on your Windows computer, so there are no ongoing service costs. Choose whether to keep the API running, pause it while preserving your files, or remove the project entirely.

Resources you used:

  • One running Node.js watch process in Windows PowerShell, serving your local API at http://localhost:3000.
  • The local ecommerce-api folder containing your Express project files and installed packages.

Keep everything running

No action is needed while you are still actively testing the API. The current server process keeps every endpoint available.

  • Keep the Windows PowerShell window from earlier running while you continue testing the API.
  • Keep the ecommerce-api folder in its current parent location.

Your API stays available at http://localhost:3000 while the watch process runs. This local setup has no ongoing service costs.

Pause - I'll come back to this later

Stop the local server process to free the port while preserving your project files.

  • Switch back to the Windows PowerShell terminal from earlier.
  • Press Ctrl+C in that terminal.
  • Keep the ecommerce-api folder in its current parent location.

That puts your API safely on hold. You will see the PowerShell prompt return after the watch process stops.

Delete - I don't want to use this again

Deleting the project folder permanently removes your code and installed project packages. Stopping the server also clears every in-memory order and notification.

Your Node.js and Visual Studio Code installations stay available for future projects.

  • Switch back to the Windows PowerShell terminal from earlier.
  • Press Ctrl+C to stop the watch process.
  • Return to Visual Studio Code from earlier.
  • Close the current project window.

The running process is now stopped. The project folder is ready for permanent removal.

  • Open the Windows file browser from system search.
  • Navigate to the parent location that contains ecommerce-api.
  • Select the ecommerce-api folder.
  • Press Shift+Delete to remove it permanently.
  • Approve the confirmation prompt if Windows shows one.

You should no longer see the ecommerce-api folder in its parent location. That confirms the local project files and installed packages are gone.

Nice Work!

Nice Work!

Excellent work! Your local e-commerce backend now handles a complete order flow under server-controlled rules.

You've learned how to:

  • Build a local Express REST API that returns product records as structured JSON.
  • Protect checkout with input validation that rejects malformed orders before stock changes. Calculate every total from server-controlled catalog prices.
  • Create customer confirmations through Node.js events. Create administrator alerts from the same confirmed order. Generate low-stock warnings when inventory crosses the threshold.
  • Secret Mission: Filter products with validated category values. Add a maximum-price query parameter with matching error responses.

Ready to quiz yourself?