RSS Network · Protocol
RSS Network protocol, version 1
What a reader server publishes to join rssnetwork.net, and what the hub publishes back. Every
document is public JSON with a version (currently 1). Examples below use the
reserved example.org domain and are the exact files the hub's tests run against.
1. Registration record
Published on the reader host itself at https://<server>/.well-known/rssnetwork.json.
It is the proof of control: only someone who runs the domain can publish it. server must
equal that host; sharesUrl must be a bare https:// origin (no path, port or IP).
Set listed to false (or remove the file) to leave.
{
"version": 1,
"server": "reader.example.org",
"listed": true,
"sharesUrl": "https://shares.example.org",
"requestedAt": "2026-10-12T00:00:00.000Z"
}
To register or leave, publish the record, then send a nudge. The body carries nothing else; the hub reads everything from your domain.
POST https://rssnetwork.net/api/register
Content-Type: application/json
{ "server": "reader.example.org" }
Responses: 202 pending (waiting for approval), 200 listed / delisted /
suspended / withdrawn, 422 with the reason the documents failed. The current status is
always at https://rssnetwork.net/status/<server>.json:
pending, listed, rejected, suspended or
delisted, with an optional public reason.
2. Server profile
<sharesUrl>/server.json. name is required. avatarUrl
must be an absolute https URL on the sharesUrl origin; the hub links to it
and never copies images. Text is clipped: name 80, description
1000, operator 200 characters; at most 8 http(s) links.
{
"version": 1,
"server": "reader.example.org",
"updatedAt": "2026-10-12T00:00:00.000Z",
"name": "Example Reader",
"description": "A small reader server for a handful of friends.",
"operator": "Example Operator",
"website": "https://example.org/",
"links": [
{ "label": "About", "url": "https://example.org/about" }
],
"avatarUrl": "https://shares.example.org/avatar.jpg?v=1791350689817"
}
3. People sharing
<sharesUrl>/shares/shares.json, keyed by handle. Handles match
^[a-z0-9-]+$; shares is reserved. Each feed's basename is
saved, tag-<id> or cat-<id>, and its
xml/html must sit under <sharesUrl>/shares/<handle>/.
Bad entries are dropped, not the whole file. Text is clipped: display name 80, bio
500, feed title 200, feed description 500. All text is
displayed as text, never HTML.
{
"version": 1,
"server": "reader.example.org",
"updatedAt": "2026-10-12T00:00:00.000Z",
"users": {
"alice": {
"displayName": "Alice",
"bio": "Science and space.",
"website": "https://alice.example.org/",
"links": [{ "label": "Blog", "url": "https://alice.example.org/blog" }],
"avatarVersion": 1791350689817,
"avatarUrl": "https://shares.example.org/shares/alice/avatar.jpg?v=1791350689817",
"feeds": [
{
"basename": "saved",
"title": "alice’s saved stories",
"description": "Every story saved by alice.",
"xml": "https://shares.example.org/shares/alice/saved.xml",
"html": "https://shares.example.org/shares/alice/saved.html"
},
{
"basename": "tag-x1",
"title": "alice · Space",
"xml": "https://shares.example.org/shares/alice/tag-x1.xml",
"html": "https://shares.example.org/shares/alice/tag-x1.html"
}
]
},
"bob-2": {
"displayName": "Bob <b>Bold</b>",
"bio": "<script>alert(1)</script> & \"quotes\" — must render as text.",
"links": [],
"feeds": [
{
"basename": "cat-f1v12u",
"title": "bob-2 · News",
"xml": "https://shares.example.org/shares/bob-2/cat-f1v12u.xml"
}
]
}
}
}
4. Shared feed items
Each shared RSS feed is ordinary RSS 2.0. Items add two elements in the namespace
https://gghc.tools/ns/reader/1 (an identifier only; it is never fetched):
<rss version="2.0" xmlns:reader="https://gghc.tools/ns/reader/1">
<channel>
<item>
<title>…</title>
<link>https://shares.example.org/shares/alice/story/abc.html</link>
<guid isPermaLink="false">abc</guid>
<reader:original>https://www.example.com/2026/10/rates-explained</reader:original>
<reader:sharedAt>2026-10-07T05:31:12Z</reader:sharedAt>
</item>
</channel>
</rss>
reader:original is the article the person shared; reader:sharedAt is when.
List each article once per feed.
5. articleKey, version 1
The cross-server identity of an article, used to collapse copies and count shares:
articleKey = sha256(normalise(url)), first 16 hex characters, where url is
reader:original when present, else the item's link. normalise,
in order:
- Parse as a URL; anything but http(s) has no key.
- Force
https; drop the default port. - Lowercase the host; drop a leading
www.. - Drop the fragment, username and password.
- Drop tracking parameters
utm_*,fbclid,gclid,at_*; keep the rest, sorted by name (stable for repeats). - Drop trailing slashes from the path.
Any change to these rules is a new version. Test vectors (identical to the reader's):
| URL | Normal form | articleKey |
|---|---|---|
https://www.bbc.co.uk/news/articles/cx62y37r1p8eo?at_medium=RSS&at_campaign=rss | https://bbc.co.uk/news/articles/cx62y37r1p8eo | fa0f7dc0a94a2402 |
https://bbc.co.uk/news/articles/cx62y37r1p8eo | https://bbc.co.uk/news/articles/cx62y37r1p8eo | fa0f7dc0a94a2402 |
http://www.Example.COM/a/b/?utm_source=x&id=7&utm_medium=rss | https://example.com/a/b?id=7 | 7e56d8635def5a2b |
https://example.com:443/a#comments | https://example.com/a | 2dce0a4c50441bfc |
http://example.com:80/a | https://example.com/a | 2dce0a4c50441bfc |
https://example.com:8443/a | https://example.com:8443/a | be01f59355334499 |
https://example.com/a?b=2&a=1 | https://example.com/a?a=1&b=2 | 051029b6a13fc668 |
https://example.com/a?fbclid=abc&gclid=def | https://example.com/a | 2dce0a4c50441bfc |
https://user:pw@example.com/a | https://example.com/a | 2dce0a4c50441bfc |
https://example.com/ | https://example.com | 100680ad546ce6a5 |
https://example.com/Path/Case | https://example.com/Path/Case | 39de57c7165848ef |
https://example.com/a | https://example.com/a | 2dce0a4c50441bfc |
https://example.com/a?ref=home | https://example.com/a?ref=home | 34fdc44ce70d914f |
javascript:alert(1) | — (no key) | — |
mailto:a@example.com | — (no key) | — |
not a url | — (no key) | — |
| — (no key) | — |
6. What the hub publishes
All public, static, CORS *, cached 5 minutes. updatedAt is when the data
last changed. The hub recrawls listed servers once a day; a server that can't be reached keeps its
last good copy and shows stale after 1 day and unreachable after 7, and its
people are hidden after 30.
/servers.json— listed servers with their profiles/servers/<server>.jsonand.html— one server and its people/people.json— everyone sharing on every listed server/status/<server>.json— registration status
7. Validation cases
How the hub treats documents that differ from the examples above in one way.
| Document | Case | Result | Note |
|---|---|---|---|
record | record-wrong-version | reject | unknown protocol version |
record | record-server-mismatch | reject | server must equal the host it was fetched from |
record | record-shares-http | reject | sharesUrl must be https |
record | record-shares-path | reject | sharesUrl must be a bare origin |
record | record-shares-private | reject | IP literal / private address |
record | record-listed-false | delist | operator asked to leave |
record | record-not-object | reject | not a JSON object |
server | server-wrong-version | reject | |
server | server-server-mismatch | reject | |
server | server-no-name | reject | name is required |
server | server-avatar-off-origin | accept | off-origin avatar is dropped, the profile is kept |
server | server-long-text | accept | |
server | server-bad-link | accept | |
shares | shares-wrong-version | reject | |
shares | shares-server-mismatch | reject | can't publish another server's people |
shares | shares-users-not-object | reject | |
shares | shares-bad-handle | accept | |
shares | shares-reserved-handle | accept | "shares" is reserved by the layout |
shares | shares-feed-off-origin | accept | |
shares | shares-feed-other-handle | accept | a feed must live under its own handle's path |
shares | shares-bad-basename | accept | |
shares | shares-avatar-off-origin | accept | |
shares | shares-long-bio | accept |