Skip to content

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:

EndpointFunctionNotes
GET /api/current_user?include=active_memberships,active_memberships.campaign,active_memberships.rewardThe memberships and campaignsThe 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 campaignGopher 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,user

Gopher 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.