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:

  1. Parse as a URL; anything but http(s) has no key.
  2. Force https; drop the default port.
  3. Lowercase the host; drop a leading www..
  4. Drop the fragment, username and password.
  5. Drop tracking parameters utm_*, fbclid, gclid, at_*; keep the rest, sorted by name (stable for repeats).
  6. Drop trailing slashes from the path.

Any change to these rules is a new version. Test vectors (identical to the reader's):

URLNormal formarticleKey
https://www.bbc.co.uk/news/articles/cx62y37r1p8eo?at_medium=RSS&at_campaign=rsshttps://bbc.co.uk/news/articles/cx62y37r1p8eofa0f7dc0a94a2402
https://bbc.co.uk/news/articles/cx62y37r1p8eohttps://bbc.co.uk/news/articles/cx62y37r1p8eofa0f7dc0a94a2402
http://www.Example.COM/a/b/?utm_source=x&id=7&utm_medium=rsshttps://example.com/a/b?id=77e56d8635def5a2b
https://example.com:443/a#commentshttps://example.com/a2dce0a4c50441bfc
http://example.com:80/ahttps://example.com/a2dce0a4c50441bfc
https://example.com:8443/ahttps://example.com:8443/abe01f59355334499
https://example.com/a?b=2&a=1https://example.com/a?a=1&b=2051029b6a13fc668
https://example.com/a?fbclid=abc&gclid=defhttps://example.com/a2dce0a4c50441bfc
https://user:pw@example.com/ahttps://example.com/a2dce0a4c50441bfc
https://example.com/https://example.com100680ad546ce6a5
https://example.com/Path/Casehttps://example.com/Path/Case39de57c7165848ef
https://example.com/a https://example.com/a2dce0a4c50441bfc
https://example.com/a?ref=homehttps://example.com/a?ref=home34fdc44ce70d914f
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.

7. Validation cases

How the hub treats documents that differ from the examples above in one way.

DocumentCaseResultNote
recordrecord-wrong-versionrejectunknown protocol version
recordrecord-server-mismatchrejectserver must equal the host it was fetched from
recordrecord-shares-httprejectsharesUrl must be https
recordrecord-shares-pathrejectsharesUrl must be a bare origin
recordrecord-shares-privaterejectIP literal / private address
recordrecord-listed-falsedelistoperator asked to leave
recordrecord-not-objectrejectnot a JSON object
serverserver-wrong-versionreject
serverserver-server-mismatchreject
serverserver-no-namerejectname is required
serverserver-avatar-off-originacceptoff-origin avatar is dropped, the profile is kept
serverserver-long-textaccept
serverserver-bad-linkaccept
sharesshares-wrong-versionreject
sharesshares-server-mismatchrejectcan't publish another server's people
sharesshares-users-not-objectreject
sharesshares-bad-handleaccept
sharesshares-reserved-handleaccept"shares" is reserved by the layout
sharesshares-feed-off-originaccept
sharesshares-feed-other-handleaccepta feed must live under its own handle's path
sharesshares-bad-basenameaccept
sharesshares-avatar-off-originaccept
sharesshares-long-bioaccept