Skip to main content
APIs APIs Markets

Build a stock quote API

A quote API is a small contract: one symbol in, one documented price out, with the time the price was true. It is not an order book, and it must not pretend a cached number is a trade.

LesterTechLabs Engineering Estimated 4 min read

A quote API is a small contract: one symbol in, one documented price out, with the time the price was true. It is not an order book, and it must not pretend a cached number is a trade.

Document the response before the client exists

Pick one resource, a quote, and give it a stable set of fields. Symbol, price, currency, and the timestamp the provider assigned are enough for a first release. Add a fresh flag when you are allowed to serve a recent copy during an outage. Leave bids, depth, and accounts out until a client has a reason to read them.

Write the errors down in the same document. An unknown symbol is a 404 with a code the client can branch on. A provider that did not answer in time is a 503, not a 200 with an empty price. A 200 that means “we are not sure” will be cached by someone else’s proxy and shown as a real price.

Version the path. /api/v1/quotes/SCOM/ can gain fields. It cannot rename price to last without a new version. Clients in a mobile app will keep the old path until someone ships an update.

Isolate the provider behind a timeout

The view should call your own function, not the vendor’s SDK, and that function should give up. Two seconds is a long time to hold a web worker for a price. Record the provider’s symbol mapping in one place, because the symbol your customer types is not always the symbol the vendor lists.

Treat the vendor payload as untrusted. Read the fields you documented and ignore the rest. If the price is missing or not a number, raise your own error. Do not pass the vendor’s JSON through, or the day they add a nested object your clients will start parsing it by accident.

Keep credentials in the environment of the process that calls the vendor. The quote view does not need the key if a separate client object already has it. Log the symbol and the latency. Do not log the key or the full vendor body.

Cache the quote, not the customer

The same symbol is requested together when a market moves. Cache the public quote for a few seconds so a hundred refreshes become one vendor call. Include the symbol and the payload version in the key. Do not include the user. A personalised watchlist is a different endpoint and should check that the caller is allowed to see those symbols.

When the vendor fails and you still hold a quote from the last fifteen seconds, you can return it with fresh set to false. When you hold nothing, return the 503. Never invent a price to keep the chart looking alive.

Tests should not call the vendor. Pass a fake loader that returns a fixed quote, and a second fake that raises. Assert the status code, the field names, and that two calls with the same symbol inside the TTL hit the loader once.

The view and the vendor boundary

The view knows the HTTP contract. The client knows the vendor. Neither reaches into the other. Price is a string of decimal digits so a float never rounds a quoted price on the way through JSON.

Python · quotes/views.py

from decimal import Decimal

from django.http import JsonResponse
from django.views import View

from quotes.cache import get_or_load, quote_key
from quotes.providers import ProviderError, fetch_quote


class QuoteView(View):
    def get(self, request, symbol: str):
        key = quote_key(symbol)
        try:
            quote = get_or_load(key, lambda: fetch_quote(symbol), ttl=15)
        except ProviderError:
            return JsonResponse(
                {
                    "error": "provider_unavailable",
                    "detail": "The quote provider did not answer in time.",
                },
                status=503,
            )
        if quote is None:
            return JsonResponse(
                {"error": "unknown_symbol", "detail": "No listing for that symbol."},
                status=404,
            )
        return JsonResponse(
            {
                "symbol": quote["symbol"],
                "price": str(Decimal(quote["price"])),
                "currency": quote["currency"],
                "as_of": quote["as_of"],
                "fresh": True,
            }
        )

A provider call that is allowed to fail

The timeout is part of the design. A vendor that hangs must not pin your workers. This client returns your own dict, or raises ProviderError, which the view already knows how to turn into a 503.

Python · quotes/providers.py

import json
import os
from urllib.error import URLError
from urllib.request import Request, urlopen

class ProviderError(Exception):
    pass

def fetch_quote(symbol: str) -> dict | None:
    request = Request(
        f"https://quotes.example.com/v1/last?symbol={symbol}",
        headers={"Authorization": f"Bearer {os.environ['QUOTE_PROVIDER_TOKEN']}"},
    )
    try:
        with urlopen(request, timeout=2) as response:
            payload = json.load(response)
    except (URLError, TimeoutError, json.JSONDecodeError) as exc:
        raise ProviderError("quote provider did not answer") from exc
    price = payload.get("price")
    if not price:
        return None
    return {
        "symbol": symbol.upper(),
        "price": price,
        "currency": payload.get("currency", "KES"),
        "as_of": payload.get("as_of"),
    }

Before a client binds to the path

  1. Publish the fields, the 404, and the 503 in the same note as the URL.
  2. Put a timeout on the vendor call and map a failure to your own error.
  3. Cache the public quote for seconds, and test the loader with a fake.

On the site

APIs

Need an API the rest of the product can trust?

We design contracts, webhooks, and payment integrations that survive real traffic and real retries.