Accounts, API keys & rate limiting (chantier 2) — guide for the web team
Backend modules: hfmarketdata/api/accounts/ (users, keys, auth, dashboard API, admin, CLI) and
hfmarketdata/api/ratelimit/ (tiers, Redis sliding windows, ASGI middleware, usage accounting).
Everything below is additive under /v1/; the legacy endpoints are unchanged. The OpenAPI document
(/openapi.json) carries the full description of every endpoint (summary, examples, x-errors).
1. Tiers and costs
| tier | who | window | requests | rows | max rows / request |
|---|---|---|---|---|---|
keyless |
per IP (first hop of X-Forwarded-For, hashed) |
1 h | 30 | 100 000 | 5 000 |
free |
per API key | 1 min | 120 | 1 000 000 | 50 000 |
high_usage |
per API key (on request to contact@spboucher.ai) | 1 min | 600 | 10 000 000 | 200 000 |
Costs: every request costs 1 (screener/frames: 2 — request.state.request_cost = 2); every returned data
row costs 1, Parquet costs ½ row (ceil(rows/2)), bulk endpoints (request.state.quota_exempt) and
HTTP 304 cost 0 rows. Errors (4xx/5xx) cost the request but no rows. /v1/limits, /v1/me/* and
/v1/admin/* are never charged. Keyless principals get 401 AUTH_REQUIRED on endpoints tagged
stream/screener (and on any handler that sets request.state.requires_key = True).
GET /v1/limits (public, free) returns the table above plus the caller's live counters — use it for the
pricing page and the playground banner. The constants live in ratelimit/tiers.py and are also exported
as info.x-tiers in the OpenAPI document.
2. Headers on every /v1/* response
X-RateLimit-Limit-Requests: 120 requests allowed in the window
X-RateLimit-Remaining-Requests: 118 after this response
X-RateLimit-Limit-Rows: 1000000
X-RateLimit-Remaining-Rows: 997512
X-RateLimit-Reset: 1788012345 unix seconds when the oldest bucket of the sliding window expires
X-Row-Count: 2488 rows in THIS response (set by frame_response / legacy endpoints)Sliding window with 1-second buckets: Reset is the moment the oldest counted second leaves the window
(i.e. when capacity starts coming back), not a fixed calendar boundary. All headers are exposed through CORS.
When Redis is unreachable the API fails open: no X-RateLimit-* headers, request served, warning logged.
429 response
HTTP/1.1 429 Too Many Requests
Retry-After: 41
X-RateLimit-Remaining-Requests: 0
{"error": {"code": "RATE_LIMIT_EXCEEDED",
"type": "requests_per_hour", # requests_per_hour | rows_per_hour | requests_per_minute | rows_per_minute
"message": "Rate limit exceeded: 30 requests per hour for keyless (per IP). Retry in 41 s. Create a free account at https://www.hfmarketdata.io/signup for 120 requests/min and 1,000,000 rows/min with an API key.",
"docs": "https://www.hfmarketdata.io/docs/errors#rate_limit_exceeded",
"details": {"limit": 30, "window_seconds": 3600, "reset": 1788012345, "tier": "keyless"}},
"detail": "Rate limit exceeded: …"}/v1/auth/* has its own throttle: 10 requests / hour / IP (all auth endpoints except logout) → same
envelope, type: requests_per_hour, message "Too many authentication attempts…".
Other quota-related errors: 400 ROW_LIMIT_EXCEEDED when limit exceeds the tier's max rows per request
(details.max_rows), 401 INVALID_API_KEY (unknown/revoked key — the request is NOT downgraded to keyless),
403 ACCOUNT_DISABLED, 401 AUTH_REQUIRED.
The site's own requests (/charts) have no quota — principal site
The charts page must work without an account and without any limit. The middleware recognises the
site's own data requests and takes them out of the quota system entirely (ratelimit/middleware.py,
is_site_request). A request is the site's own when all three hold, on a GET of a charts data path
(/v1/bars/*, /v1/futures/*, /v1/{asset}/tickers, /v1/options/*):
X-HFMD-Client: charts(orweb) — set by the page's data layer (web/src/charts/data/*.js);Sec-Fetch-Site: same-origin(orsame-site) — set by the browser, never by afetch()from another origin;Origin(orReferer) whose host is the host ofHFMD_PUBLIC_URL(with/withoutwww.);localhost,127.0.0.1,::1are also accepted whenHFMD_ENV != production.
A Bearer key / ?api_key= always wins (the caller asked for its own principal). Anything short of the three
criteria — a curl, a third-party site, a missing header — is a regular keyless / key / session request.
What changes for a site request:
- not counted, not row-charged, never 429 for quota reasons; no
X-RateLimit-*header at all; request.state.principal = "site",principal_kind = "site",max_rows = 200 000(tiers.SITE_MAX_ROWS; the page asks for 5 000–20 000 bars at once; the JSON ceiling of the legacy bars endpoint, 50 000, still applies throughbound_limit);- usage is recorded under the
siteprincipal for internal statistics (requests, rows, bytes) — never under the visitor's IP, whose keyless counters stay untouched; - the only guard is a DoS protection, not a product limit:
tiers.SITE_BURST_PER_MINUTE_PER_IP= 1 200 requests per minute per IP on this path (Redis sliding window, fail-open). Above it the API answers429 RATE_LIMIT_EXCEEDEDwith a dedicated message ("an abuse guard, not a quota: the charts have no data limit"),Retry-After,details.guard = "site_burst"and noX-RateLimit-*header. A human on the page cannot reach it (a full 1-minute history load is ~10 requests).
site is not a tier: it is absent from /v1/limits (which, not being a data path, still answers as keyless /
key / session). Tests: tests/test_ratelimit_site.py.
3. Authentication model
- API key:
Authorization: Bearer hfmd_live_…or?api_key=…— for data endpoints and programmatic access to/v1/me*. Formathfmd_live_+ 32 base62 chars; onlysha256(salt+key)and the display prefix (hfmd_live_ab12cd34) are stored; shown once at creation. Lookups are cached 60 s per worker; revoke/rotate/tier changes invalidate the cache immediately on the worker that performed them. - Session cookie
hfmd_session(itsdangerous-signed, HttpOnly, SameSite=Lax, Secure in production, 30 days) — for the web dashboard. Set byverify,login,reset,accept-invite; cleared bylogout. - CSRF: every POST/PATCH/DELETE on
/v1/auth,/v1/me,/v1/adminmust sendContent-Type: application/json(use{}as body when there is nothing to send) → otherwise415 UNSUPPORTED_MEDIA_TYPE. Cookies are never sent cross-origin (CORS without credentials).
4. Flows (what the SPA must implement)
Self-service signup
POST /v1/auth/signup {email, name, password}→202 {"status":"verification_sent"}(or"invitation_sent"if the address had been invited by an admin). Password ≥ 10 chars (400 WEAK_PASSWORD), existing verified account →409 EMAIL_TAKEN.- The e-mail links to
/verify?token=…(SPA route). The page callsGET /v1/auth/verify?token=…→200profile + session cookie → redirect to/dashboard/keysand prompt to create the first key.400 INVALID_TOKEN= expired (48 h) or already used → offer "sign up again" (re-sends). POST /v1/auth/login {email, password}→200profile + cookie. Errors:401 INVALID_CREDENTIALS,403 EMAIL_NOT_VERIFIED,403 ACCOUNT_DISABLED.POST /v1/auth/logout {}.
Password reset
POST /v1/auth/forgot {email} → always 202. Link → /reset-password?token=…; page posts
POST /v1/auth/reset {token, password} → 200 + session. Reset tokens last 1 hour.
Invitation (admin / CLI created users)
Link → /accept-invite?token=…; page posts POST /v1/auth/accept-invite {token, password} →
200 + session; the account becomes active, e-mail counts as verified, and an API key already exists
(created at invitation, visible by prefix under /v1/me/keys; the user should rotate or create a new one to
obtain a full key). Invitation tokens last 7 days.
Dashboard
GET /v1/me→{user:{…, keys_active}, limits:{tier, window_seconds, requests, rows, max_rows_per_request, upgrade}}GET /v1/me/keys·POST /v1/me/keys {name}(201,data.key= full key, once) ·DELETE /v1/me/keys/{id}·POST /v1/me/keys/{id}/rotate {}(201, newdata.key,rotated_from). Max 10 active keys (409 KEY_LIMIT_REACHED),404 KEY_NOT_FOUND.GET /v1/me/usage?range=24h|7d|30d→{range, step_seconds, from, to, points:[{t, requests, rows}], totals, principals}(per minute / per hour / per day; UTC; includes the live minute).- Playground with key injected: read
X-RateLimit-*from every response for the quota widget.
Admin (role: admin)
GET /v1/admin/users?search=&tier=&status=&role=&limit=&cursor=·POST /v1/admin/users(invite when nopassword; response haskey.prefixandinvitation.{delivered, link?}) ·GET/PATCH /v1/admin/users/{id}(tier,role,status,name) ·POST …/{id}/invite {}·POST …/{id}/reset-password {}·POST …/{id}/keys {name, tier_override?}(full key once) ·DELETE …/{id}/keys/{kid}.GET /v1/admin/usage?days=30&top=20→per_day[]+top[](principals mapped to users).GET /v1/admin/audit?limit=&cursor=&action=(newest first).
In development (HFMD_ENV != production and no Resend key) auth responses include debug_link so the
flows can be tested without a mailbox. Never in production.
5. E-mail (Resend)
accounts/mailer.py posts to https://api.resend.com/emails with httpx. Templates (English, text + minimal
HTML): verification, invitation, password reset, "new key created" notice. API keys are never e-mailed.
Configuration: HFMD_RESEND_API_KEY=re_… and HFMD_MAIL_FROM="HF Market Data <noreply@hfmarketdata.io>"
(the domain must be verified in Resend — DKIM/SPF records — before the from address works).
Without a key, mails are logged and the action link is stored in email_tokens.meta; admins see it in
GET /v1/admin/users/{id} (pending_invite_link) / creation responses, and the CLI prints it.
6. Usage accounting
Per request the middleware increments usage:{principal}:{minute} in Redis (requests, rows, rows_parquet,
bytes, status_2xx, status_429; TTL 8 days). A background task (started with the first request, one Redis lock
across the 2 uvicorn workers) folds finished minutes every 60 s into SQLite usage_minute (7 rolling days)
and usage_daily (kept forever). Series endpoints merge folded data with the live minute.
7. CLI hfmd (scripts/hfmd)
scripts/hfmd seed [--show-key] [--no-mail] # idempotent: 3 users + admin, prints prefixes + invitation links
scripts/hfmd users add "Name" email [--tier free|high_usage] [--admin] [--show-key] [--no-mail]
scripts/hfmd users list | set-tier <email> <tier> | disable <email> | enable <email> | invite-resend <email>
scripts/hfmd keys list <email>The wrapper picks .venv/bin/python or hfmarketdata/venv/bin/python, puts hfmarketdata/api on
PYTHONPATH and honours the same HFMD_* variables as the API (in particular HFMD_STATE_DB). Full keys
are only printed at creation with --show-key; re-running seed/users add never re-creates users, keys
or tokens (an existing pending invitation link is printed again).
8. Production configuration
| variable | purpose |
|---|---|
HFMD_SECRET_KEY |
signs session cookies — long random string, rotate = everyone signed out |
HFMD_KEY_SALT |
salt of sha256(salt+key) for API keys/tokens and of the IP hash — never change after keys exist |
HFMD_REDIS_URL |
redis://127.0.0.1:6379/0 (quotas + usage); fakeredis:// in tests |
HFMD_RESEND_API_KEY |
Resend API key (absent → links printed/stored, nothing sent) |
HFMD_MAIL_FROM |
verified sender, e.g. HF Market Data <noreply@hfmarketdata.io> |
HFMD_STATE_DB |
SQLite path (default <data_root>/state/hfmd.db) |
HFMD_RATELIMIT |
0 disables the middleware entirely (emergency only) |
HFMD_PUBLIC_URL |
base of the action links in e-mails (/verify, /accept-invite, /reset-password) |
Behind ngrok the client IP is the first hop of X-Forwarded-For; make sure nothing upstream lets clients
spoof it (ngrok overwrites it).
Inventaire des routes comptes (généré 2026-09-06 depuis /openapi.json)
| Route | Méthode | Résumé |
|---|---|---|
/v1/admin/audit |
GET | Audit log |
/v1/admin/usage |
GET | Global usage: totals per day + top principals |
/v1/admin/users |
GET | List users |
/v1/admin/users |
POST | Create or invite a user |
/v1/admin/users/{user_id} |
DELETE | Delete (anonymise) a user |
/v1/admin/users/{user_id} |
GET | User detail: keys, pending links, 7-day usage |
/v1/admin/users/{user_id} |
PATCH | Update tier / role / status / name |
/v1/admin/users/{user_id}/invite |
POST | (Re-)send the invitation |
/v1/admin/users/{user_id}/keys |
POST | Create an API key for a user (shown once) |
/v1/admin/users/{user_id}/keys/{key_id} |
DELETE | Revoke a user's API key |
/v1/admin/users/{user_id}/reset-password |
POST | Send a password-reset link to a user |
/v1/auth/accept-invite |
POST | Accept an invitation (set your password) |
/v1/auth/forgot |
POST | Request a password-reset link |
/v1/auth/login |
POST | Sign in with e-mail + password |
/v1/auth/logout |
POST | Sign out (this browser) |
/v1/auth/reset |
POST | Set a new password from a reset link |
/v1/auth/signup |
POST | Create an account (sends a verification e-mail) |
/v1/auth/verify |
GET | Verification link landing (redirects to the web page, does not consume the token) |
/v1/auth/verify |
POST | Verify an e-mail address (or confirm an e-mail change) |
/v1/limits |
GET | Tier table + your current counters (works without a key) |
/v1/me |
DELETE | Delete your account |
/v1/me |
GET | Your profile, tier and limits |
/v1/me |
PATCH | Update your profile (name, quota alerts) |
/v1/me/email |
POST | Change your e-mail address (confirmation link sent to the new address) |
/v1/me/keys |
GET | List your API keys |
/v1/me/keys |
POST | Create an API key (shown once) |
/v1/me/keys/{key_id} |
DELETE | Revoke an API key |
/v1/me/keys/{key_id} |
PATCH | Rename a key / edit its note |
/v1/me/keys/{key_id}/rotate |
POST | Rotate an API key (revoke + create) |
/v1/me/limits |
GET | Live quota of your keys (remaining requests / rows in the current window) |
/v1/me/password |
POST | Change your password |
/v1/me/sessions/revoke-all |
POST | Sign out everywhere |
/v1/me/usage |
GET | Your usage series (requests, rows, 429s) |
/v1/me/usage.csv |
GET | Your usage series as CSV |