TEJHQ
All posts
·8 min read·TejHQ

API keys, two tiers, and the cache bug that shipped with them

TejHQ now has sign-in by email, hashed API keys, a free tier with real quotas, and a Pro tier with metrics, batch and resolve. How the auth is built, the edge-cache leak we caught before launch, and why there is no billing page.

#api#auth#pro#engineering#release

Until this month api.tejhq.dev had exactly one kind of caller: anonymous. That is still true for OHLCV, snapshots and corporate actions, and it will stay true. But the derived data has been sitting on R2 for months with no API in front of it, and an API that computes 52-week highs across 4,500 symbols cannot be anonymous and free at the same time. So: keys.

This is the build log for the auth layer, the two tiers it gates, the three Pro endpoints that went live with it, and the bug we found the day before launch.

Sign-in with no password

Go to tejhq.dev/keys, type an email, click the link. The link is single-use and expires in 15 minutes. On the other side you get one API key, shown once. We store the SHA-256 of it and the first few characters as a prefix so you can tell your keys apart in /v1/me. If you lose it, revoke it and make another.

key-format.txt
tej_live_ + 32 hex characters

# stored:   sha256(key), prefix "tej_live_af9"
# shown:    once, on the verify page, never again

The store is Postgres on Neon, Singapore region. Four tables: users, magic links, keys, daily usage. Email goes out through Resend. Neither of these is on the request path for a keyless call, which is the property we care about most: if Neon is down, the free tier does not know.

Two tiers, enforced in one middleware

A key resolves to a principal with a tier. The tier picks a limit set. Daily quota is counted in Postgres; the per-minute limit is an in-process token bucket per key, because a round trip to the database on every request would be the slowest thing in the stack.

middleware.go
var DefaultLimits = map[string]Limits{
    TierFree: {PerDay: 1000,   PerMinute: 300,  Burst: 60},
    TierPro:  {PerDay: 500000, PerMinute: 3000, Burst: 300},
}

// Authenticator resolves bearer keys with a short in-memory cache so a hot
// key costs one Postgres round trip per minute per instance, not per request.

Free key unlocks /v1/adjusted (back-adjusted prices) and /v1/symbols (symbol history by ISIN). Pro unlocks the rest. Every keyed response carries X-RateLimit-Limit-Day and X-RateLimit-Remaining-Day, and a 429 tells you which limit you hit and when to retry.

The cache bug

The free tier is served through a Cloudflare Worker with the edge cache turned on, keyed by URL. That was fine when every caller got the same bytes for the same URL. It stopped being fine the moment a URL could return different bytes depending on the Authorization header.

The failure we caught in testing: request /v1/adjusted/nse/RELIANCE with a valid key, get a 200 with a public cache header, and the edge stores it. The next keyless caller to the same URL gets the Pro response for free. Worse in the other direction: a 401 for a bad key, cached, served to the next caller with a good one.

Two fixes, both required. The origin marks every keyed response Cache-Control: private, no-store and every error no-store, so even a misconfigured proxy will not keep them. And the Worker bypasses the edge cache outright for any request that carries a key or is not a GET:

index.js
// The edge cache is keyed by URL only. Never cache keyed or non-GET
// traffic, or one caller's private response would be served to others.
const keyed = request.headers.has('authorization') || request.headers.has('x-api-key');
if (keyed || request.method !== 'GET') {
  return fetch(proxied, { cf: { cacheEverything: false, cacheTtl: 0 } });
}
return fetch(proxied, { cf: { cacheEverything: true } });

Belt and braces, because the cost of getting this wrong is someone else's quota, or someone else's data.

Three Pro endpoints, then a fourth

A screener, /v1/screener, shipped the same afternoon this went up: the whole market on one day filtered by any of the metrics fields, with the point-in-time universe as an optional restriction. It is in the API reference and both SDKs; the three below are the ones this post was written around.

/v1/metrics

The nightly metrics tree, per symbol, over a date range. Returns and rolling stats on adjusted close, volume and turnover on raw:

metrics-columns.txt
ret_1d  ret_5d  ret_21d  ret_63d  ret_126d  ret_252d  ret_ytd
high_52w  low_52w  pct_off_52w_high  pct_off_52w_low
avg_vol_20d  avg_vol_60d  avg_turnover_20d

/v1/batch

OHLCV for up to 50 symbols in one request, one DuckDB scan instead of fifty. The response is an object keyed by symbol; unknown symbols map to an empty list rather than an error, so a stale watchlist does not fail the whole call.

curl.sh
$ curl -s -H "Authorization: Bearer tej_live_..." \
    "https://api.tejhq.dev/v1/batch?exchange=nse&symbols=RELIANCE,TCS,INFY&from=2026-08-01&to=2026-08-31" \
    | jq '.data | map_values(length)'
{
  "INFY": 20,
  "RELIANCE": 20,
  "TCS": 20
}

/v1/resolve

Free text to symbol. "reliance industries" to RELIANCE, "hdfc bank" to HDFCBANK, and, because the symbol-history tree knows about renames, an old ticker to the symbol its ISIN trades under today. Matching is layered: exact symbol, former symbol of the same ISIN, symbol prefix, exact cleaned name, then Jaro-Winkler on the cleaned name. The index is built from the latest trading day's rows and refreshed hourly.

resolve.go
var nameNoise = regexp.MustCompile(
    `\b(LIMITED|LTD|LTD\.|PVT|PRIVATE|CO|COMPANY|CORP|CORPORATION|INC|THE|OF|AND|&)\b`,
)

// "Reliance Industries Limited" -> "RELIANCE INDUSTRIES"
// "HDFC Bank Ltd."              -> "HDFC BANK"

All of it in the Python SDK

tejhq 0.2.0 on PyPI adds every new endpoint to both the sync and async clients, still with zero runtime dependencies:

sdk.py
from tej import Client

c = Client(api_key="tej_live_...")

c.adjusted("RELIANCE", "nse", "2024-10-01", "2024-11-30")   # free key
c.symbols("nse", isin="INE040A01034")                        # free key
c.me()                                                        # keys and today's usage

c.universe("liquid500", "nse", as_of="2012-06-15")           # pro
c.metrics("RELIANCE", "nse", "2026-01-01", "2026-08-31")     # pro
c.batch(["RELIANCE", "TCS", "INFY"], "nse")                  # pro
c.resolve("reliance industries")                             # pro

A 401 raises AuthError with the same error_code the API returns, key_required or invalid_key, so you can tell a missing key from a revoked one without parsing a message.

Why there is no billing page

Pro is ₹4,999 a month and there is no card form. You email support@tejhq.dev from the address you signed in with, say what you are building, and we flip your tier by hand. This is deliberate. We are early enough that every Pro user is a conversation we want to have, and wiring up a payment provider before we know what people actually pull would mean building the wrong thing carefully. The manual path also lets us say yes to students and open-source projects without a coupon system.

What is unchanged: the keyless tier, the parquet on HuggingFace, the MIT licence on every repo. If you only ever wanted OHLCV, nothing in this post applies to you and nothing got slower.