The pitch for a unified API is easy to say and easy to get wrong: "one response shape for every platform". Taken literally it is impossible, because platforms do not have the same things. A GitHub repository has stars and a default branch. A Hacker News story has points. A weather station has a temperature. What can be unified is the vocabulary for the parts that overlap, and an honest place for everything else.
This post describes where Onekey draws that line.
The numbers, and how to check them#
On 2026-09-30 the registry held 266 platforms and 1,605 endpoints, of which 1,271 are marked stable. You do not have to take that from a blog post. GET /v1/platforms is free, needs no key and returns every endpoint with its price, status and parameters:
import requests
r = requests.get("https://api.onekey.dev/v1/platforms", timeout=30).json()
endpoints = [e for p in r["platforms"] for e in p["endpoints"]]
print(len(r["platforms"]), "platforms,", len(endpoints), "endpoints,",
sum(e["status"] == "stable" for e in endpoints), "stable")The counts grow as endpoints are added, so run it rather than trust the figures above once they are old.
What is normalised#
Every endpoint maps its source onto typed groups. The groups are entity (with entity.stats for shared counters), content, author, engagement, media, and a few domain groups: market, place, listing, package, paper and event. The full list and every field's type are on the unified schema page.
Three real responses, recorded on 2026-09-30, show the effect. Different platforms, different kinds of thing, one vocabulary:
| What | Endpoint | Where the interesting bits landed |
|---|---|---|
| A repository | github.repo | entity.name, entity.stats.stars, entity.language, package.pushedAt |
| A story | hackernews.item | content.title, content.createdAt, author.username, engagement.likes |
| An article | devto.article | content.title, content.language, author.displayName, engagement.likes, engagement.comments |
And underneath the names, the rules that make them dependable:
- Types are enforced, not trusted. Counts are numbers, ids are strings, booleans are real booleans. A value that fails the check becomes
nullinstead of a string that breaks your parser. - Dates are ISO 8601 UTC.
2007-04-04T19:16:40Zon a Hacker News item from 2007 and2026-09-29T06:11:11Zon a DEV article from this week have the same format. - Links are absolute
httporhttpsURLs. Anything else isnull. - Text is plain. HTML tags are removed and entities decoded.
- Ids are strings, including numeric ones such as
"8863", so you never lose precision or guess the type.
The payoff is that a function reading content.title and engagement.likes works on both the Hacker News item and the DEV article without a branch:
def headline(item):
c, e = item.get("content") or {}, item.get("engagement") or {}
return c.get("title"), e.get("likes") # None when unknown, never a guessWhat is not normalised#
This is the part unified APIs tend to skip.
Meaning can differ even when the name matches. The schema has no points field, so Hacker News points are mapped to engagement.likes, and the DEV reaction count goes to the same place. They are both "the number people signalled approval with", but they are not the same quantity. The mapping is documented on each endpoint page and the caveat is real: compare within a platform, be careful across them.
Sometimes there is no good home. Bluesky's trending topics report a postCount. The schema has no post-count field, so it is mapped to engagement.comments. The data snapshot of Bluesky trending topics says exactly this next to the numbers, and calls the column "posts". If a field ends up somewhere approximate, the honest thing is to say so in the place people read it.
Platform-specific extras go in ext. On a Hacker News user, karma has no cross-platform meaning, so it lives under ext:
{ "author": { "id": "pg", "username": "pg" }, "ext": { "karma": 157316 }, "content": { "text": "Bug fixer." } }ext is one flat level of camelCase names, listed on each endpoint's page. A name is stable for its endpoint, but the same name on another endpoint is not promised to mean the same thing, and ext is not part of the cross-platform contract. That is the trade: the fixed schema is small and strict so that you can rely on it, and ext is wide and loose so that nothing useful is thrown away. Even long text can land there: the DEV article's full body is in ext, next to a short excerpt in content.text.
Nothing is inferred. A field the source does not have is not synthesised from another. If an endpoint does not map a group at all, the group is absent. The rule for one endpoint is fixed: every mapped field is present in every item, as a value or null, so the set of keys does not change from item to item.
Promotion#
When an ext name turns out to be useful on many platforms, it can be promoted into the fixed schema. The ext copy is kept alongside for a while when that happens, so your code does not break on the day of the change. The changelog is where changes are announced.
How to work with it#
- Write cross-platform code against fixed-schema fields only.
- Read the Field mapping table on the endpoint page before you rely on a field. It lists exactly what that endpoint emits.
- Treat
extas a bonus, and pin the endpoint when you use it. - Keep nulls as nulls.
The stable and beta labels matter here too: a stable endpoint has been checked against the live source, and a beta one has not, so a field may be null where you expected a value. The details are on the data availability page, and the terms are in the glossary: canonical schema, ext field and stable and beta.