Build a Financial Tracker Backend API

Build a Fastify REST API serving live stock, crypto, and forex data.

Introduction

30 Second Summary

Every time you check a stock price or glance at crypto markets on your phone, an API is fetching that data from somewhere. Behind every financial dashboard is a backend that talks to market data providers, manages rate limits, and serves prices fast.

In this project, you will build a backend REST API that pulls live stock, crypto, and forex prices from Finnhub and serves them through your own endpoints. You'll add MongoDB caching to stay within rate limits and a persistent watchlist for tracking symbols.

What You'll Build

Hit your own API endpoints and get back real-time financial quotes for any stock ticker, cryptocurrency pair, or forex rate, all powered by a Fastify server you built from scratch.

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

  • A live quote endpoint where you request any stock or crypto symbol and get back the current price, daily high/low, and change percentage.
  • A server-side cache that returns instant responses for repeated requests instead of burning through Finnhub's 60 calls per minute limit.
  • A persistent watchlist with full create, read, and delete operations, plus a news feed endpoint for market and company-specific articles.
  • Secret Mission: Abstract the data layer into a swappable provider pattern so upgrading from Finnhub's free tier to a paid API is a one-file change.

Are there any prerequisites?

This project assumes you have Node.js v24 installed, a local MongoDB instance running, and basic familiarity with REST APIs. You'll also need a free Finnhub API key (no credit card required).

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it needs its own backend server. A frontend app could call Finnhub directly, but that would expose your API key in the browser, run into CORS restrictions, and burn through the 60 calls/minute rate limit every time a user refreshes the page.

Set Up the Project

Every financial tracker needs a data source and a place to store results. Before you can serve any live market data, you need a running server that connects to your database and can reach the Finnhub API.

In this step, you will create the project folder, install Fastify and its MongoDB plugin, grab a free Finnhub API key, and confirm the server starts cleanly.

In this step, get ready to:
  • Create the project folder with package.json and install dependencies.
  • Sign up for a free Finnhub API key and configure environment variables.
  • Build server.js and confirm the server starts.
Create the project folder and install dependencies

You need a dedicated folder on your Desktop so you can find your project easily.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir financial-tracker-api
cd financial-tracker-api
  • Confirm the folder exists by running:
pwd

You should see a path ending in financial-tracker-api.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VSCodium and press Enter to open it.
  • In VSCodium, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the financial-tracker-api folder, and click Open.

Now you need a package.json file to define your project and its dependencies.

  • Create a new file called package.json in the root of your financial-tracker-api folder and paste this content:
{
  "name": "financial-tracker-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node --env-file=.env server.js",
    "dev": "node --watch --env-file=.env server.js"
  },
  "dependencies": {
    "fastify": "5.10.0",
    "@fastify/mongodb": "10.0.0"
  }
}

What does this file do?

  • "type": "module" enables ES module syntax so you can use import and export in your code.
  • The dev script uses Node.js native --watch to auto-restart on file changes and --env-file to load environment variables from .env. No extra packages needed.
  • fastify is the web framework. @fastify/mongodb is its official MongoDB plugin.
  • Save package.json.
  • Install the dependencies by running this command in your terminal:
npm install

You should see output confirming packages were added, ending with a line like added X packages.

Seeing permission errors or command not found?

  • Make sure you are inside the financial-tracker-api folder. Run pwd to confirm your path ends with financial-tracker-api.
  • Confirm Node.js is installed by running node --version. You should see v24 or higher.

Still stuck? Help me troubleshoot npm install errors in my financial tracker project.

✔️ Awesome, I've got everything!

Great. Double check you have saved package.json and that a node_modules folder now exists in your project.

ⓧ I'd like to double check the full code

{
  "name": "financial-tracker-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node --env-file=.env server.js",
    "dev": "node --watch --env-file=.env server.js"
  },
  "dependencies": {
    "fastify": "5.10.0",
    "@fastify/mongodb": "10.0.0"
  }
}
Get your Finnhub API key and configure environment variables

Finnhub provides free real-time data for stocks, crypto, and forex. You need an API key to authenticate your requests.

  • Open your browser and go to finnhub.io.
  • Click Get free API key.
  • Create a free account. No credit card is required.
  • After signing up, you will be taken to your dashboard where your API key is displayed.
  • Copy your API key and keep it handy.

What is an API key?

An API key is a unique string that identifies your requests to a service. Finnhub uses it to track your usage against the free tier limit of 60 calls per minute.

Now create the .env file that stores your configuration.

  • In VSCodium, create a new file called .env in the root of your financial-tracker-api folder and paste this content:
FINNHUB_API_KEY=your-api-key-here
MONGODB_URL=mongodb://localhost:27017/financial-tracker
PORT=3000
  • Replace your-api-key-here with the API key you copied from the Finnhub dashboard.
  • Save .env.

What do these variables do?

  • FINNHUB_API_KEY authenticates your requests to the Finnhub API.
  • MONGODB_URL tells your server where to find MongoDB. The financial-tracker database will be created automatically on first use.
  • PORT sets the port your server listens on.

Not sure where to find your API key?

After logging in to finnhub.io, your API key is shown on the dashboard page. Look for a string starting with letters and numbers. Copy the full value.

