Skip to main content
Pharos
PHAROSlive stablecoin signals

External Integrations

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.

Quick Facts

  • Public auth: X-API-Key
  • No-key public routes: health, OG images, feedback, self-serve key request, Telegram webhook (Telegram secret)
  • Admin auth: Cloudflare Access on the ops hosts

For integrations

External API

Call https://api.pharos.watch directly. Non-exempt /api/* requests require a valid X-API-Key; missing or invalid keys return 401. Self-serve access starts at /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.

Need A Key?

Request API access by email verification

If you want a public API key, use the self-serve API access form.

The default self-serve key is email-verified, limited to 30 requests per minute, scoped to the public external API lane, and expires after 60 days.

API Access FAQ

How do I get a Pharos API key?

Use the self-serve request form at https://pharos.watch/api/. It sends an email verification link and reveals the API key once after verification.

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 health checks, OG images, feedback submission, the Telegram webhook, Telegram Mini App session/mutation, and the self-serve API-key request and verification endpoints; 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/* / HEAD /api/og/* return 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.

Section

Surface Split

Reference Section

Surface Split

The runtime now uses three HTTP lanes:

  • https://api.pharos.watch is the external integration API. Protected public routes require X-API-Key.
  • https://site-api.pharos.watch is the website-internal Worker host. It accepts only allowlisted GET reads plus X-Pharos-Site-Proxy-Secret.
  • /_site-data/* is the same-origin Pages Functions proxy used by browsers on pharos.watch, ops.pharos.watch, stablecoin-dashboard.pages.dev, and subdomains of stablecoin-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.

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, CI smoke, and build-time sync scripts should target https://api.pharos.watch and send X-API-Key for protected public reads, including /api/telegram-pulse.

Production Pages does not proxy public self-serve /api/* POST requests. The public form at https://pharos.watch/api/ calls https://api.pharos.watch/api/api-key-requests and https://api.pharos.watch/api/api-key-requests/verify with normal CORS preflights for JSON POST requests.

The direct Worker cache profiles below describe responses from api.pharos.watch / site-api.pharos.watch. The Pages /_site-data/* proxy adds a separate same-origin Cache API layer for successful responses without Set-Cookie, without Cache-Control: no-store, and without freshness Warning: 110; it does not cache no-store routes such as /api/health.

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/health
  • GET /api/og/*
  • HEAD /api/og/*
  • POST /api/feedback
  • POST /api/api-key-requests
  • POST /api/api-key-requests/verify
  • POST /api/telegram-webhook
  • POST /api/telegram-mini-app/session
  • POST /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 only through ops-api.pharos.watch or the ops.pharos.watch/api/admin/* Pages proxy. The public API host rejects known admin and admin-like path families before API-key auth, so a public API key cannot be used to reach /api/status, /api/api-keys*, /api/api-key-requests-admin*, or other operator roots on api.pharos.watch.

The public self-serve request form lives at https://pharos.watch/api/. It sends an email verification link, then exchanges that one-time token for a default key after verification. Default self-serve keys are tier="self-serve", trafficClass="external", limited to 30 requests per minute, expire after 60 days, and allow one active/pending self-serve claim per normalized email. Request details are available only in the private ops.pharos.watch/admin-api/ UI.

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 issuance uses the same storage model and returns the plaintext token only once after verification.

For protected cacheable GET routes, the worker keeps a bounded isolate-local verified-key cache and a bounded isolate-local limiter. A recently verified non-self-serve 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. 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:

ExampleAsset
"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:

HeaderDescription
X-Data-AgeSeconds elapsed since the cron last wrote this data to D1
WarningFreshness 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

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.

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:

json
{
  "_meta": {
    "updatedAt": 1710500000,
    "ageSeconds": 42,
    "status": "fresh"
  }
}
FieldTypeDescription
updatedAtnumberUnix epoch seconds when the cron last wrote this data to D1
ageSecondsnumberfloor(now / 1000) - updatedAt
statusstring"fresh" (age/max <= 8.0), "degraded" (8.0 < ratio <= 12.0), or "stale" (ratio > 12.0)

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.

Endpoints with `_meta`:

EndpointMax Age (sec)Source
GET /api/stablecoins600createCacheHandler
GET /api/chains1800worker/src/api/chains.ts
GET /api/bluechip-ratings43200createCacheHandler
GET /api/usds-status86400createCacheHandler
GET /api/yield-rankings3600Manual injection after live safety hydration

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 apply while the dataset is within its generic freshness runway. Once a cache-backed response exceeds 8x its endpoint max age, the worker overrides that response to Cache-Control: no-store until a fresh response is generated.

ProfileCache-ControlUsed by
realtimepublic, s-maxage=60, max-age=10stablecoins, stablecoin-summary, blacklist, blacklist-summary, depeg-events, peg-summary, mint-burn-events, chains
standardpublic, s-maxage=300, max-age=60stablecoin-charts, redemption-backstops, usds-status, daily-digest, digest-archive, report-cards, stability-index, yield-rankings, mint-burn-flows, stress-signals
custompublic, s-maxage=300, max-age=300dex-liquidity (browser-side max-age extended to match CDN TTL)
per-coinpublic, s-maxage=300, max-age=10stablecoin/:id (cache-aside with 5-min per-coin TTL in D1)
slowpublic, s-maxage=3600, max-age=300supply-history, dex-liquidity-history, bluechip-ratings, yield-history, safety-score-history, non-usd-share
archivepublic, s-maxage=86400, max-age=3600digest-snapshot, snapshots-index
immutable-snapshotpublic, s-maxage=31536000, max-age=31536000, immutablesnapshots/:date.json, snapshot/:date/stablecoin/:id
public-statuspublic, max-age=60public-status-history
og-imagepublic, max-age=900, s-maxage=900dynamic Open Graph images
reserve-livepublic, s-maxage=3600, max-age=300stablecoin-reserves live mode
reserve-live-stalepublic, s-maxage=1800, max-age=120stablecoin-reserves live-stale mode
reserve-fallbackpublic, s-maxage=300, max-age=60stablecoin-reserves curated/template/unavailable fallback modes
no-storeno-storehealth plus all admin GET routes after router override (status, status-history, request-source-stats, yield-source-decisions, API key inventory/audit routes, admin-action-log, debug-sync-state, backfill-dews, backfill-dews?repair=...&dry-run=true, audit-depeg-history?dry-run=true, discovery-candidates, status-probe-history)

POST /api/feedback, POST /api/api-key-requests, POST /api/api-key-requests/verify, 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 self-serve API-key endpoints and Telegram Mini App endpoints explicitly return no-store responses so verification tokens, plaintext API keys, and per-chat alert state are never cacheable.

Reference Section

Polling Guidance

Recommended minimum polling cadence for external integrations:

Cache profileMinimum poll intervalNotes
realtime60 secondsPolling faster usually re-fetches the same edge-cached payload
standard300 secondsPreferred baseline for most dashboards
per-coin300 secondsGET /api/stablecoin/:id is history-heavy; avoid short loops
slow3600 secondsHistorical/timeline endpoints should generally be polled hourly
archive86400 secondsHistorical digest snapshots and public snapshot index listings
immutable-snapshotOn-demand onlyDated public dataset snapshots are content-addressed and immutable
no-storeOn-demand onlyHealth/admin diagnostics; avoid high-frequency polling

Client best practices:

  • Add interval jitter (±10%) to avoid synchronized bursts.
  • Read X-Data-Age + Warning for freshness/stale decisions when those optional headers are present.
  • Back off exponentially on 429 and 5xx responses.

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/health, GET /api/og/*, HEAD /api/og/*, POST /api/feedback, POST /api/api-key-requests, POST /api/api-key-requests/verify, 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.

Per-key limit

ScopeLimitWindow
Per API keyVaries (default 120)60 seconds

Per-key overrides are stored in api_keys.rate_limit_per_minute.

Self-serve keys are issued with a fixed default of 30 requests per minute and a 60 day expiry. The request workflow has separate abuse limits: initial submissions are throttled by salted IP hash (5/hour) and private email hash (3/day), verification attempts are throttled by salted IP hash (20/10 minutes) and token hash (5/10 minutes), and successful issuance allows one self-serve key creation per salted IP hash per 24 hours.

When the per-key limiter is exceeded, the API returns 429 Too Many Requests:

json
{
  "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 non-self-serve key through a bounded isolate-local auth/limiter path. It can also continue serving a recently verified non-self-serve key during a brief D1 outage by reusing its bounded verified-key cache and isolate-local limiter. Self-serve keys are refused when their D1 lookup is unavailable because revocation and claim state cannot be rechecked from stale isolate cache. 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-After header 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