Introduction
kaspulse serves signed price feeds: each price is a median of independent venues, threshold-signed by a 5-node committee (3-of-5), and carries everything needed to verify it — the exact signed message, the signer pubkeys and the signatures. Never trust the API — verify. The oracle and its signatures are live and real; on-chain consumers are proven on Kaspa testnet-10, mainnet publishing is next.
All endpoints are GET (plus HEAD/OPTIONS); live data is served Cache-Control: no-store. Poll — there is deliberately no push stream.
# everything, one round, ~60 feeds
curl $ORIGIN/v1/feed
# one pair (dash form, case-insensitive)
curl $ORIGIN/v1/feed/KAS-USD
# light catalog for dashboards
curl $ORIGIN/v1/feeds
The signed message
The keystone of the whole API. For every feed, the committee signs the ASCII string
kaspulse/v2|PAIR|mant|expo|ts|round
where PAIR is slash form (KAS/USD) and the rest are decimal integers (expo may be negative). The signed price is mant × 10^expo — exact at any magnitude. The digest is unkeyed blake2b-256 of those ASCII bytes, and each node’s signature is standard BIP340 Schnorr over that 32-byte digest as the message m (the digest is not hashed again outside BIP340’s own tagged hashing). signers[i] are 32-byte x-only pubkeys, index-paired with signatures[i].
Worked example, captured from a live round:
# the message string, verbatim from the feed:
kaspulse/v2|KAS/USD|295500000|-10|1783791092|183775
# its blake2b-256 digest (this is BIP340's m):
0f86dadc725bf4cc21900ab008ee1f9d75884935d9e6a0e4a48db2f4d8cda88d
A feed is VALID when ≥ threshold signatures verify and the message’s PAIR|mant|expo|ts fields equal the JSON’s pair/mant/expo/signed_ts — the field binding stops a lying server from serving a price the signatures don’t cover. Every kaspulse verifier checks both.
// JS (see clients/js/kaspulse.mjs for the real thing)
digest = blake2b256(utf8(feed.message))
valid = count(i => bip340_verify(feed.signers[i], digest, feed.signatures[i]))
ok = valid >= feed.threshold && fieldsBound(feed)
// Rust — the SDK does all of it, binding included
let feed = kaspulse_sdk::fetch(base, "KAS/USD")?;
let price = feed.checked_value_fresh(Duration::from_secs(30))?;
Prices are signed on change plus a 5s heartbeat — signed_ts/signed_round belong to the signature, the envelope’s timestamp/round to the serve tick. Full grammar: docs/MESSAGE-FORMAT.md.
Zero-dependency clients
One file each, no installs, no keys. Both fetch a feed AND verify it locally (signatures + field binding + freshness + safety flags), and both self-test their crypto core at load — a broken core refuses to verify rather than risk a fake ✓.
# JS — Node 18+ or the browser
curl -O https://raw.githubusercontent.com/Knitser/kaspulse/main/clients/js/kaspulse.mjs
node kaspulse.mjs verify KAS/USD $ORIGIN
# Python 3.9+ — stdlib only
curl -O https://raw.githubusercontent.com/Knitser/kaspulse/main/clients/py/kaspulse.py
python3 kaspulse.py verify KAS/USD $ORIGIN
Honest scope: they verify the committee’s signatures over the served message. They do not re-fetch the exchanges and recompute the median — that deeper audit is cargo run --bin verify in the repo.
Full envelope
GET/v1/feed
Every feed, one round, one document — multi-hundred-KB with ~60 feeds. Permanent aliases: /api/feed, /feed.json. For dashboards, poll /v1/feeds instead.
// captured from a live round (feeds abridged)
{
"round": 183775, "timestamp": 1783791092,
"threshold": 3, "num_nodes": 5, "transport": "websocket",
"peg": { "igra_usdc": 0.99606, "igra_ok": true },
"feeds": [ /* ~60 × FeedObj — see /v1/feed/{PAIR} */ ]
}
| field | type | meaning |
| round | u64 | serve-tick round counter |
| timestamp | u64 | serve-tick unix seconds |
| threshold | u32 | signatures required per feed (3) |
| num_nodes | u32 | committee size (5) |
| transport | str | how majors arrive ("websocket") |
| peg | obj | WKAS/USDC peg check on the Igra leg: igra_usdc (f64|null), igra_ok (bool|null) |
| feeds | [FeedObj] | all feeds — full shape below |
Single feed
GET/v1/feed/{PAIR}
One FeedObj. PAIR is dash form, case-insensitive: KAS-USD ↔ pair "KAS/USD". Unknown pair → real HTTP 404 with body {"error":"no such feed"} (the legacy alias /api/feed/{PAIR} now 404s too).
curl $ORIGIN/v1/feed/KAS-USD
// captured from a live round (arrays abridged)
{
"pair": "KAS/USD", "kind": "major",
"price": 0.02955, "price_e8": 2955000,
"mant": 295500000, "expo": -10,
"sources": [
{ "name": "Gate.io", "price": 0.02955, "age_ms": 12467 },
{ "name": "KuCoin", "price": 0.02955, "age_ms": 12890 },
{ "name": "MEXC", "price": 0.029564, "age_ms": 12279 }
],
"num_sources": 3, "outliers": [],
"halted": false, "degraded": false, "freshest_ms": 12279,
"low": 0.02955, "high": 0.029564, "spread_bps": 4.74,
"median": 0.02955, "twap": true, "liq_wkas": 0.0, "thin": false,
"signers": [ "0dd71bf25a98da9ff89a661537997fc9…", /* ×5 */ ],
"threshold": 3,
"signatures": [ "7eb9f2973d6976da3dc8bdc29d66e697…", /* ×5 */ ],
"message": "kaspulse/v2|KAS/USD|295500000|-10|1783791092|183775",
"signed_ts": 1783791092, "signed_round": 183775,
"history": [ [1783791030, 0.02955], /* ~120 [ts, price] points */ ]
}
The fields worth honoring. A consumer that reads price and ignores the flags is doing it wrong — the flags are the oracle telling you when not to trust the number:
| field | type | meaning |
| halted | bool | circuit breaker tripped — do not consume this round |
| thin | bool | shallow pool — price is real but easily moved; honor it |
| degraded | bool | fewer venues than nominal — widen your margins |
| peg_ok | bool | KRC-20 only — false when the WKAS/USDC peg looks off; absent on majors |
| freshest_ms | u64 | age of the freshest venue at serve time |
| outliers | [str] | venues MAD-filtered out of this round’s median |
Everything else:
| field | type | meaning |
| pair / kind | str | "KAS/USD" · "major" | "krc20" |
| mant / expo | u64 / i32 | the exact signed price: mant × 10^expo |
| price | f64 | convenience float of mant × 10^expo — display only |
| price_e8 | i64 | price × 1e8 — the unit on-chain covenants gate on |
| sources | [obj] | per-venue {name, price, age_ms} |
| num_sources | usize | venues in the median after filtering |
| low / high / spread_bps / median | f64 | venue range, spread (bps) and raw median this round |
| twap | bool | KRC-20 pool reads are time-weighted; always true today |
| liq_wkas | f64 | pool depth in WKAS (KRC-20; 0 on majors) |
| signers / signatures | [hex] | 5 x-only pubkeys / 5 BIP340 sigs, index-paired |
| threshold | u32 | signatures required (3) |
| message | str | the exact signed string — see “the signed message” |
| signed_ts / signed_round | u64 | what the signature covers (≠ envelope tick) |
| history | [[u64,f64]] | last ~120 [ts, price] points — the only history there is |
Catalog
GET/v1/feeds
The light board: one small row per pair, built once per round. This is what dashboards should poll. Catalog rows are not signed — verify a pair’s full feed before acting on it.
// captured from a live round (rows abridged)
{
"round": 183775, "timestamp": 1783791092, "count": 61,
"feeds": [
{ "pair": "KAS/USD", "kind": "major", "price": 0.02955,
"num_sources": 3, "halted": false, "degraded": false, "thin": false,
"liq_wkas": 0.0, "spread_bps": 4.74, "freshest_ms": 12279 },
{ "pair": "yoni/USD", "kind": "krc20", "price": 7.863e-9,
"num_sources": 1, "halted": false, "degraded": false, "thin": true,
"liq_wkas": 224.0, "spread_bps": 0.0, "freshest_ms": 3120 }
]
}
Health
GET/health
Liveness for uptime checks: status 200 when ok, 503 when not. ok := build_age_ms < 5000 && feeds_total ≥ 1 && feeds_live ≥ 1.
curl $ORIGIN/health
# → {"ok":true,"round":183775,"uptime_s":86432,"build_age_ms":412,
# "feeds_total":61,"feeds_live":61,"pools":58}
Share pages & OG cards
GET/share/{PAIR}
A crawler-visible page with OpenGraph meta (title, description, live price card) that redirects humans to the SPA’s #/feed/{PAIR}. Paste it in a chat and it unfurls with a live price card. Unknown pair → 404.
GET/og/{PAIR}.png
The 1200×630 card itself: pair, live price, sparkline, the trust line (median of N venues · 3-of-5 signed) and thin/halted badges rendered on the card. Only served by builds with the og feature (the deployed image); otherwise 404. Cached ~60s.
curl $ORIGIN/share/KAS-USD
curl -o card.png $ORIGIN/og/KAS-USD.png