curl https://api.pharos.watch/api/stablecoins \
-H "X-API-Key: $PHAROS_API_KEY"API Reference
The public integration lane is https://api.pharos.watch. In production, protected public routes require X-API-Key. The website itself does not use that lane directly; it talks to the internal site-data proxy instead.
For implementation context beyond the HTTP contract, read the public API reference doc and the broader documentation archive.
Prefer machine-readable tooling? Download the OpenAPI spec, or import the Pharos API collection with the production environment template.
For integrations
External API
Call https://api.pharos.watch directly. GET /api/safety-grades needs no key; other non-exempt /api/* requests require a valid X-API-Key, and missing or invalid keys return 401. Access options are on /api/.
Same-origin only
Website lane
Browsers on pharos.watch, ops.pharos.watch, and Pages previews use same-origin /_site-data/*. The lane accepts only requests whose Origin or Referer maps to an allowed site hostname; external integrations should use the public API lane.
For operators
Ops lane
Admin routes live behind Cloudflare Access on ops.pharos.watch and ops-api.pharos.watch. They do not use public API keys.
Quick Facts
- Public auth:
X-API-Key - No-key public routes: safety grades, health, OG images, feedback, supporter key claim, Telegram webhook (Telegram secret)
- Admin auth: Cloudflare Access on the ops hosts
API Keys
Grades are free; keys unlock the rest
GET /api/safety-grades on https://api.pharos.watch needs no key and returns one Safety Score and grade per tracked stablecoin.
For keyed access, donors of $10 or more can claim a supporter key, and teams that need higher limits and direct support can request a partner key.
Quickstart
Call Pharos from your stack
Send your key in the X-API-Key header and call the API from a server or script, never from browser code.
const response = await fetch("https://api.pharos.watch/api/stablecoin/usdc-circle", {
headers: { "X-API-Key": process.env.PHAROS_API_KEY },
});
if (!response.ok) throw new Error(`Pharos API returned ${response.status}`);
const coin = await response.json();import os
import requests
response = requests.get(
"https://api.pharos.watch/api/depeg-events",
params={"active": "true"},
headers={"X-API-Key": os.environ["PHAROS_API_KEY"]},
timeout=10,
)
response.raise_for_status()
events = response.json()No API Key Required
Public dataset downloads
These exports are published snapshots, not live API responses. Check the JSON metadata for the snapshot time, row count and methodology; the latest download can be older than today.
Pharos Top Stablecoins Dataset
Public snapshot of tracked stablecoins with peg type, peg mechanism, price, circulating USD supply, chain count, and chain coverage.
Pharos Latest Stablecoin Scores Dataset
Public snapshot of latest PegScore, Safety Score, DEWS, LiquidityScore, grade, and coverage-class values for tracked stablecoins.
Pharos Depeg History Dataset
Public history of tracked depeg events with stablecoin IDs, direction, peak deviation, timing, duration, prices, peg reference, and source.
Pharos Peg Mechanism Distribution Dataset
Public market-structure export summarizing stablecoin counts by mechanism archetype, peg reference, and jurisdiction.
API Access FAQ
How do I get a Pharos API key?
Safety Score grades are free without a key at https://api.pharos.watch/api/safety-grades. Donors of $10 or more in qualifying stablecoins can claim a supporter key (10 requests per minute, no expiry) at https://pharos.watch/api/#supporter-key. Teams that need higher limits, integration help and support can request a partner key at https://pharos.watch/api/#partner-access by messaging @TokenBrice on Telegram.
Do I need an API key for every endpoint?
Almost every public data endpoint on https://api.pharos.watch requires X-API-Key. The no-key exceptions are the safety-grades feed, health checks, OG images, feedback submission, the Telegram webhook, Telegram Mini App session/mutation, and the supporter key claim; Telegram still authenticates with its own secret or signed Mini App initData. Admin routes use Cloudflare Access instead of public API keys.
What is the difference between the public API lane and the website lane?
The public lane is https://api.pharos.watch and requires a valid X-API-Key. The website lane is same-origin /_site-data/* for browsers on pharos.watch, ops.pharos.watch, and Pages previews; the Pages function rejects requests without an allowed Origin or Referer, so external integrations should use the public API lane.
How is admin auth handled?
Admin routes live behind Cloudflare Access on ops.pharos.watch and ops-api.pharos.watch. They do not use public API keys; access is granted through the Pharos Cloudflare Access team domain.
Getting Started
Before You Call The API
The Pharos API is a REST API served by a Cloudflare Worker backed by a D1 database. It powers the pharos.watch stablecoin analytics dashboard through a split website-data lane plus an external integration API. On https://api.pharos.watch, all public routes are API-key protected unless this reference explicitly marks them as exempt.
Base URL: https://api.pharos.watch
Unless noted otherwise, responses are Content-Type: application/json. Exceptions: GET /api/og/* returns image/png for known image routes, and POST /api/telegram-webhook returns a plain-text ok body. CORS headers are added to every response, but Access-Control-Allow-Origin is restricted by the Worker CORS_ORIGIN allowlist (production repo config: https://pharos.watch,https://ops.pharos.watch). When the request Origin matches an allowlisted entry, the Worker echoes that origin and sets Vary: Origin; when a request includes a foreign Origin, the worker omits Access-Control-Allow-Origin, and OPTIONS preflights from foreign origins receive 403. Requests without an Origin header keep the existing first-allowlisted-origin fallback. Non-exempt /api/* requests on api.pharos.watch require a valid X-API-Key; missing or invalid keys return 401 Unauthorized. Per-key rate-limit overages return 429, and cold auth/limiter dependency failures can still return 503.
Agent navigation — Grep the heading you need: Surface Split · Public API Auth · Stablecoin IDs · Response Headers · Response Body Freshness (_meta) · Cache-Control Profiles · Polling Guidance · Rate Limits · Error Response Conventions · Method Gating Policy · Public Endpoints (generated from OpenAPI and the endpoint registry) · Pages Function endpoints. For one route, grep its path (for example,/api/stablecoins). Operator routes live in the internal admin reference.
Section
Quickstart
Reference Section
Surface Split
The runtime now uses three HTTP lanes:
https://api.pharos.watchis the external integration API. Protected public routes requireX-API-Key.https://site-api.pharos.watchis the website-internal Worker host. It accepts allowlistedGETreads and the internalPOST /api/telegram-adoptionmutation withX-Pharos-Site-Proxy-Secret./_site-data/*is the same-origin Pages Functions proxy used by browsers onpharos.watch,ops.pharos.watch,stablecoin-dashboard.pages.dev, and subdomains ofstablecoin-dashboard.pages.dev.
Static dataset exports are served from the public website, not from the Worker API, and do not require X-API-Key. The Stablecoin Cemetery export is available as JSON at https://pharos.watch/datasets/stablecoin-cemetery.json and CSV at https://pharos.watch/datasets/stablecoin-cemetery.csv.
The same static lane also serves the rolling public dataset mirrors at https://pharos.watch/datasets/<topic>/latest.{csv,json,ndjson}, plus one dated artifact per refresh run at https://pharos.watch/datasets/<topic>/<YYYY-MM-DD>.{csv,json,ndjson}. Topic identifiers are a never-break external contract and are enumerated by PUBLIC_DATASET_TOPICS in shared/lib/api-endpoints/datasets.ts; scripts/maintenance/generate-public-datasets.ts writes the dated files and prunes copies older than 90 days. Current-date generation maintains the generated public/_redirects block and frontend current-dataset module. Historical generation through PUBLIC_DATASETS_DATE=<past> writes only dated artifacts unless the operator explicitly passes --repoint-current, preventing a backfill from moving the public latest aliases backward. Each latest URL is a Cloudflare Pages 200 rewrite to its current same-extension dated artifact, preserving the direct-fetch URL and response bytes without committing a duplicate file. The artifact check rejects aliases more than two UTC dates behind the daily producer cadence, and the production frontend build rejects the same stale scores-latest mirror. A date with no refresh run has no file; consumers should treat a missing dated URL as "no run", not as "no data". https://pharos.watch/sheets/<topic>.csv also rewrites directly to the dated CSV rather than chaining through latest.csv, because Pages does not follow chained redirects. These URLs are unauthenticated, are advertised to crawlers as JSON-LD DataDownload targets (src/lib/analytics-dataset-json-ld.ts), and are served with the extension-compatible content types, Access-Control-Allow-Origin: *, and cache policies from public/_headers.
Machine-readable integration artifacts are also served from the public website for onboarding. The OpenAPI endpoint catalogue is available at https://pharos.watch/openapi.json, and Postman artifacts are available at https://pharos.watch/postman/pharos-api.postman_collection.json plus https://pharos.watch/postman/pharos-api.postman_environment.json. Import both Postman files, then replace the environment apiKey placeholder with a real X-API-Key. The generated OpenAPI artifact includes named schemas for the richer Yield Intelligence ranking and history payloads, and the Postman collection includes both best-source and source-key yield-history examples. These are public integration/read onboarding artifacts, not a complete dump of every no-key route; they intentionally exclude Cloudflare-Access-gated admin routes, self-serve key issuance POST endpoints, feedback submission, Telegram webhook ingestion, Telegram Mini App endpoints, and dynamic OG image routes. Request keys through https://pharos.watch/api/.
Browser consumers should use same-origin /_site-data/* via the frontend helpers in src/lib/api.ts. In production, that Pages proxy targets https://site-api.pharos.watch through SITE_API_ORIGIN. Direct integrations and CI smoke should target https://api.pharos.watch and send X-API-Key for protected public reads, including /api/telegram-pulse; production Pages build-input syncs instead read allowlisted GET endpoints through https://stablecoin-dashboard.pages.dev/_site-data/* with an allowed site caller header. Each sync command rejects missing or invalid input. A Pages release may retain one failed producer's committed snapshot, but it fails before build when all three producers fail or when a failed public-dataset refresh cannot be rolled back cleanly.
Production Pages does not proxy public /api/* POST requests. https://pharos.watch/api/ is the API access page: it presents the free grades feed, the supporter-key claim, and the partner-key contact. Its browser POST is the supporter-key claim, which goes cross-origin to https://api.pharos.watch/api/donor-key-claims with a normal CORS preflight for the JSON POST request.
The direct Worker cache profiles below describe responses from api.pharos.watch / site-api.pharos.watch. Pages /_site-data/* forwards the upstream cache policy, Age, and Date without adding a second Cache API lifetime, so it cannot make a nearly expired Worker response fresh again.
Reference Section
Public API Auth
Unless a route is explicitly called out below as exempt, requests to https://api.pharos.watch must send:
- header:
X-API-Key: ph_live_<16 hex prefix>_<32 char base64url secret> - example shape:
ph_live_0123456789abcdef_abcdefghijklmnopqrstuvwxyzABCDEF
Public, non-admin routes on https://api.pharos.watch that do not require X-API-Key are limited to:
GET /api/safety-grades(the free lane: one Safety Score and grade per tracked stablecoin)GET /api/healthGET /api/og/*POST /api/feedbackPOST /api/donor-key-claims(the supporter-key claim: a Sign-In-With-Ethereum signature from an eligible donor wallet, rate-limited per IP before the body is read)POST /api/telegram-webhookPOST /api/telegram-mini-app/sessionPOST /api/telegram-mini-app/mutate
POST /api/telegram-webhook is externally reachable but not anonymous: it requires X-Telegram-Bot-Api-Secret-Token instead of X-API-Key.
POST /api/telegram-mini-app/session and POST /api/telegram-mini-app/mutate are also externally reachable but not anonymous. They require Telegram Mini App initData signed for @PharosWatchBot; the worker validates the HMAC, auth_date, and user payload before any D1-backed state write. These endpoints are denied on the website-internal site-data lane and are intended only for the Mini App at https://pharos.watch/pharoswatchbot/app/.
Admin/operator routes are also outside the public API-key gate, but they remain Cloudflare-Access-gated and are supported through ops-api.pharos.watch or the ops.pharos.watch/api/admin/* Pages proxy. The public API host rejects registered admin paths and configured admin-like root families before API-key auth, so a public API key cannot be used to reach registered admin routes or malformed children of configured roots such as /api/api-keys* on api.pharos.watch.
Self-serve key issuance is retired: the lane was removed on 2026-09-29, so POST /api/api-key-requests and POST /api/api-key-requests/verify are unregistered and respond like any unknown API path on the public host (401 without a valid X-API-Key, 404 with one), while existing tier="self-serve" keys keep authenticating until they drain through their 60-day expiry (about 2026-11-06). The lane's D1 tables (api_key_requests, api_key_request_rate_limit_v2, api_key_self_serve_email_claims, api_key_self_serve_issuance_limits) are left in place and dropped in a separate follow-up rollout after this Worker is live; the rate-limit table's daily prune continues until then, and the operator may delete the lane's now-unused Worker secrets with Wrangler (names only, never values): API_KEY_SELF_SERVE_IP_SALT, API_KEY_SELF_SERVE_EMAIL_HASH_PEPPER, API_KEY_SELF_SERVE_REQUEST_PEPPER, API_KEY_SELF_SERVE_EMAIL_FROM, API_KEY_SELF_SERVE_EMAIL_REPLY_TO, API_KEY_SELF_SERVE_PUBLIC_BASE_URL, RESEND_API_KEY. Keyed access is a supporter key, claimed by donors on the access page at https://pharos.watch/api/ through POST /api/donor-key-claims, or an operator-issued partner key (internal tier standard) requested through the private channel on that page: Telegram DM to @TokenBrice, with X DM to @PharosWatch as secondary, and a human reply within PARTNER_KEY_REPLY_BUSINESS_DAYS (2) business days. The reply window and the freshness stamped on every response are service commitments, not a contractual SLA.
POST /api/donor-key-claims issues the supporter key and is exempt from X-API-Key. An externally-owned wallet signs an EIP-4361 message with personal_sign for domain pharos.watch and URI https://pharos.watch/api/, valid for 5 minutes, then posts { "message", "signature" }. Eligibility requires at least $10 in receipt-date USD from qualifying-stablecoin donations whose current grades are A+, A, A-, B+, B, or B-. Qualifying stablecoins are the reviewed (chain, token contract) pairs in DONOR_KEY_QUALIFYING_STABLECOINS (shared/lib/funding/donor-eligibility.ts, 15 coins; the source file wins): each coin counts only on its reviewed contract deployments, so bridged or same-ticker tokens never qualify, and EURC is valued at the ECB EUR/USD reference rate for the receipt date, never 1:1. The threshold is inclusive with floating-point tolerance, addresses are matched case-insensitively, founder rows count, and pool payouts do not. The donor list is updated every Sunday. Smart-contract wallets, Giveth streams, and exchange withdrawals cannot claim.
All claim responses use Cache-Control: no-store. 201 returns the plaintext token once, with tier donor, 10 requests per minute, and no expiry. Every failure returns { "error": "message", "reason": "code" }; codes are defined once by DONOR_KEY_CLAIM_FAILURE_REASONS in shared/types/api-keys.ts:
| Status | Reasons |
|---|---|
| 400 | body_invalid, siwe_invalid, signature_invalid |
| 413 | body_invalid (body exceeds 4096 bytes) |
| 403 | claims_closed, claim_revoked, ineligible |
| 409 | claim_exists, claim_orphaned |
| 429 | rate_limited (Retry-After: 60) |
| 503 | rate_limiter_missing, rate_limit_unavailable, donations_ledger_invalid, safety_scores_unavailable, grade_unavailable, pepper_missing, issue_failed |
The 403 ineligible body additionally returns ledgerUpdatedAt (Unix seconds), qualifyingUsd (number), and countedAssets (string array of counted stablecoin labels). A missing coin grade is unavailable, not a zero observation: if counted donations plus donations with unavailable grades could reach the threshold, the route returns 503 grade_unavailable with Retry-After: 60 instead of 403. Published grades outside A/B do not count. Missing or held canonical accepted V9 publications return 503 safety_scores_unavailable.
One key is issued per wallet, atomically with its api_key_donor_claims row. Existing claim lookup precedes grading; later grade changes do not alter already-issued keys. Re-signing never rotates a key. For a lost key or record removal, message @TokenBrice on Telegram, with @PharosWatch on X as secondary. An operator verifies the donating wallet before rotating a lost key. Claim outcome logs contain codes only, not signed messages, signatures, or wallet addresses.
Claims are enabled by DONOR_KEY_CLAIMS_OPEN in shared/lib/public-api-contract.ts. The DONOR_KEY_CLAIM_RATE_LIMIT binding admits 10 attempts per 60 seconds per client IP before reading the body. Donor keys always use D1-backed auth and quotas, not the isolate fast-cache path. Migration 0238_api_key_donor_claims.sql must precede deployment; docs/api-page.md owns the live claim, quota, replay, and access-gate acceptance sequence.
The worker stores only the key prefix plus a peppered HMAC of the secret portion. Admin callers create, rotate, and deactivate keys through the operator lane (ops.pharos.watch / ops-api.pharos.watch); plaintext tokens are returned only once at creation/rotation time.
Self-serve authentication also consults durable revocation tombstones in the D1 table api_key_self_serve_revocations (worker/src/lib/api-key-auth.ts). The tombstone is keyed on the key prefix, not on the api_keys row id, so it survives deactivating, rotating, or deleting that row: a revoked prefix stays refused until the tombstone itself is removed. The writer was the retired self-serve admin route, so no new tombstones are written, but existing ones keep blocking their prefixes while self-serve keys drain. Because that check is only answerable from D1, self-serve keys are never served from the isolate-local verified-key cache and fail closed whenever the D1 lookup is unavailable.
For protected cacheable GET routes, the worker keeps a bounded isolate-local verified-key cache and a bounded isolate-local limiter. A recently verified standard key can use that local path for hot edge-cache hits, and can continue to read cached routes during a brief D1 auth/limiter outage. Donor, self-serve, unknown, stale-cache, or not-yet-verified keys still fail closed.
Reference Section
Stablecoin IDs
Most endpoints use the Pharos stablecoin ID in ticker-issuer format (e.g. usdt-tether). IDs are checked through the shared stablecoin-ID registry (shared/lib/stablecoin-id-registry.ts). Unknown or non-canonical IDs return 404.
Canonical IDs use ticker-issuer format — lowercase ticker symbol hyphenated with the issuer/protocol name:
| Example | Asset |
|---|---|
"usdt-tether" | Tether (USDT) |
"usdc-circle" | USD Coin (USDC) |
"paxg-paxos" | PAX Gold (PAXG) |
"ustb-superstate" | Superstate USTB |
"gyen-gyen" | GYEN |
The full list is exported from shared/lib/stablecoins/registry.ts, with editable per-coin metadata stored in shared/data/stablecoins/coins/*.json, the checked-in generated aggregate at shared/data/stablecoins/coins.generated.json, and validation in shared/lib/stablecoins/schema.ts. The API accepts canonical IDs only. Non-canonical stablecoin detail URLs and legacy frontend route aliases are retired and unsupported.
Reference Section
Response Headers
Endpoints backed by the cron cache include these additional headers:
| Header | Description |
|---|---|
X-Data-Age | Seconds elapsed since the authoritative producer observation; unavailable when that clock cannot be established |
Warning | Freshness warning (110) when cached data is older than the generic freshness runway, plus endpoint-specific advisory warnings (199) on a few compute-on-read routes |
X-Data-Freshness | stale when retained successful producer history is absent, or unknown when its lookup failed |
X-Data-Freshness-Reason | Machine-readable unavailable-authority reason: producer-history-missing or freshness-lookup-failed |
Generic freshness status is fresh through 8x maxAge, degraded through 12x maxAge, then stale. Generic freshness headers emit Warning and downgrade Cache-Control to no-store after age > 8x maxAge so edge/browser caches do not keep serving an old payload after the underlying cron data recovers. Some routes also use Warning for dependency or quality advisories even when the age is still inside that runway; clients should treat body _meta.status as authoritative when it exists.
DEX liquidity keeps its dataset-wide advisory in Warning and also emits a nullable warning on each coin row. Coin-specific TVL cliffs or pool-count drops from an otherwise successful run apply only to affected coins and are omitted from the Warning header and from the __global__ row entirely; provider failures, near-guard proximity, and unscoped findings remain global. The advisory comes from the latest liquidity producer outcome, excluding neutral or locked skips. Coin detail consumers use the row advisory while retaining the producer timestamp for independent freshness checks; older responses without the field retain their global warning.
Reference Section
Response Body Freshness (_meta)
Endpoints that emit _meta into plain-object (non-array) response bodies do so through createCacheHandler() or route-specific manual injection, alongside the HTTP freshness headers above. This provides inline freshness metadata for consumers that prefer not to parse response headers.
Shape:
{
"_meta": {
"updatedAt": 1710500000,
"ageSeconds": 42,
"status": "fresh",
"assessedAt": 1710500042,
"freshBudgetSec": 4800,
"degradedBudgetSec": 7200
}
}| Field | Type | Description |
|---|---|---|
updatedAt | number | Unix epoch seconds when the cron last wrote this data to D1 |
ageSeconds | number | Nonnegative age of the generation at assessedAt |
assessedAt | number | Unix seconds at which the verdict was assessed; not a replacement generation clock |
freshBudgetSec | number | Inclusive maximum generation age for an age-based fresh verdict |
degradedBudgetSec | number | Inclusive maximum generation age for an age-based degraded verdict; older is stale |
status | string | "fresh", "degraded", or "stale"; generic bands remain 8x/12x the endpoint max age |
Route-specific manual _meta injectors can be stricter. GET /api/chains uses its 1800-second budget directly (fresh <= 1x, degraded <= 2x, then stale) and switches its response to no-store whenever the chain snapshot is not fresh.
Generic, chains, and yield producers publish assessment time and effective budgets, including response-ready cache injection. Readers accept legacy cached metadata without these fields but must treat its assessment/budget as unknown, not infer that it used the current policy. Chains can also be degraded by its named dependencies.reportCards verdict even when snapshot age is fresh. Timestamps over the public 60-second future allowance are degraded and not cacheable; this is timestamp validity, not an age-band change.
GET /api/stress-signals publishes assessedAt, freshBudgetSec, degradedBudgetSec, and newestReturnedComputedAt beside each row's computedAt and ageClassification. The newest-returned clock is the aggregate comparison basis for retainedLastValid; single-coin responses have no peer-generation comparison (null). These are row-generation verdicts, not a claim that every source used by DEWS was observed at that time.
Event feeds (events, depeg-events, blacklist, mint-burn-events) derive freshness only from a successful producer run, never request time or the newest matching event. An empty filtered page is fresh only with a fresh producer observation, including a successful zero-event run. No successful run in the retained seven-day cron history means stale/no authoritative recent run; a failed lookup means unknown. Both use Cache-Control: no-store, Warning: 199, and X-Data-Age: unavailable, with the reason headers above. /api/events also publishes null updatedAt / ageSeconds and status: "stale" | "unknown" plus reason in _meta; normal observed metadata is unchanged. Safety-score history uses the same producer-authority headers. Blacklist-summary retains the producing snapshot's clock and lookup state, not its materialization/request time.
For uncounted offset pages, total is a conservative observed lower bound and totalExact is false. A nonempty offset page establishes the offset plus its observed rows (and any lookahead row); an empty page establishes only zero, even at offset 50,000. Cursor continuations report only their observed page/lookahead bound. Blacklist defaults to uncounted pages; use includeTotal=true for an exact filtered count.
Yield routes override the generic 8x/12x runway: GET /api/yield-rankings (full and summary) and GET /api/yield-history use the shared yield-data bands: fresh through 7,200 seconds (2x the hourly producer interval), degraded through 14,400 seconds (4x), then stale. Both non-fresh states return HTTP Warning: 110 and Cache-Control: no-store. Their _meta includes required assessedAt (response-time Unix seconds), freshBudgetSec, degradedBudgetSec, and nullable reason alongside updatedAt, ageSeconds, and status. Non-fresh publication age names yield-publication-age. History freshness measures the authoritative publication cutoff, not the last point in a requested historical window; unavailable authority is stale with publication-cutoff-unavailable.
Yield wire contract: Full and summary rankings and history publish the expanded evidence directly.
- Summary rows expose
benchmarkSelectionModeandprovenance.sourceMaxAgeSeconds. SummarybenchmarkIsFallbackdescribes feed fallback independently of currency-selection policy; readers use explicit selection mode rather than inferring it from the fallback flag. - Detailed ranking provenance retains nullable
sourceObservedAtandsourceAgeSecondswithout dropping the remaining evidence. Unavailable rankingsmedianApyis null.sourceRisk.rewardShareis nonnegative with no upper bound, preserving raw ratios above 1 in rankings, alternatives, and history.
Yield source freshness is separate from publication freshness. Unknown observation evidence must not be replaced with publication time or treated as refreshed merely because the snapshot was newly served. Source and comparison-anchor ages advance at read time; selected sourceRisk.sourceAgeSeconds follows authoritative provenance, while alternate ages advance from the publication clock and preserve unavailable ages.
Yield history may include a top-level warning explaining publication-cutoff fallback. A point with unreadable stored warnings returns warningSignals: [] with warningSignalsStatus: "unreadable"; that marker is not a clean warning assessment. Each point's pysReproducibility is exact, not-scored, legacy-partial, or invalid: only exact affirms reproduction from the publish-time input snapshot, while legacy/absent evidence and invalid snapshots remain explicit.
Endpoints with _meta:
| Endpoint | Max Age (sec) | Source |
|---|---|---|
GET /api/stablecoins | <!-- GENERATED-START: api-meta-stablecoins-max-age -->600<!-- GENERATED-END: api-meta-stablecoins-max-age --> | createCacheHandler |
GET /api/chains | <!-- GENERATED-START: api-meta-chains-max-age -->1800<!-- GENERATED-END: api-meta-chains-max-age --> | worker/src/api/chains.ts |
GET /api/events | 600 | worker/src/api/events.ts |
GET /api/bluechip-ratings | <!-- GENERATED-START: api-meta-bluechip-ratings-max-age -->43200<!-- GENERATED-END: api-meta-bluechip-ratings-max-age --> | createCacheHandler |
GET /api/usds-status | <!-- GENERATED-START: api-meta-usds-status-max-age -->86400<!-- GENERATED-END: api-meta-usds-status-max-age --> | createCacheHandler |
GET /api/yield-rankings | <!-- GENERATED-START: api-meta-yield-rankings-max-age -->3600<!-- GENERATED-END: api-meta-yield-rankings-max-age --> | Manual injection after live safety hydration |
GET /api/depeg-resolver | 900 | worker/src/api/depeg-resolver.ts |
GET /api/depeg-resolver-review | 900 | worker/src/api/depeg-resolver-review.ts |
Array-typed responses (e.g., endpoints returning a JSON array at the top level) do not include _meta. They receive X-Data-Age / Warning only when their handler wires freshness metadata explicitly. Supply history, safety score history, and non-USD share are explicit history-endpoint exceptions that emit freshness headers; DEX liquidity history currently exposes cache headers but no freshness headers.
The frontend apiFetchWithMeta() helper (in src/lib/api.ts) reads _meta from the response body when present, falling back to the X-Data-Age header for endpoints that do not include it.
Reference Section
Cache-Control Profiles
These profiles are ceilings, not a fresh lifetime renewed on each read. Freshness-aware responses clamp browser max-age, shared s-maxage, and the combined TTL plus stale-while-revalidate window to the remaining fresh runway. At the boundary (no whole second remains), or for a non-fresh/invalid timestamp, the response is no-store. Generic responses retain 8x/12x bands, chains 1x/2x, and yield its own publication bands; no cadence or SLO is changed. Edge cache reads reject entries whose HTTP Age/Date has exhausted that bounded lifetime. Proxies preserve Age and Date; clients interpreting cached _meta must distinguish its assessedAt from the current wall clock.
All rows below are members of the centralized API_CACHE_PROFILES map (shared/lib/api-cache-profiles.ts) except immutable-snapshot, which is a route-local constant (IMMUTABLE_CACHE_CONTROL in worker/src/api/snapshot.ts) reused for the immutable public-snapshot routes.
| Profile | Cache-Control | Used by |
|---|---|---|
| realtime | public, s-maxage=60, max-age=10 | health, events |
| producer-backed | public, s-maxage=300, max-age=60, stale-while-revalidate=300 | stablecoins, stablecoin-summary, blacklist, blacklist-summary, depeg-events, peg-summary, mint-burn-events, chains (cron-published payloads with 15-30 min producers) |
| standard | public, s-maxage=300, max-age=60 | stablecoin-charts, depeg-resolver, depeg-resolver-review, redemption-backstops, usds-status, daily-digest, digest-archive, stability-index, yield-rankings, yield-adapter-manifest, mint-burn-flows, stress-signals. /api/report-cards/v9 uses this profile only for a current handler response, uses no-store while held, and always bypasses the edge cache. |
| custom | public, s-maxage=300, max-age=300 | dex-liquidity (browser-side max-age extended to match CDN TTL); telegram-pulse uses route-local public, max-age=300, s-maxage=300 |
| per-coin | public, s-maxage=300, max-age=10 | stablecoin/:id (cache-aside with 5-min per-coin TTL in D1) |
| slow | public, s-maxage=3600, max-age=300 | supply-history, dex-liquidity-history, bluechip-ratings, yield-history, safety-score-history, non-usd-share, safety-score-history-v2 |
| archive | public, s-maxage=86400, max-age=3600 | digest-snapshot, snapshots-index |
| immutable-snapshot | public, s-maxage=31536000, max-age=31536000, immutable | snapshots/:date.json, snapshot/:date/stablecoin/:id |
| public-status | public, max-age=60 | public-status-history |
| og-image | public, max-age=900, s-maxage=900 | dynamic Open Graph images, including rendered safety-score degraded states that remain explicitly marked with degraded metadata headers |
| reserve-live | public, s-maxage=3600, max-age=300 | stablecoin-reserves live mode |
| reserve-live-stale | public, s-maxage=1800, max-age=120 | stablecoin-reserves live-stale mode |
| reserve-fallback | public, s-maxage=300, max-age=60 | stablecoin-reserves curated/template/unavailable fallback modes |
| no-store | no-store | admin GET routes via the router override or admin route wrapper (status, status-history, request-source-stats, API key inventory/audit routes, admin-action-log, debug-sync-state, rpc-provider-trial, backfill-dews, backfill-dews?repair=...&dry-run=true, audit-depeg-history?dry-run=true) |
POST /api/feedback, POST /api/donor-key-claims, POST /api/telegram-webhook, POST /api/telegram-mini-app/session, POST /api/telegram-mini-app/mutate, and admin POST endpoints bypass edge caching because they are non-GET request paths. The donor key claim and Telegram Mini App endpoints explicitly return no-store responses so plaintext API keys and per-chat alert state are never cacheable.
Reference Section
Polling Guidance
Recommended minimum polling cadence for external integrations:
| Cache profile | Minimum poll interval | Notes |
|---|---|---|
| realtime | 60 seconds | Polling faster usually re-fetches the same edge-cached payload |
| producer-backed | 300 seconds | Backing crons publish every 15-30 min; faster polls hit the edge cache |
| standard | 300 seconds | Preferred baseline for most dashboards |
| per-coin | 300 seconds | GET /api/stablecoin/:id is history-heavy; avoid short loops |
| slow | 3600 seconds | Historical/timeline endpoints should generally be polled hourly |
| archive | 86400 seconds | Historical digest snapshots and public snapshot index listings |
| immutable-snapshot | On-demand only | Dated public dataset snapshots are content-addressed and immutable |
| no-store | On-demand only | Admin/control diagnostics; avoid high-frequency polling |
Client best practices:
- Add interval jitter (
±10%) to avoid synchronized bursts. - Read
X-Data-Age+Warningfor freshness/stale decisions when those optional headers are present. - Back off exponentially on
429and5xxresponses.
Reference Section
Rate Limits
Public API traffic enforces per-key rate limiting to ensure fair usage. Non-exempt /api/* requests require a valid X-API-Key; the no-key public exceptions are GET /api/safety-grades, GET /api/health, GET /api/og/*, POST /api/feedback, POST /api/donor-key-claims, POST /api/telegram-webhook, POST /api/telegram-mini-app/session, and POST /api/telegram-mini-app/mutate. The Telegram webhook is authenticated separately with X-Telegram-Bot-Api-Secret-Token; Telegram Mini App endpoints are authenticated with signed Telegram initData.
Missing or invalid keys receive 401 with Unauthorized: valid X-API-Key required. Safety grades are free at /api/safety-grades; see https://pharos.watch/api/ for supporter and partner keys. Supporter claims have their own pre-body IP limiter; its 429 response includes reason: "rate_limited" and Retry-After: 60, separately from the issued key's global 10 requests per minute quota.
Per-key limit
| Scope | Limit | Window |
|---|---|---|
| Per API key | Varies (default 120) | 60 seconds |
Per-key overrides are stored in api_keys.rate_limit_per_minute.
Existing self-serve keys (retired lane; see Public API Auth) keep their fixed default of 30 requests per minute and 60 day expiry until they drain; the request and verification abuse limiters were removed with the route.
When the per-key limiter is exceeded, the API returns 429 Too Many Requests:
{
"error": "Rate limit exceeded"
}Rate-limited responses include the retry delay in the HTTP Retry-After header when the worker can compute one.
POST /api/feedback also has a form-specific limiter. Its 429 body is { "error": "Too many submissions. Please wait a few minutes." }, and it should be handled as a local submission throttle rather than as a public API quota response. If the feedback limiter's D1 dependency is unavailable, the endpoint returns 503 Service Unavailable with { "error": "Feedback service temporarily unavailable. Please try again." } and Retry-After: 60.
API-key authentication and per-key limiter storage normally rely on D1. For protected cacheable GET edge-cache hits, the worker can serve a recently verified standard (non-donor, non-self-serve) key through a bounded isolate-local auth/limiter path. It can also continue serving a recently verified standard (non-donor, non-self-serve) key during a brief D1 outage by reusing its bounded verified-key cache and isolate-local limiter. Donor and self-serve keys never use the isolate path — donor quotas must stay global and self-serve revocation state is only answerable from D1 — so both fail closed when D1 is unavailable. Unknown or not-yet-verified keys still fail closed with 503 Service Unavailable, { "error": "Public API temporarily unavailable" }, and Retry-After: 60. Best-effort API-key usage timestamp updates do not fail otherwise successful reads.
Retry Guidance
- Respect the
Retry-Afterheader when present - Add random jitter (0–2 seconds) to avoid thundering-herd retries
- Use exponential backoff for sustained 429 responses
- Combine with the polling cadences in the section above to stay well under limits
Endpoint Directory
Public API routes
This page keeps the route list scannable. The canonical field tables, examples, and edge-case contracts live in the full API reference.
/api/eventsGET/api/stablecoinsGET/api/stablecoin/:idGET/api/stablecoin-summary/:idGET/api/non-usd-shareGET/api/chainsGET/api/stablecoin-reserves/:idGET/api/stablecoin-chartsGET/api/blacklistGET/api/blacklist-summaryGET/api/depeg-eventsGET/api/depeg-resolverGET/api/depeg-resolver-reviewGET/api/peg-summaryGET/api/usds-statusGET/api/bluechip-ratingsGET/api/dex-liquidityGET/api/dex-liquidity-historyGET/api/supply-historyGET/api/daily-digestGET/api/digest-archiveGET/api/digest-snapshotGET/api/snapshots/indexGET/api/snapshots/:date.jsonGET/api/snapshot/:date/stablecoin/:idGET/api/healthGET/api/public-status-historyGET/api/telegram-pulseGET/api/stability-indexGET/api/og/*GET/api/report-cards/v9GET/api/safety-gradesGET/api/dependency-graph/v1GET/api/dependency-scenarios/v1GET/api/redemption-backstopsGET/api/safety-score-historyGET/api/safety-score-history-v2GET/api/yield-rankingsGET/api/yield-adapter-manifestGET/api/yield-historyGET/api/mint-burn-flowsGET/api/mint-burn-eventsGET/api/stress-signalsPOST/api/donor-key-claimsPOST/api/feedbackPOST/api/telegram-mini-app/sessionPOST/api/telegram-mini-app/mutatePOST/api/telegram-webhook