Data layer
Why the internal API and not the official API (ADR-001). The
official OAuth API v2 is for creators. To register a client, you must have a creator account.
identity.memberships gives the memberships but not their posts. campaigns.posts gives only
the posts of the caller. There is no endpoint that gives the posts of the creators that a patron
pays. Thus Gopher calls the same internal API that the web site of Patreon calls, from inside the
browser of the user. It does not read cookies and does not use OAuth.
Verified endpoints
These are the only endpoints that the code calls:
| Endpoint | Function | Notes |
|---|---|---|
GET /api/current_user?include=active_memberships,active_memberships.campaign,active_memberships.reward | The memberships and campaigns | The memberships are in the active_memberships relationship, and their type is member. The memberships relationship gives an empty list. The include parameter is necessary; without it the API gives incomplete records. The relationship also contains free memberships and old memberships. member.attributes.is_free_member === false identifies a paid membership. |
GET /api/posts?filter[campaign_id]=<id> | The posts of one campaign | Gopher gets one page at a time with page[cursor] and page[count]. The endpoint answers with HTTP 429 when there are too many requests. |
The /api/posts query that Gopher sends (lib/patreon/endpoints.ts). The live API accepted
this query with HTTP 200 on 2026-08-30:
/api/posts?filter[campaign_id]=<id>&filter[is_draft]=false&sort=-published_at &page[count]=20&page[cursor]=<cursor> &include=campaign,attachments_media,images,audio,media,userGopher reads the cursor of the next page from meta.pagination.cursors.next. If that field is
not there, it reads page[cursor] from the links.next URL. Both fields are in the recorded
data.
Query format. One function, buildUrl(path, params), makes each URL. The parameters are
include=a,b with a dot for each level, fields[<type>]=name1,name2, filter[<key>]=value,
page[count|cursor|offset|size|number], json-api-use-default-includes=false, and sort. The
brackets go into the URL without a change. An include path can end with .null for an
optional relationship, for example audio.null.
Media relationships. The media of a post is in its relationships and not in one attribute.
The normalizer reads all four. attachments is for files such as ZIP and PDF, audio_file is
for audio, images is for a post with images, and media is for video. The
attributes.post_file and attributes.video fields alone are not sufficient. They do not show
all images of a post with more than one image. Other useful fields are alt_text, is_nsfw,
and current_user_can_view.
A third-party player is the one player of its post. Patreon also sends
attributes.post_file for a post that carries an embed, and sometimes a media entity beside
it. Both become a video. The bytes of that video are not on the CDN of Patreon: the stream is on
the server of the provider. Thus the panel showed an empty black player beside the link that
operates. collectMedia in lib/patreon/normalize.ts now removes each video of a post that
carries an embed. It gives the picture of that video to the embed first. The card removes
one as well (components/PostCard.tsx). A record that Dexie wrote before this rule still holds it.
current_user_can_view describes the session and not the post. A browser that is signed
out of Patreon still receives the feed. Each post in it comes back locked, with no body and with
fewer content classes. A plain write of that answer removed the content that Gopher read with a
session. The panel then said “locked” for a post whose pictures are in the cache. Thus
putPostPage merges a post field by field with mergePost (lib/db.ts):
- A poorer answer refreshes only what it is authoritative about. It keeps the body, the content classes, and the count of media that the cache holds.
- It always carries the newest
canView, because the panel must say which posts the session can open now. - It never removes
everViewable. That field remembers that an answer once opened the post, and it is what lets a user read the cache with no session.
The panel reads both fields. A lock notice is only for a post that no answer ever opened. For a
post that the cache holds, the card says that the copy comes from the cache
(components/PostCard.tsx). The photo view keeps the pictures too (see the panel).
isEverViewable() in lib/patreon/types.ts is the one reader of the field. A record that Dexie
wrote before the field existed has no value for it, and canView was the newest answer at that
time.
Maintenance
These endpoints have no documentation and can change at any time. Record the current data with
scripts/inspect-api.ts and keep all calls in one layer of code. Then a change is a repair in
one file. npm run normalize:check reads the recorded data again and finds a change early.
npm run bundle:mine reads the JavaScript files of the web site and makes a list of endpoints.
It sends no request to /api/. That list contains approximately 60 endpoints, and some of them
are sensitive, for example /api/enterprise/login-as/{id}. Decrease the list to the endpoints
that Gopher calls before you release the extension.