The Pythia node pool lets the Pythia RPC service route traffic across the AncientHoldings chainweb fleet: the hub publishes the set of nodes Pythia may route to right now (the feed), and Pythia reports back how many requests each node served (the usagereport) so the node’s operator earns for the reads it fulfilled. It is a pure machine-to-machine (M2M) contract — no human is in the loop — authenticated by a single dedicated HMAC service credential, distinct from the human OIDC login.
In one sentence
Poll POST https://ancientholdings.eu/api/pythia/nodes/ for the usable-slot list, route your RPC traffic to those nodes, then POST https://ancientholdings.eu/api/pythia/usage/ a signed per-slot request count for each non-overlapping window — both requests carrying the same HMAC envelope { signature, nonce, timestamp, payload }.
1 · Endpoints
There are two endpoints. Both are folder routes served at the canonical trailing-slash form, and both take POST only (any other method → 405 + an Allow: POST header):
POST https://ancientholdings.eu/api/pythia/nodes/POST https://ancientholdings.eu/api/pythia/usage/The feed is POST-to-read (deliberate). The read carries no filter today, but its auth carrier is a signed HMAC envelope in the request body, and that signed body must ride the request intact. A GET-with-body is not portably deliverable, so the feed is served over POST with the envelope in the body (the payload may be empty/minimal). The usage write is naturally a POST.
POST the trailing-slash form directly. Because the hub runs trailingSlash: true, the canonical URLs end in / (as advertised above). POST that exact form — a request to the slash-free variant would 308-redirect to add the slash, and a 307/308 makes many HTTP clients drop the POST body and the auth envelope, failing the signature check. Use the URLs verbatim.
2 · Service authentication (HMAC)
Every request to both endpoints is authenticated by one dedicated HMAC credential — a shared secret held by the hub and by Pythia. This is explicitly not the human OIDC login and not the OIDC client_credentials grant; it is a transport origin-proof key (it proves a request came from a holder of the secret, and grants no authority over money — it is never a signing/spend key).
Each request body is a signed envelope:
HMAC-SHA256 over the canonical { payload, nonce, timestamp }single-use, random per requestrequest time (used for freshness)the request body (feed: minimal/empty · usage: the report)The hub gate does three checks: a constant-time HMAC verify, a ±300 s freshness window on the timestamp, and a single-use nonce replay rejection (a nonce is remembered until its freshness window closes, so a captured envelope cannot be replayed inside the still-valid window). The HMAC credential is the only gate — there is no admin session check.
Credential flow
A hub ancient admin provisions the shared secret — a 64-hex (32-byte) random string — on the hub and hands it to Pythia out of band. Pythia stores it as a secret (never committed to source) and signs every envelope with it. Rotating the secret invalidates the old one, so Pythia must be reconfigured after a rotation. If the secret is unset on the hub, both endpoints fail closed with 503 — the HMAC gate never runs against an empty key.
3 · Feed schema
A successful POST https://ancientholdings.eu/api/pythia/nodes/ returns 200 with the usable-slot list plus a recommended refresh interval:
{
"slots": [
{
"id": "203.0.113.5", // bare public IP (the join key)
"url": "https://203.0.113.5:1848", // https + host-published port
"networkId": "stoa",
"operator": "k:1a2b…", // stable k: account, or null
"atTip": true, // reachable && !lagging
"height": 4210037 // backing node cut height
}
],
"refreshAfter": 60 // poll no faster than this (seconds)
}id— the slot’s bare public IP (e.g.203.0.113.5), stable across container switches. Noip:prefix on the wire. (The handoff’sip:<ip>notation is illustrative only; the shipped wire form is the bare IP.) Pythia must echo back the exactidit received in its usage report — this string is the join key the hub uses to attribute earnings, so it must be byte-identical across the feed and the report.url— the public, routable chainweb endpoint for that slot:https://<slot-ip>:<host-published-port>. Alwayshttps; the port is the host-published port that pairs with the public IP (not the container-internal port, not a hub-side loopback).networkId— the network id,"stoa".operator— the owning operator’s stablek:<64hex>account, ornullwhen the IP formed a usable slot but resolved no account (usable-but-unearning). This is a snapshot Pythia may cache/echo; the hub attributes earnings from its own live record, not from this cached value.atTip— a boolean: the backing node is reachable and not lagging the network tip.height— the backing node’s current cut height.refreshAfter— 60 (seconds): how long the list is good for. Poll no faster than this.
Usability rule. A slot is listed only when its assigned backing container is reachable AND not lagging; an offline or lagging node is omitted from the feed entirely. No seed special-casing:every usable IP-slot is listed, including the IPs of Pythia’s own seed nodes — recognising its own seeds is entirely Pythia-side.
4 · Usage report schema
Pythia reports per-slot request counts by POST https://ancientholdings.eu/api/pythia/usage/. The signed envelope’s payload is the report:
// envelope.payload =
{
"period": { "from": "2026-07-01T00:00:00Z", "to": "2026-07-01T01:00:00Z" },
"slots": [
{
"id": "203.0.113.5", // bare IP, echoed exactly as received
"operator": "k:1a2b…", // echoed snapshot, or null
"keyedRequests": 4300, // non-negative integer
"anonRequests": 120, // non-negative integer (metered, never earns)
"ok": 4290 // COUNT of successful requests — NOT a 0/1 flag
}
]
}period.from/period.to— ISO-8601 bounds of the measured window (stored verbatim).slots[].id— the bare IP, exactly as received from the feed (the join key back to the operator).slots[].operator— Pythia’s echoed snapshot of the owning account (ornull); provenance only, not the attribution key.slots[].keyedRequests/anonRequests— non-negative integer counts.slots[].ok— a non-negative integer COUNT of successful requests in the window (e.g.4300). Not a0/1flag, not a boolean.
An empty slots array is a valid report (a zero-usage window). A slot id may not appear twice within one report. A success returns 200 with { ok:true, inserted:[…], duplicate:[…] } — the normalized slot ids newly stored and those already present (a deduped retry).
5 · The window contract (a Pythia MUST)
Load-bearing — Pythia MUST honour this
Report non-overlapping, contiguous period windows, each window immutable once reported.
Ingest is idempotent per (period, slot) via first-write-wins. A re-send of the same (period, slot) is a retry carrying byte-identical counts — safe and deduped, never a correction. A corrected count MUST be a new, non-overlapping window, not a re-send: a re-send of an already-stored window is silently dropped (its numbers are the retry of what is already stored).
The hub does no range math and no overlap detection — an overlap is a Pythia-side contract violation that silently double-stores (each distinct periodtuple stores independently), which would over-attribute earnings. Cadence is Pythia’s call — immediate, periodic, or daily; the hub is cadence-agnostic.
6 · Error shapes
Every rejection is a JSON body with a stable literal:
| Status | Body | When |
|---|---|---|
| 503 | { error: 'Pythia service is not configured' } | the HMAC secret is unset on the hub (fail-closed) |
| 400 | { ok:false, reason:'invalid_shape' } | the envelope itself is malformed (pre-auth) |
| 401 | { ok:false, reason } | signature/freshness/nonce failure — reason ∈ bad_signature | stale_timestamp | future_timestamp | replayed_nonce |
| 400 | { ok:false, reason:'invalid_payload' } | /usage only: the envelope verified but the usage report content is malformed (post-auth) |
| 429 | Retry-After header set | rate-limited (see §7) |
| 405 | Allow: POST header set | a non-POST method |
The invalid_shape vs invalid_payload distinction is load-bearing. invalid_shape (400, both endpoints) is a pre-auth rejection: the envelope structure is wrong, so no signature was even checked. invalid_payload (400, /usage only) is post-auth: the signature verified — the request truly came from Pythia — but the money-report content inside the payload is malformed. The two reasons let an auditor tell auth abuse apart from a trusted caller sending a bad report.
7 · Cadence, rate + size limits
- Feed refresh: poll no faster than the
refreshAfterthe feed returns (60 seconds). The list never claims freshness longer than the tip caches behind it refresh. - Usage cadence:Pythia’s call (immediate / periodic / daily) — the hub is cadence-agnostic, subject only to the window contract in §5.
- Rate limits: each endpoint has its own per-IP bucket —
pythia:nodes:<ip>for the feed andpythia:usage:<ip>for the usage write — each 120 requests / 60 s. Separate buckets so a feed-poll flood can never starve the usage push (Pythia hits both from one egress IP). Over budget →429with aRetry-Afterheader. - Caller IP is the rightmost
X-Forwarded-Forhop (the nginx-added client IP) plus a fixedpythiaservice tag — the finer-grained caller field for future use. - Size: there is no explicit body /
slots-array size cap beyond the per-IP rate limit; keep reports to one window each and the 120/60 s budget is ample.
8 · Operator identity & earning
The operator on each slot is the resolved, stable Ouronet k:<64hex> account — it survives owner-email changes because it is keyed on the account, not the mailbox. A slot may carry a null operator: it is usable but unearning until an account is resolved. Only keyedRequests are reward-eligible; anonRequests are metered but never earn.
The feed’s operator is a snapshot Pythia caches and echoes back for provenance. The hub attributes earnings from its own live operator record via the slot-id join at accrual time — never from the echoed snapshot.
Rewards — available once enabled, not live
Metering and the feed are live from day one — usage is stored the moment Pythia reports it. Reward accrual, however, is a scaffold: the hub mints nothing until a hub ancient admin arms rewards (off by default) and the owner sets the (deferred) per-keyed-request rate. Only keyed reads are reward-eligible; on-chain settlement is out of scope for this contract. This page deliberately publishes no Stoicism-per-read rate — the rate is owner policy, set at arm time.
Related: SSO integration (the human OIDC login — a separate credential from this M2M HMAC) and The Stoic System (how on-chain earning is scored). Hub host: https://ancientholdings.eu.