Need more help? Help me find my Finnhub API key on the dashboard.

✔️ Awesome, I've got everything!

Great. Make sure you have replaced your-api-key-here with your real key and saved .env.

ⓧ I'd like to double check the full code

FINNHUB_API_KEY=your-api-key-here
MONGODB_URL=mongodb://localhost:27017/financial-tracker
PORT=3000
Create the server and start it

With dependencies installed and environment configured, you can now write the server entry point.

  • Create a new file called server.js in the root of your financial-tracker-api folder and paste this code:
import Fastify from 'fastify'
import fastifyMongodb from '@fastify/mongodb'

// Create a Fastify instance with logging enabled
const fastify = Fastify({ logger: true })

// Register the MongoDB plugin to connect to your database
fastify.register(fastifyMongodb, {
  forceClose: true,
  url: process.env.MONGODB_URL
})

// Start the server
const start = async () => {
  try {
    await fastify.listen({ port: parseInt(process.env.PORT) || 3000 })
  } catch (err) {
    fastify.log.error(err)
    process.exit(1)
  }
}

start()

What does this code do?

  • Fastify({ logger: true }) creates the server with Pino logging enabled. Every request and lifecycle event gets logged as structured JSON.
  • fastify.register(fastifyMongodb, ...) connects to MongoDB using the URL from your .env file. forceClose: true ensures the connection closes when the server stops.
  • fastify.listen() binds to the port and starts accepting requests.
  • Save server.js.

Before you run the server, what do you think will appear in the terminal if everything is configured correctly?

  • Start the development server by running:
npm run dev

You should see JSON log output in your terminal that includes Server listening at http://127.0.0.1:3000.

Server not starting?

  • If you see a connection error mentioning MongoDB, confirm your local MongoDB instance is running. Start it with mongod if needed.
  • If you see Cannot find module, make sure you ran npm install successfully and that node_modules exists.
  • If port 3000 is already in use, change the PORT value in your .env file to another number like 3001.

Still stuck? Help me debug why my Fastify server won't start.

✔️ Awesome, I've got everything!

Great. Your server is running. Keep this terminal open for the rest of the project.

ⓧ I'd like to double check the full code

import Fastify from 'fastify'
import fastifyMongodb from '@fastify/mongodb'

const fastify = Fastify({ logger: true })

fastify.register(fastifyMongodb, {
  forceClose: true,
  url: process.env.MONGODB_URL
})

const start = async () => {
  try {
    await fastify.listen({ port: parseInt(process.env.PORT) || 3000 })
  } catch (err) {
    fastify.log.error(err)
    process.exit(1)
  }
}

start()

Your server is live and connected to MongoDB. Next up, you will build the live quote endpoint that pulls real-time stock prices from Finnhub.

Build the Live Quote Endpoint

Your server is running and MongoDB is connected. Now it's time to make your API actually do something useful.

You'll build the core feature of your tracker: an endpoint that fetches live stock prices from Finnhub and returns them through your own server. By the end of this step, you'll hit a URL and see real market data flowing back.

In this step, get ready to:
  • Create a Finnhub service that fetches live stock quotes.
  • Build a quote route that exposes the data as an API endpoint.
  • Register the route and test with real ticker symbols.
Create the Finnhub service

A service layer separates your API-calling logic from your route handlers. This keeps each file focused on one job.

  • In VSCodium's Explorer sidebar, right-click the project folder and select New Folder.
  • Name the folder services.
  • Right-click the new services folder and select New File.
  • Name the file finnhub.js.
  • Paste the following code into services/finnhub.js:
// Base URL for all Finnhub API requests
const FINNHUB_BASE_URL = 'https://finnhub.io/api/v1'

// Fetches a real-time quote for the given stock/crypto symbol
export async function getQuote(symbol) {
  const url = `${FINNHUB_BASE_URL}/quote?symbol=${encodeURIComponent(symbol)}&token=${process.env.FINNHUB_API_KEY}`
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`Finnhub API error: ${response.status}`)
  }
  return response.json()
}

What does this code do?

This module exports a single function that talks to Finnhub's quote endpoint.

  • FINNHUB_BASE_URL stores the root of Finnhub's API so you don't repeat it in every function.
  • getQuote builds the request URL with the symbol and your API key from the environment.
  • If the response status isn't 200, it throws an error immediately. Otherwise it parses the JSON and returns it.
  • Native fetch() is built into Node.js v24. No extra dependencies needed.
  • Save services/finnhub.js.
  • Check the Explorer sidebar. You should see a services folder with finnhub.js inside it.

Don't see the services folder?

Make sure you created the folder at the project root level (next to server.js), not inside node_modules or another subfolder.

help me find where to create my services folder

✔️ Awesome, I've got everything!

That's the complete services/finnhub.js file. You'll add more functions to it later, but for now this is all you need.

ⓧ I'd like to double check the full code

const FINNHUB_BASE_URL = 'https://finnhub.io/api/v1'

export async function getQuote(symbol) {
  const url = `${FINNHUB_BASE_URL}/quote?symbol=${encodeURIComponent(symbol)}&token=${process.env.FINNHUB_API_KEY}`
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`Finnhub API error: ${response.status}`)
  }
  return response.json()
}
Create the quote route

