# Post and author keys

Every client (extension, userscript, Android, iOS, the Hush apps) turns a post into the same short string, called a key, so a report made on one client matches a lookup made on another. `test-vectors.json` holds the cases each client's test suite has to pass.

The prefix names the platform: `fb:` Facebook, `tt:` TikTok, `ig:` Instagram, `yt:` YouTube, `tw:` X, `rd:` Reddit. A report's `platform` field for each is `facebook`, `tiktok`, `instagram`, `youtube`, `x` or `reddit`. A key that doesn't match one of the kinds below isn't a key, and the server refuses it.

## Facebook

```
fb:<kind>:<id>      a post
fb:author:<id>      the profile or Page that posted it
```

| Kind | Comes from | Id rule |
|---|---|---|
| `post` | `/<user>/posts/<id>`, `/<user>/posts/<slug>/<id>` (Facebook's canonical form, digits only, and it wins over the slug even when the slug is digits too), `/groups/<g>/posts/<id>`, `/groups/<g>/permalink/<id>`, `permalink.php?story_fbid=<id>`, `story.php?story_fbid=<id>` | digits (1 to 25) or `pfbid` plus 10 to 80 letters and digits, case kept |
| `photo` | `photo.php?fbid=<id>`, `/photo/?fbid=<id>`, `/<user>/photos/<album>/<id>` | digits |
| `video` | `/watch/?v=<id>`, `/watch/live/?v=<id>`, `/<user>/videos/<id>`, `/<user>/videos/<slug>/<id>` | digits |
| `reel` | `/reel/<id>` | digits |
| `share` | `/share/p/<t>`, `/share/r/<t>`, `/share/v/<t>`, `/share/<t>` | 4 to 64 of `A-Z a-z 0-9 _ -`, case kept |
| `watch` | `fb.watch/<t>` | 4 to 64 of `A-Z a-z 0-9 _ -`, case kept |

`share` and `watch` keys are short links (see [Short links](#short-links)). A `/share/<x>/<t>` link with any other `<x>` gives no key, since it's some other kind of share.

### Facebook authors

| Source | Key |
|---|---|
| `/<user>/posts/...`, `/<user>/videos/...`, `/<user>/photos/...`, or a bare `/<user>` | `fb:author:<user>` lowercased |
| `profile.php?id=<id>` | `fb:author:<id>` |
| `/people/<name>/<id>` | `fb:author:<id>` |
| `permalink.php` or `story.php` with `id=<id>` | `fb:author:<id>` |

A user name is 2 to 80 of `A-Z a-z 0-9 .` and is lowercased, so an author key's id is digits (1 to 25) or 2 to 80 of `a-z 0-9 .`. Group posts carry no author key, because the group is a place, not the poster.

These first path segments are never user names: `groups`, `watch`, `reel`, `reels`, `share`, `photo`, `photo.php`, `permalink.php`, `story.php`, `profile.php`, `people`, `events`, `marketplace`, `gaming`, `pages`, `help`, `login`, `login.php`, `hashtag`, `search`, `stories`, `notifications`, `messages`, `friends`, `bookmarks`, `settings`, `ads`, `business`, `l.php`, `sharer`, `sharer.php`, `dialog`, `plugins`, `privacy`, `policies`, `legal`, `home.php`, `media`, `video.php`.

### Facebook hosts

Accepted: `facebook.com`, `www.`, `web.`, `m.`, `mbasic.`, `touch.` and `business.` subdomains, and `fb.watch`. A URL without a scheme gets `https://`. Anything else, including `l.facebook.com` redirect links, gives no key. Query parameters other than the ones named above are ignored.

## TikTok

```
tt:video:<aweme id>     a video or a photo post (they share one id space)
tt:share:<token>        a short link TikTok mints
tt:author:<uid>         the account that posted it
```

| Kind | Comes from | Id rule |
|---|---|---|
| `video` | `/@<user>/video/<id>`, `/@<user>/photo/<id>`, `/v/<id>.html`, `/embed/v2/<id>`, `/embed/<id>`, `/player/v1/<id>` | digits (1 to 25) |
| `share` | `vm.tiktok.com/<t>`, `vt.tiktok.com/<t>`, `/t/<t>` | 4 to 64 of `A-Z a-z 0-9 _ -`, case kept |

Hosts: `tiktok.com` with or without `www.` or `m.`, plus `vm.` and `vt.` for short links.

## Instagram

```
ig:media:<media pk>     a post, reel, IGTV video or story item
ig:share:<token>        a share link Instagram mints
ig:author:<owner pk>    the account that posted it
```

| Kind | Comes from | Id rule |
|---|---|---|
| `media` | `/p/<code>`, `/reel/<code>`, `/reels/<code>`, `/tv/<code>`, the same after `/<user>/`, and `/stories/<user>/<pk>` | a pk: the shortcode decoded (below), or a story's pk as the link writes it |
| `share` | `/share/<t>`, `/share/p/<t>`, `/share/reel/<t>` | 4 to 64 of `A-Z a-z 0-9 _ -`, case kept |

A pk is written in digits with no leading zero, from 1 to 9223372036854775807 (2^63 - 1), so 64-bit clients agree with the rest. A story link with any other number gives no key. `/stories/highlights/<id>` gives none either, because that id names a whole highlight reel, not one story. As with Facebook, a `/share/<x>/<t>` link with any other `<x>` gives no key.

A shortcode is the media pk written in base 64, most significant digit first, with the alphabet `A-Z a-z 0-9 - _` (`A` is 0, `_` is 63). Only 10 or 11 character shortcodes count, since shorter words sit in the same spot (`/reels/audio/...`). A shortcode that decodes to 0 or past 2^63 - 1 gives no key. The key holds the pk, not the shortcode, because that's the id the Instagram app holds in memory.

Hosts: `instagram.com` with or without `www.` or `m.`, and `instagr.am` with or without `www.`.

### Author keys on TikTok and Instagram

Links on these two platforms name the account by its handle, and handles change. So their links never give an author key. `tt:author:<uid>` and `ig:author:<owner pk>` come only from clients that read the numeric id from the app or page itself, such as the Hush apps' report action. The own server's appeal form also opens a TikTok account's page once to read its uid, for a creator who sends their page's link. A TikTok uid is digits (1 to 25). An Instagram owner pk follows the pk rule above.

## YouTube

```
yt:video:<video id>     a video, a Short or a live stream (they share one id space)
yt:author:<channel id>  the channel that posted it
```

| Kind | Comes from | Id rule |
|---|---|---|
| `video` | `/watch?v=<id>`, `/shorts/<id>`, `/live/<id>`, `/embed/<id>`, `/v/<id>`, `youtu.be/<id>` | exactly 11 of `A-Z a-z 0-9 _ -`, case kept |
| `author` | `/channel/<id>`, with anything after it | `UC` plus exactly 22 of `A-Z a-z 0-9 _ -`, case kept |

Handles (`/@name`) and the old `/c/<name>` and `/user/<name>` links give no author key, since a channel can change them. Hosts: `youtube.com` with or without `www.`, `m.` or `music.`, `youtube-nocookie.com` with or without `www.`, and `youtu.be`. A YouTube link has no minted share token. `youtu.be/<id>` carries the video id itself, and its `si` parameter is ignored like every other one.

## X

```
tw:post:<status id>     a post, with or without photos or a video
tw:author:<user id>     the account that posted it
```

| Kind | Comes from | Id rule |
|---|---|---|
| `post` | `/<user>/status/<id>`, the same with `/photo/<n>` or `/video/<n>` after it, `/<user>/statuses/<id>`, `/i/status/<id>`, `/i/web/status/<id>` | digits (1 to 25) |
| `author` | `/i/user/<id>`, `/intent/user?user_id=<id>` | digits (1 to 25) |

Hosts: `x.com` and `twitter.com`, each with or without `www.` or `mobile.`. A handle can change, so `/<user>` alone gives no author key. `t.co` links can lead anywhere and give no key.

## Reddit

```
rd:post:<post id>       a post of any kind: text, link, image, gallery or video
rd:author:<user name>   the account that posted it
rd:share:<sub>/<token>  a share link the Reddit app makes
```

| Kind | Comes from | Id rule |
|---|---|---|
| `post` | `/r/<sub>/comments/<id>`, `/comments/<id>`, `/user/<name>/comments/<id>`, `/u/<name>/comments/<id>`, `/gallery/<id>`, `redd.it/<id>`, each with anything after it | 1 to 13 of `0-9 a-z`, lowercased |
| `author` | `/user/<name>`, `/u/<name>`, with anything after it but `/comments/<id>` | 3 to 20 of `a-z 0-9 _ -`, lowercased |
| `share` | `/r/<sub>/s/<token>`, with nothing after the token | `<sub>/<token>` as written: 2 to 21 of `A-Z a-z 0-9 _`, a slash, then 4 to 32 of `A-Z a-z 0-9` |

Reddit post ids are base 36 and user names can't be changed, so a profile link's user name is a safe author key (Reddit treats it without regard to case). A post link gives the post and no author, `/user/<name>/comments/<id>` included, because Reddit finds a post by its id whatever name comes before it, so anyone could pin a post on someone else's name. Hosts: `reddit.com` with or without `www.`, `old.`, `new.`, `m.` or `np.`, and `redd.it`.

A share link opens only under the subreddit it was made in, spelled the way the link spells it. Checked on 2026-10-20, `/r/gnome/s/keG0u7viaw` leads to its post and `/r/GNOME/s/keG0u7viaw` leads to r/GNOME's front page. So the key keeps the sub and the token exactly as the link has them, and doesn't lowercase either one.

## Reading a link

Clients read a link the way a browser's URL parser would. A `.` path segment drops out and `..` removes the segment before it (`%2e` counts as a dot). A backslash before the query counts as a slash. A port has to be digits up to 65535, or the link gives no key. Matching is on the decoded segments. Each client compares code points, so a combining mark after `@` or `/` doesn't change the result.

Spaces and control characters at either end drop off: U+0000 to U+0020, U+007F to U+00A0, U+1680, U+2000 to U+200A, U+2028, U+2029, U+202F, U+205F, U+3000 and the byte order mark U+FEFF. One of them anywhere else means the link gives no key.

The host has to come right after the `//` (and any `user@`) and be written in plain ASCII letters, digits, dots and hyphens. It's lowercased only after that check. A browser would still open `https://youtu%2Ebe/<id>`, a full-width host, `https:///youtu.be/<id>` or `//youtu.be/<id>`, but those give no key, because some clients split links by hand and a real share link never looks like that.

## Checking a key

Each kind takes only the id rule in its table, and a key with any other id isn't a key. An undecoded shortcode in an `ig:media` key fails the pk rule, and so does a leading zero. So a client that skips a step sends a key the server refuses, not one that quietly splits a post's votes. `test-vectors.json` lists keys on both sides of each rule.

## Short links

`fb:share`, `fb:watch`, `tt:share`, `ig:share` and `rd:share` keys are tokens the platform mints on its servers. The same post shared twice can get two different tokens. Clients send them as they are. Following the redirect to the real key is the server's job, not the client's. The Report Worker does it once per token, counts the vote on the post and lists the token's hash with the post's entry ([api.md](api.md#short-links)), so a client looks a token up like any other key.

`rd:share` is the one exception. Reddit turns some servers' addresses away (a VPS's address got `403` on every request on 2026-10-20, while Cloudflare's network and a home connection got the redirect), so a server there can never open the link. The Android and iPhone apps get these links from the share sheet, and they open `https://www.reddit.com/r/<sub>/s/<token>` once themselves without following it. When its `Location` is an `https` Reddit post address, they check and report that post's `rd:post` key and send its address with the query and any fragment cut off, since `share_id` names the person who shared it. Anything else keeps the share key.

## Hashing

```
keyHash = first 16 hex characters of lowercase hex SHA-256 of the UTF-8 key
prefix  = first 4 hex characters of keyHash
```

The server publishes labels by `keyHash` only. Clients hash locally and look up. The mobile apps ask for a `prefix` and match the full hash on the device, so the server never learns which post someone is looking at.

## Labels

The published list, format 2 in [api.md](api.md#publishing), gives `[code, ai, notAi]` for every post with at least one AI report and a vote in the last 30 days, so each app can apply the threshold its user picked.

| Code | Name | Rule (ai and notAi are vote weights, reporters is the number of distinct ai voters) |
|---|---|---|
| 1 | reported | reporters >= 3 and ai / (ai + notAi) >= 0.7 |
| 2 | widely reported | reporters >= 10 and ai / (ai + notAi) >= 0.85 |
| 3 | disputed | reporters >= 3 and ai / (ai + notAi) >= 0.4 and below 0.7 |
| 4 | author often reported | the author has 3 or more posts at code 1, 2, 5 or 8 |
| 5 | confirmed | the list's owner looked at the post and ruled it AI-made, whatever the votes say |
| 6 | creator ruled AI | the list's owner looked at the creator and ruled their posts AI-made, whatever the votes say |
| 7 | ruled real | the list's owner looked at the post and ruled it real, whatever the votes or its creator's entry say |
| 8 | noted | an X Community Note rated helpful says the post was made with AI, unless the owner ruled on the post |
| 9 | named | AiSList, a community list of AI YouTube channels, names the creator, unless the owner ruled on them or their posts make them code 4 |

Codes 8 and 9 come from outside lists, which the server reads again every day (see [api.md](api.md#outside-lists)), so an entry goes when its source drops it. Neither is the owner's own check. For a post, the owner's ruling comes first (5 and 7), then code 8, then the crowd's codes. For a creator, the owner's ruling comes first (6, or none for one ruled real), then code 4, then code 9. Code 8 is `[8, ai, notAi]` with the post's counts, `[8, 0, 0]` when nobody reported it, and counts toward its creator's reported posts like code 1. Code 9 is `[9, reportedPosts, 0]` under the channel's author key hash.

In `[code, count]`, `count` is the reporter count for codes 1 to 3 and 5 and the number of reported posts for code 4, and a post below 0.4 isn't published, because the crowd thinks it's real. Format 2 lists that post anyway, and any post short of a label, with code 0, which a client that only shows labels skips. An author there is `[4, reportedPosts, 0]`, and a post reported under several keys is one of them once the server knows its keys go together ([api.md](api.md#reporters)). A post the owner ruled AI is `[5, ai, notAi]`, or `[5, 0, 0]` when nobody reported it. One the owner ruled real is `[7, ai, notAi]`, listed even with no votes so it can beat its creator's code 6. A creator the owner ruled AI is listed under their author key's hash as `[6, reportedPosts, 0]`, `[6, 0, 0]` when none of their posts is reported, and that beats the 3-post rule. A creator ruled real isn't listed, however many of their posts are reported. Their posts keep their own entries either way.

A server with the bridging rule on (`BRIDGE_DISPUTES=1`, off by default) lists a code 3 post as code 1 once installs that usually disagree both call it AI. That takes AI votes from two networks whose installs voted "Looks real" on most of their other posts, and from a third whose install voted AI on most of its. An install counts toward it once it has at least 5 votes on other posts and a weight above 0. A vote it cast in its first days doesn't. "Looks real" votes cost nothing, so a skeptic also needs the owner's rulings to have agreed with it at least twice and more often than they disagreed. Rulings only grade votes cast before them (see [api.md](api.md#standing-and-new-installs)), so copying the published ones doesn't earn it. The server checks a post again whenever its own votes change and checks every post in its daily rebuild, so when a voter's record elsewhere or a ruling tips it, the list can take up to a day to follow. A lookup answers with the check as it stands. A post lifted this way counts toward its creator's reported posts (code 4) like any other code 1 post.

Client wording never says a post *is* AI. It says how many people reported it, for code 5 that the list's owner checked it, and for code 6 that the owner checked the account that posted it. A site's own AI label, which the browser clients read from the page with no list involved, is passed on in the site's name ("TikTok labeled this video as AI-made"), never as a report. A client counts code 5 as at least as strong as code 2, whatever vote threshold its user picked. A post whose creator is code 6 counts the same, and so do its hide rules, unless the post's own entry is code 5, which comes first. A client from before code 6 skips it like any code it doesn't know, so it shows that creator's posts by their own entries. A code 7 post shows with no label, no count and no word about its creator, whatever the rest of the list says, and it isn't counted as a label. The share-sheet apps say the list's owner checked it and marked it real. A client from before code 7 skips it the same way, so it goes by the post's creator. For code 8 the wording says an X Community Note says the post was made with AI, with the report count when there is one, and links the note where it can. For code 9 it says AiSList, a community list, names the channel as AI. Neither ever says the list's owner checked it. A client counts code 8 as at least as strong as code 2, like code 5, unless the post's "Looks real" votes outnumber its AI reports. A code 9 creator's posts show that line the way a code 4 creator's posts show theirs, and it never hides a post or turns on a creator's hide rules. A client from before codes 8 and 9 skips them like any code it doesn't know, so it shows a code 8 post with no label and a code 9 creator's posts by their own entries.

Every client that hides posts offers the same **Hide after** choice: 1, 2, 3, 5 or 10 AI reports, starting at 2. A saved value that isn't one of those reads as 2. A post hides at a choice when its entry has at least that many AI reports and more of them than "Looks real" votes, so a tie stays up. Code 5 hides at every choice, code 8 at every choice unless its "Looks real" votes outnumber its AI reports, and code 7 at none. Codes 4, 6 and 9 are an author's entries, never a post's, so they don't count here (a code 6 creator still hides their posts as above). A post the user called "Looks real" themselves doesn't hide by its count. Under 1, the client warns that a single report hides a post, so some real ones will go too. The `hideAfter` section of [test-vectors.json](test-vectors.json) runs each choice against a set of entries. The share-sheet apps hide nothing, so they call the same choice **Warn after**: a post that would hide at it gets a highlighted card, and one under every label (code 0) shows its count there. A list.json has no "Looks real" counts, so they read as 0 from it.
