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.
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.
tej_live_ + 32 hex characters
# stored: sha256(key), prefix "tej_live_af9"
# shown: once, on the verify page, never againThe 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.
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:
// 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:
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 -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.
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:
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") # proA 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.