Fastify organizes routes as plugins. Each plugin is a function that receives the Fastify instance and registers its own endpoints.

  • In the Explorer sidebar, right-click the project folder and select New Folder.
  • Name the folder routes.
  • Right-click the new routes folder and select New File.
  • Name the file quote.js.
  • Paste the following code into routes/quote.js:
import { getQuote } from '../services/finnhub.js'

// Fastify plugin that handles quote requests
export default async function quoteRoutes(fastify) {
  fastify.get('/:symbol', async (request, reply) => {
    const { symbol } = request.params
    // Normalize to uppercase so AAPL, aapl, and Aapl all work
    const upperSymbol = symbol.toUpperCase()

    const data = await getQuote(upperSymbol)
    return { source: 'api', data }
  })
}

What does this code do?

This file defines a Fastify plugin with one route.

  • The /:symbol route parameter captures whatever the user puts in the URL (like AAPL or BINANCE:BTCUSDT).
  • toUpperCase() normalizes the symbol so the learner can type it in any case.
  • The source: 'api' field tells the caller this data came fresh from Finnhub. You'll use this later when you add caching.
  • Save routes/quote.js.
  • Check the Explorer sidebar. You should see a routes folder with quote.js inside it.

Seeing a red underline on the import?

VSCodium may show a warning on the import line because services/finnhub.js hasn't been connected to the running server yet. This is normal and will resolve once you wire everything together in the next substep.

help me understand why my import has a red underline

✔️ Awesome, I've got everything!

That's the complete routes/quote.js file for now. You'll update it in a later step to add caching.

ⓧ I'd like to double check the full code

import { getQuote } from '../services/finnhub.js'

export default async function quoteRoutes(fastify) {
  fastify.get('/:symbol', async (request, reply) => {
    const { symbol } = request.params
    const upperSymbol = symbol.toUpperCase()

    const data = await getQuote(upperSymbol)
    return { source: 'api', data }
  })
}
Register the route and test with live data

Your route and service are ready, but Fastify doesn't know about them yet. You need to import and register the quote plugin in server.js.

  • Open server.js and find the existing import lines at the top:
import Fastify from 'fastify'
import fastifyMongodb from '@fastify/mongodb'
  • Add this import directly below them:
import quoteRoutes from './routes/quote.js'
  • Now scroll down to the MongoDB registration block. Find the closing } and ) of this block:
fastify.register(fastifyMongodb, {
  forceClose: true,
  url: process.env.MONGODB_URL
})
  • Add this line directly below it to register your quote routes:
fastify.register(quoteRoutes, { prefix: '/api/quote' })

What does the prefix do?

The prefix: '/api/quote' option means every route inside your plugin gets that path prepended automatically. Your /:symbol route becomes /api/quote/:symbol without you repeating the prefix in the route file.

  • Save server.js.
  • Check your terminal where npm run dev is running. The --watch flag auto-restarts the server. You should see a fresh Server listening at http://127.0.0.1:3000 log line.

Server crashed instead of restarting?

If you see an error about a module not found, double-check that ./routes/quote.js matches the exact path of the file you created. The .js extension is required for ES modules.

If you see a MongoDB connection error, make sure your local MongoDB instance is still running.

help me fix my server crash after adding the quote route

Now test your endpoint with real market data. Before you run this, what do you expect to see in the response for a stock like Apple?

  • Open a new terminal panel in VSCodium by pressing Ctrl+Shift+` (backtick).
  • Fetch a live Apple stock quote by running:
curl http://localhost:3000/api/quote/AAPL

You should see a JSON response with real-time price data from Finnhub. The source field reads api and the data object contains fields like c (current price), h (daily high), l (daily low), o (open), pc (previous close), and d (change).

What do Finnhub's response fields mean?

  • c = current price.
  • d = change (dollar amount).
  • dp = change as a percentage.
  • h = daily high.
  • l = daily low.
  • o = open price.
  • pc = previous close.
  • t = Unix timestamp of the quote.
  • Now try a cryptocurrency symbol by running:
curl http://localhost:3000/api/quote/BINANCE:BTCUSDT

You should see the same JSON structure with Bitcoin's live price data. The same endpoint handles both stocks and crypto because Finnhub uses a unified quote API.

Seeing all zeros in the response?

If every field in data is 0, the symbol might not be recognized by Finnhub. Double-check that you typed AAPL (not APPL). For crypto, use the exchange prefix format like BINANCE:BTCUSDT.

If you see Finnhub API error: 401, your API key is missing or incorrect. Check that .env has your real key (not the placeholder) and that the server was restarted after the change.

help me debug why my Finnhub quote endpoint returns zeros or an error

✔️ Awesome, I've got everything!

Your full server.js should now have three imports at the top and two fastify.register calls. Save it and confirm the server is running.

ⓧ I'd like to double check the full code

import Fastify from 'fastify'
import fastifyMongodb from '@fastify/mongodb'
import quoteRoutes from './routes/quote.js'

const fastify = Fastify({ logger: true })

fastify.register(fastifyMongodb, {
  forceClose: true,
  url: process.env.MONGODB_URL
})

fastify.register(quoteRoutes, { prefix: '/api/quote' })

const start = async () => {
  try {
    await fastify.listen({ port: parseInt(process.env.PORT) || 3000 })
  } catch (err) {
    fastify.log.error(err)
    process.exit(1)
  }
}

start()

You now have live market data flowing through your own API. Hit any stock or crypto symbol and see real prices in milliseconds. In the next step, you'll add a caching layer so repeated requests don't burn through Finnhub's 60 calls per minute rate limit.

Add Rate Limit Protection with Caching

Your quote endpoint works. You can hit /api/quote/AAPL and get live market data flowing through your own server.

There is a problem. Finnhub's free tier allows only 60 API calls per minute. Without protection, every single page refresh or repeated request burns through that quota. Hit the limit and Finnhub returns a 429 error, cutting off your data entirely. In this step, you will add a MongoDB-backed caching layer that serves instant responses and keeps your usage sustainable.

In this step, get ready to:
  • Add a 30-second cache layer to the quote route using MongoDB.
  • Verify that repeated requests return cached data instead of hitting the API.
Add cache logic to the quote route

The entire caching strategy lives inside the route handler. Before calling Finnhub, the handler checks MongoDB for a recent result. If one exists, it returns immediately. If not, it fetches fresh data and stores it for the next request.

  • In VSCodium, open routes/quote.js.
  • Replace the entire file contents with this updated version:
import { getQuote } from '../services/finnhub.js'

export default async function quoteRoutes(fastify) {
  fastify.get('/:symbol', async (request, reply) => {
    const { symbol } = request.params
    const upperSymbol = symbol.toUpperCase()
    const cache = fastify.mongo.db.collection('quote_cache')

    // Check for a cached entry less than 30 seconds old
    const cached = await cache.findOne({
      symbol: upperSymbol,
      cachedAt: { $gt: new Date(Date.now() - 30 * 1000) }
    })
    if (cached) {
      return { source: 'cache', data: cached.data }
    }

    // No cache hit - fetch fresh data from Finnhub
    const data = await getQuote(upperSymbol)

    // Store fresh data in cache for future requests
    await cache.updateOne(
      { symbol: upperSymbol },
      { $set: { symbol: upperSymbol, data, cachedAt: new Date() } },
      { upsert: true }
    )

    return { source: 'api', data }
  })
}

What does this code do?

This route handler now has two paths depending on whether cached data exists:

  • fastify.mongo.db.collection('quote_cache') grabs a reference to the quote_cache collection. MongoDB creates it automatically the first time a document is inserted.
  • The findOne query looks for a document matching the symbol where cachedAt is newer than 30 seconds ago. If found, the route returns immediately with source: 'cache'.
  • updateOne with upsert: true either updates the existing document for that symbol or creates a new one. This prevents duplicate entries piling up in the collection.
  • Save routes/quote.js.
  • Check your terminal where npm run dev is running. The --watch flag automatically restarts the server when you save.

Server crashed on save?

  • Check that fastify.mongo.db is spelled exactly as shown. A typo like fastify.mongodb or fastify.db will throw a reference error.
  • Make sure your local MongoDB is still running. If you stopped it since Step 1, start it again before testing.

Still stuck? Help me debug my quote route cache logic.

Test the caching behavior

Before you run the next command, think about what you expect to see. Will the response include source: "api" or source: "cache" on the first request?

  • Run this command in a separate terminal:
curl http://localhost:3000/api/quote/AAPL

You should see a JSON response with "source":"api" because no cached entry exists yet. The data comes directly from Finnhub.

  • Run the exact same command again within 30 seconds:
curl http://localhost:3000/api/quote/AAPL

This time you should see "source":"cache". The data came from MongoDB instead of burning another Finnhub API call.

Why 30 seconds?

Stock prices change frequently during market hours, so stale data loses value quickly. A 30-second window strikes a balance: short enough to stay reasonably current, long enough to absorb rapid page refreshes without wasting API calls.

With 60 calls per minute and a 30-second cache, you could track up to 60 unique symbols before hitting the rate limit. Without caching, a single user refreshing one ticker every second would exhaust the quota in one minute.

  • Check your server logs in the npm run dev terminal. You should see only one outbound request logged for both curl calls, confirming the second one never reached Finnhub.

Both requests showing source: api?

  • Make sure you ran both curl commands within 30 seconds of each other. After 30 seconds, the cache entry expires and a fresh API call is made.
  • Confirm your MONGODB_URL in .env points to mongodb://localhost:27017/financial-tracker. A wrong database name means the cache writes go somewhere else.
  • Check that your updateOne call includes { upsert: true } as the third argument. Without it, the first insert never happens.

Need more help? Debug why my cache isn't returning on the second request.

✔️ Awesome, I've got everything!

Great. Make sure you have saved routes/quote.js and confirmed the cache behavior with two rapid curl calls.

ⓧ I'd like to double check the full code

Here is the complete routes/quote.js file at this point:

import { getQuote } from '../services/finnhub.js'

export default async function quoteRoutes(fastify) {
  fastify.get('/:symbol', async (request, reply) => {
    const { symbol } = request.params
    const upperSymbol = symbol.toUpperCase()
    const cache = fastify.mongo.db.collection('quote_cache')

    const cached = await cache.findOne({
      symbol: upperSymbol,
      cachedAt: { $gt: new Date(Date.now() - 30 * 1000) }
    })

    if (cached) {
      return { source: 'cache', data: cached.data }
    }

    const data = await getQuote(upperSymbol)

    await cache.updateOne(
      { symbol: upperSymbol },
      { $set: { symbol: upperSymbol, data, cachedAt: new Date() } },
      { upsert: true }
    )

    return { source: 'api', data }
  })
}

Your API is now sustainable. Repeated requests for the same ticker return instantly from MongoDB instead of hammering Finnhub. Next up, you will build a watchlist so users can save the symbols they care about.

Build the Watchlist CRUD Endpoints

Your API can fetch live prices, but right now there is no way for a user to say "these are the symbols I care about." A financial tracker needs a persistent list of tracked assets.

In this step you will build a CRUD watchlist that stores multi-asset symbols (stocks, crypto, forex) in MongoDB. The data persists across server restarts, so a future frontend can load the user's saved symbols on every page visit.

In this step, get ready to:
  • Create a watchlist route file with GET, POST, and DELETE endpoints.
  • Add validation that rejects duplicate symbols and handles missing entries.
  • Register the routes and test the full create/read/delete cycle.
Create the watchlist route file and register it

The watchlist follows the same Fastify plugin pattern you used for quote routes. Each route file exports an async function that receives the Fastify instance and registers its handlers.

  • Create a new file called routes/watchlist.js in your routes folder.
  • Add the following code to set up the GET route that returns all watchlist items:
export default async function watchlistRoutes(fastify) {
  fastify.get('/', async (request, reply) => {
    // Retrieve all saved symbols from the watchlist collection
    const watchlist = fastify.mongo.db.collection('watchlist')
    const items = await watchlist.find({}).toArray()
    return { data: items }
  })
}

What does this code do?

The function signature async function watchlistRoutes(fastify) makes this a Fastify plugin. Fastify automatically passes itself in so you can access the MongoDB connection via fastify.mongo.db.

The GET route queries the watchlist collection and returns every document as an array. If the collection is empty, the response is { data: [] }.

Before you can test this route, you need to register it in server.js.

  • In server.js, find this import line near the top:
import quoteRoutes from './routes/quote.js'
  • Add this import directly below it:
import watchlistRoutes from './routes/watchlist.js'
  • Now find this registration line:
fastify.register(quoteRoutes, { prefix: '/api/quote' })
  • Add this line directly below it:
fastify.register(watchlistRoutes, { prefix: '/api/watchlist' })
  • Save both routes/watchlist.js and server.js. Your server restarts automatically thanks to the --watch flag.
  • Test the empty watchlist by running:
curl http://localhost:3000/api/watchlist

You should see {"data":[]} in your terminal. The empty array confirms the route is connected and MongoDB is responding.

Seeing a 404 or connection error?

  • Check that your import path in server.js is exactly './routes/watchlist.js' with the .js extension. ES modules require the full extension.
  • Confirm the dev server restarted by checking for the Server listening log line in your terminal.
  • Help me debug this
Add a POST route to save symbols

The POST route lets a user add a symbol to their watchlist. It needs to handle three things: normalize the symbol to uppercase, check for duplicates, and store the entry with metadata.

  • In routes/watchlist.js, add the following POST handler below the GET route's closing }) but before the final }:
  fastify.post('/', async (request, reply) => {
    const { symbol, assetType, name } = request.body
    const watchlist = fastify.mongo.db.collection('watchlist')
    const upperSymbol = symbol.toUpperCase()

    // Reject duplicates with a 409 Conflict status
    const existing = await watchlist.findOne({ symbol: upperSymbol })
    if (existing) {
      reply.code(409)
      return { error: 'Symbol already in watchlist' }
    }

    const item = {
      symbol: upperSymbol,
      assetType: assetType || 'stock',
      name: name || upperSymbol,
      addedAt: new Date()
    }

    await watchlist.insertOne(item)
    reply.code(201)
    return { data: item }
  })

What does this code do?

  • The route destructures symbol, assetType, and name from the request body. Only symbol is required.
  • It normalizes the symbol to uppercase so aapl and AAPL are treated as the same entry.
  • If the symbol already exists, it returns a 409 status (Conflict) instead of creating a duplicate.
  • Default values (assetType: 'stock', name: upperSymbol) keep the API easy to use for quick additions.
  • Save routes/watchlist.js.
  • Add Apple to your watchlist by running:
curl -X POST http://localhost:3000/api/watchlist -H "Content-Type: application/json" -d '{"symbol": "AAPL", "assetType": "stock", "name": "Apple Inc"}'

You should see a 201 response with your new item, including the addedAt timestamp MongoDB stored.

  • Confirm it persisted by running the GET request again:
curl http://localhost:3000/api/watchlist

The data array now contains your Apple entry.

  • Test duplicate rejection by running the same POST command again:
curl -X POST http://localhost:3000/api/watchlist -H "Content-Type: application/json" -d '{"symbol": "AAPL", "assetType": "stock", "name": "Apple Inc"}'

This time you see {"error":"Symbol already in watchlist"} with a 409 status. No duplicate was created.

Seeing a parse error on the POST?

  • On Windows, single quotes around the JSON body may not work in PowerShell. Use escaped double quotes instead: -d "{\"symbol\": \"AAPL\"}".
  • Make sure the Content-Type: application/json header is included. Without it, Fastify does not parse the body.
  • Help me troubleshoot this
Add a DELETE route and test the full cycle

The final piece is removing symbols. The DELETE route needs to handle the case where a user tries to delete something that is not in their watchlist.

  • In routes/watchlist.js, add the DELETE handler below the POST route's closing }) but before the final }:
  fastify.delete('/:symbol', async (request, reply) => {
    const { symbol } = request.params
    const watchlist = fastify.mongo.db.collection('watchlist')
    const upperSymbol = symbol.toUpperCase()

    // Attempt to remove the symbol
    const result = await watchlist.deleteOne({ symbol: upperSymbol })
    if (result.deletedCount === 0) {
      reply.code(404)
      return { error: 'Symbol not found in watchlist' }
    }

    return { message: `${upperSymbol} removed from watchlist` }
  })

What does this code do?

The deleteOne method returns a result object with a deletedCount property. If no document matched the symbol, deletedCount is 0 and the route responds with 404.

This prevents silent no-ops. The user always knows whether the deletion actually happened.

  • Save routes/watchlist.js.

Before you run the final test, think about what you expect to happen. If you DELETE the AAPL entry you added earlier, what should a follow-up GET return?

  • Remove Apple from the watchlist by running:
curl -X DELETE http://localhost:3000/api/watchlist/AAPL

You see {"message":"AAPL removed from watchlist"}.

  • Confirm it is gone by running:
curl http://localhost:3000/api/watchlist

The response is {"data":[]} again. Your full create/read/delete cycle works.

  • Test the 404 case by trying to delete a symbol that does not exist:
curl -X DELETE http://localhost:3000/api/watchlist/MSFT

You see {"error":"Symbol not found in watchlist"} with a 404 status. Both error paths are covered.

DELETE returning 404 even for a symbol you just added?

  • Confirm the symbol is still in the collection by running the GET request first. Your earlier test may have already removed it.
  • Check that your DELETE URL uses the symbol directly in the path (/api/watchlist/AAPL) with no trailing slash.
  • Help me debug this

✔️ Awesome, I've got everything!

Great. Double check you have saved both routes/watchlist.js and server.js.

ⓧ I'd like to double check the full code

Your routes/watchlist.js should contain three handlers in this order:

  • The GET / handler that retrieves all items.
  • The POST / handler with duplicate checking and 201 response.
  • The DELETE /:symbol handler with 404 not-found check.

Scroll up and confirm each chunk is present in your file, in that order.

Here is the complete server.js for this step:

import Fastify from 'fastify'
import fastifyMongodb from '@fastify/mongodb'
import quoteRoutes from './routes/quote.js'
import watchlistRoutes from './routes/watchlist.js'

const fastify = Fastify({ logger: true })

fastify.register(fastifyMongodb, {
  forceClose: true,
  url: process.env.MONGODB_URL
})

fastify.register(quoteRoutes, { prefix: '/api/quote' })
fastify.register(watchlistRoutes, { prefix: '/api/watchlist' })

const start = async () => {
  try {
    await fastify.listen({ port: parseInt(process.env.PORT) || 3000 })
  } catch (err) {
    fastify.log.error(err)
    process.exit(1)
  }
}

start()

Your watchlist is storing, retrieving, and removing symbols with proper error handling. Next up, you will add a news feed endpoint to give context to the price movements your tracker surfaces.

Add the News Feed Endpoint

Your tracker serves live prices and lets users save symbols to a watchlist. But price data alone does not tell the story.

News moves markets. In this step, you will add a news endpoint that surfaces both general market news and company-specific news. A future frontend can use this to show why a stock moved, not just that it moved.

In this step, get ready to:
  • Add market news and company news functions to the Finnhub service.
  • Create a news route with 5-minute caching for both general and symbol-specific news.
  • Register the new routes and confirm news flows through your API.
Add news functions to the Finnhub service

Finnhub exposes two news endpoints: one for general market news by category, and one for company-specific news filtered by date range. You need a function for each.

  • In services/finnhub.js, find the closing } of the getQuote function and add these two new functions below it:
export async function getMarketNews(category = 'general') {
  // Call Finnhub's market news endpoint with a category filter
  const url = `${FINNHUB_BASE_URL}/news?category=${encodeURIComponent(category)}&token=${process.env.FINNHUB_API_KEY}`
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`Finnhub API error: ${response.status}`)
  }
  return response.json()
}

export async function getCompanyNews(symbol, from, to) {
  // Call Finnhub's company news endpoint with a date range
  const url = `${FINNHUB_BASE_URL}/company-news?symbol=${encodeURIComponent(symbol)}&from=${from}&to=${to}&token=${process.env.FINNHUB_API_KEY}`
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`Finnhub API error: ${response.status}`)
  }
  return response.json()
}

What do these functions do?

Both follow the same pattern as getQuote. They build a URL with your API key, call fetch(), and return the parsed JSON.

  • getMarketNews defaults to the general category but accepts any Finnhub news category (e.g. forex, crypto).
  • getCompanyNews takes a symbol plus from and to date strings so you can request a specific window of articles.
  • Save services/finnhub.js.
  • Check your terminal. Because --watch is running, the server should restart without errors.

Server crashed on restart?

  • Check for missing backticks in the template literal URLs. Each URL uses ${FINNHUB_BASE_URL} wrapped in backticks.
  • Confirm you added both functions after the existing getQuote function, not inside it.

Why did my server crash after adding the news functions?

Create the market news route

The news route file will hold two endpoints. You will start with the general market news route, wire it into the server, and confirm it works before adding the company-specific route.

  • Create a new file called routes/news.js and add this code:
import { getMarketNews, getCompanyNews } from '../services/finnhub.js'

export default async function newsRoutes(fastify) {
  fastify.get('/', async (request, reply) => {
    const { category } = request.query
    const cache = fastify.mongo.db.collection('news_cache')
    // Default to 'general' if no category query param
    const cacheKey = category || 'general'

    // Check for cached news less than 5 minutes old
    const cached = await cache.findOne({
      key: cacheKey,
      cachedAt: { $gt: new Date(Date.now() - 5 * 60 * 1000) }
    })

    if (cached) {
      return { source: 'cache', data: cached.data }
    }

    const data = await getMarketNews(cacheKey)

    // Upsert fresh news into the cache
    await cache.updateOne(
      { key: cacheKey },
      { $set: { key: cacheKey, data, cachedAt: new Date() } },
      { upsert: true }
    )

    return { source: 'api', data }
  })
}

What does this route do?

  • The GET / handler reads an optional ?category= query parameter from the request URL.
  • It checks the news_cache collection for an entry younger than 5 minutes. News articles do not go stale in seconds like prices do, so a longer TTL is appropriate.
  • If no cache hit, it calls Finnhub, stores the response, and returns it with "source": "api".

Now register this route in your server so Fastify knows about it.

  • In server.js, find this import line:
import watchlistRoutes from './routes/watchlist.js'
  • Add this import directly below it:
import newsRoutes from './routes/news.js'
  • Then find this registration line:
fastify.register(watchlistRoutes, { prefix: '/api/watchlist' })
  • Add this registration directly below it:
fastify.register(newsRoutes, { prefix: '/api/news' })
  • Save server.js.

Before you run this, what do you expect to see when you hit /api/news? Think about whether it will return cached data or fresh data on the first request.

  • Test the market news endpoint by running:
curl http://localhost:3000/api/news

You should see a JSON response with "source": "api" and a data array containing news articles. Each article includes fields like headline, source, summary, and url.

Getting an empty array or error?

  • An empty data: [] array is normal outside of US market hours. Finnhub may return fewer articles when markets are closed.
  • If you see a 500 error, check your terminal logs. The most common cause is a typo in the Finnhub URL template inside getMarketNews.
  • Confirm routes/news.js is inside the routes/ folder, not in the project root.

Why is my news endpoint returning an error or empty data?

Add company-specific news

General market news is useful, but a tracker user wants news about the specific stocks they follow. The second route fetches news for a single company from the last 7 days.

  • In routes/news.js, find the closing }) of the first fastify.get('/', ...) handler. Add this second route directly below it, still inside the newsRoutes function:
  fastify.get('/:symbol', async (request, reply) => {
    const { symbol } = request.params
    const cache = fastify.mongo.db.collection('news_cache')
    const upperSymbol = symbol.toUpperCase()

    // Calculate date range: today minus 7 days
    const today = new Date()
    const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
    const from = weekAgo.toISOString().split('T')[0]
    const to = today.toISOString().split('T')[0]

    // Use a company-prefixed cache key
    const cacheKey = `company-${upperSymbol}`
    const cached = await cache.findOne({
      key: cacheKey,
      cachedAt: { $gt: new Date(Date.now() - 5 * 60 * 1000) }
    })

    if (cached) {
      return { source: 'cache', data: cached.data }
    }

    const data = await getCompanyNews(upperSymbol, from, to)

    await cache.updateOne(
      { key: cacheKey },
      { $set: { key: cacheKey, data, cachedAt: new Date() } },
      { upsert: true }
    )

    return { source: 'api', data }
  })

What does this route do?

  • It calculates from and to dates as ISO strings (YYYY-MM-DD) spanning the last 7 days. Finnhub's company news endpoint requires this date range.
  • The cache key is prefixed with company- so it does not collide with general news cache entries that use plain category names.
  • The same 5-minute TTL applies. Company news does not change by the second, so caching avoids burning API calls on repeated lookups.
  • Save routes/news.js.

What do you think will happen when you request news for AAPL? Will it come from cache or from the API?

  • Test the company news endpoint by running:
curl http://localhost:3000/api/news/AAPL

You should see "source": "api" and a data array with Apple-specific news articles from the past week.

  • Run the same command again within 5 minutes:
curl http://localhost:3000/api/news/AAPL

This time you should see "source": "cache" confirming the 5-minute caching works.

Seeing an empty array for company news?

  • Some smaller or non-US tickers have no recent news. Try AAPL, TSLA, or MSFT for reliable results.
  • Make sure your route uses /:symbol (with the colon) in fastify.get. Without the colon, Fastify treats it as a literal path segment.

Why is my company news endpoint returning an empty array or not matching the symbol?

✔️ Awesome, I've got everything!

Great. Double check you have saved both services/finnhub.js and routes/news.js.

ⓧ I'd like to double check the full code

Here is the full services/finnhub.js with all three functions:

const FINNHUB_BASE_URL = 'https://finnhub.io/api/v1'

export async function getQuote(symbol) {
  const url = `${FINNHUB_BASE_URL}/quote?symbol=${encodeURIComponent(symbol)}&token=${process.env.FINNHUB_API_KEY}`
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`Finnhub API error: ${response.status}`)
  }
  return response.json()
}

export async function getMarketNews(category = 'general') {
  const url = `${FINNHUB_BASE_URL}/news?category=${encodeURIComponent(category)}&token=${process.env.FINNHUB_API_KEY}`
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`Finnhub API error: ${response.status}`)
  }
  return response.json()
}

export async function getCompanyNews(symbol, from, to) {
  const url = `${FINNHUB_BASE_URL}/company-news?symbol=${encodeURIComponent(symbol)}&from=${from}&to=${to}&token=${process.env.FINNHUB_API_KEY}`
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`Finnhub API error: ${response.status}`)
  }
  return response.json()
}

Here is the full server.js:

import Fastify from 'fastify'
import fastifyMongodb from '@fastify/mongodb'
import quoteRoutes from './routes/quote.js'
import watchlistRoutes from './routes/watchlist.js'
import newsRoutes from './routes/news.js'

const fastify = Fastify({ logger: true })

fastify.register(fastifyMongodb, {
  forceClose: true,
  url: process.env.MONGODB_URL
})

fastify.register(quoteRoutes, { prefix: '/api/quote' })
fastify.register(watchlistRoutes, { prefix: '/api/watchlist' })
fastify.register(newsRoutes, { prefix: '/api/news' })

const start = async () => {
  try {
    await fastify.listen({ port: parseInt(process.env.PORT) || 3000 })
  } catch (err) {
    fastify.log.error(err)
    process.exit(1)
  }
}

start()

For routes/news.js, scroll up and confirm you have both route handlers in order:

  • The import line for getMarketNews and getCompanyNews at the top.
  • The fastify.get('/', ...) handler for general market news with 5-minute caching.
  • The fastify.get('/:symbol', ...) handler for company news with a 7-day date range and the same 5-minute cache.
  • Both handlers are inside the newsRoutes function's closing }.

Your API now serves live prices, a persistent watchlist, and a full news feed. That is everything a frontend dashboard needs to show users what is happening in the market and why.

Secret mission

Provider Abstraction Pattern

Finnhub's free tier will eventually limit you. What if swapping to Alpha Vantage, Polygon.io, or any paid API required changing one environment variable and writing one adapter file instead of rewriting every route? Build a provider pattern that makes your data source pluggable.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a free Finnhub API key, so there are no ongoing costs.

Resources you used:

  • MongoDB database financial-tracker with collections: quote_cache, news_cache, watchlist.
  • Local project folder containing all source files and node_modules.
  • Finnhub API key (free, no charges).

Keep everything running

No action needed. Choose this if you are still actively building or plan to connect a frontend to this API soon.

  • MongoDB stores your data locally. There is no ongoing cost.
  • Your Finnhub API key remains active with no charges.
  • Run npm run dev from your project folder any time you want to restart the server.

Pause - I'll come back to this later

Stop the Fastify server to free up your terminal. Your data persists in MongoDB until you are ready to come back.

  • Press Ctrl+C in the terminal where the server is running to stop Fastify.
  • Your watchlist items, cached quotes, and cached news remain in MongoDB.
  • To resume later, navigate back to your project folder and run npm run dev.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each item below to clean up completely.

  • Stop the Fastify server by pressing Ctrl+C in the terminal if it is still running.
  • Drop the MongoDB database by running these commands in your terminal:
mongosh --eval 'use("financial-tracker")' --eval 'db.dropDatabase()'
  • Delete the project folder. On macOS or Linux, run:
rm -rf ~/Desktop/financial-tracker-api
  • On Windows, run:
Remove-Item -Recurse -Force ~\Desktop\financial-tracker-api
  • Your Finnhub API key remains active with no charges. No action is needed for it.

Nice Work!

Nice Work!

Nice work! You just built a production-ready financial tracker API from scratch, complete with live market data, intelligent caching, and a persistent watchlist.

You've learned how to:

  • Build a Fastify REST API with structured route plugins and a dedicated service layer that serves real-time stock, crypto, and forex data from Finnhub.
  • Implement server-side caching in MongoDB to stay within a 60 calls/min rate limit, returning instant responses for repeated requests.
  • Create a persistent multi-asset watchlist with full CRUD operations and a news feed endpoint with both general market and company-specific filtering.
  • Secret Mission: Abstracted the data layer into a swappable provider pattern so upgrading to a paid API requires changing one environment variable and writing one adapter file.

Ready to quiz yourself?