Birchfeed Developers

Add Birchfeed to your reader

Birchfeed is an RSS syncing service. It polls the feeds, keeps one copy of every article, and remembers what has been read and starred. Your app does not have to fetch a single feed: it syncs against us.

It speaks two protocols that reader apps already implement, so supporting it is usually a URL and an account type rather than a new client. This page is the whole surface, including the parts that are not implemented.

Three ways in, pick one

All three end up at the same account and the same data. They differ only in how much you build and how much the person using your app has to type.

PathWhat you buildWhat the user does
Drop inNothing, if you already support FreshRSS or Google Reader. Point at our URL.Pastes an email address and an API key.
FeverNothing, if you already support Fever.Pastes an email address and an API key.
Full integrationA "Birchfeed" account type and the connect flow below. Sync is still the Reader API.Taps Connect, approves in the browser, done. No token, ever.

The honest recommendation: ship the drop-in path first. It costs you a settings entry and it works today. Add the connect flow when Birchfeed is worth a named account type in your app, because that is the part that makes it feel built in rather than configured.

Base URLs

ProtocolURL
Google Reader / FreshRSShttps://birchfeed.app/api/greader.php
Feverhttps://birchfeed.app/api/fever.php

The Reader API is also served from the root (https://birchfeed.app/reader/api/0/...), because some clients build their own paths. Both are the same API. Prefer the /api/greader.php form: it is what every FreshRSS setup guide tells users to paste, so it is the string they will already have.

Trailing and duplicate slashes are tolerated on every route, because clients concatenate a user-typed server URL with an API path and users type trailing slashes.

API keys

Every request is authenticated by an API key: a 46-character string that starts with bf_. One token is one app on one device. The user creates them under Settings, and the connect flow below mints them without the user ever seeing one.

A token is stored only as a SHA-256 digest, so it is shown exactly once, at the moment it is created. There is no endpoint that reads it back. Revoking one in Settings takes effect on the next request.

ProtocolUsername fieldPassword field
Reader APIThe account email addressThe API key
FeverNot sentapi_key = md5(lowercase_email + ":" + token)

Store it in the keychain, or wherever your platform keeps a password. It grants full read and write access to the account's feeds and reading state. It does not grant access to the password, the passkeys, or account deletion.

The connect flow

This is how an app gets a token without asking anyone to copy and paste one. It is PKCE (RFC 7636) with a fixed, pre-registered redirect. Register your app first (see below), then:

  1. Make a verifier and a challenge. The verifier is 43 to 128 random characters. The challenge is its SHA-256 in unpadded base64url, which is always exactly 43 characters. Keep the verifier in memory; it never leaves the device.
  2. Open the approve page in the system browser. Not an embedded web view: the user may need to sign in with a passkey, and they should be able to see the address bar of the site they are approving.
    https://birchfeed.app/connect?client=YOUR_CLIENT_ID&challenge=<challenge>
  3. The user approves. If they are signed out they sign in first and come back automatically. If the account is brand new they finish setup first, so your app is never connected to an empty account.

    The pages your users see on the way are sign-in only: no offer to create an account, no pricing, no links off the page. That is deliberate. They render inside your app's authentication session, and App Review holds what appears there to guideline 3.1.1 whoever drew it. People without a Birchfeed account make one on the web, not inside your app.

  4. The browser hands off to your redirect, which is the one recorded in the registry for your client id, with the code appended:
    yourapp://connect?code=<code>
  5. Exchange the code for a token.
    curl -X POST https://birchfeed.app/v1/connect/exchange \
      -H 'content-type: application/json' \
      -d '{
        "code": "<code from the redirect>",
        "verifier": "<the verifier from step 1>",
        "client": "YOUR_CLIENT_ID",
        "deviceName": "Reeder on an iPhone 17"
      }'
    Answers with the token, the account email, and the name the token was given:
    {
      "token": "bf_...",
      "email": "you@example.com",
      "name": "Reeder on an iPhone 17"
    }
  6. Store the token and start syncing. You already have the email address, so you can skip the ClientLogin round trip entirely and go straight to authenticated Reader API calls.

Rules worth knowing before you build it

  • A code is valid for 10 minutes and can be spent once.
  • A code is burned on the first attempt, even a failed one. A wrong verifier does not get a retry. This is deliberate: it gives someone holding an intercepted code exactly one guess. If an exchange fails, start a new flow rather than retrying the same code.
  • Every failure answers identically: 400 with {"error":"invalid_code"}. Expired, already used, wrong verifier, wrong client, and never existed are indistinguishable on purpose. Do not try to branch on them.
  • deviceName is optional, trimmed, and capped at 60 characters. It is what the user sees in Settings, so name the device rather than your app: the app's name is already recorded. If you omit it, the token is named after your app.
  • client is optional but send it. When present it must match the client the user approved, which is what stops one registered app from spending another's code.
  • The exchange endpoint is rate limited to 10 requests per minute.
  • The account owner gets an email saying which app connected, every time.
  • Signing in by email finishes in the system browser, not in your session. The person opens the emailed link from their inbox, so it is Safari that ends up signed in, on the approve page, and on the handoff to your redirect. Your app then receives yourapp://connect?code= through its ordinary URL handler (onOpenURL or application(_:open:)), not through the completion handler of the ASWebAuthenticationSession it started. Handle both, and cancel the session when the URL arrives. Passkey sign-ins complete inside the session as usual. A session with prefersEphemeralWebBrowserSession = false shares Safari's cookies, so someone already signed in to Birchfeed there skips the sign-in step entirely.

What this flow deliberately does not have

There is no redirect_uri parameter. Your redirect comes from the registry, not from your request. A custom URL scheme belongs to whoever claims it first, so a caller-supplied redirect would let a hostile app ask for a code and have the system hand it over. Looking the destination up on our side means a code only ever travels to an address that was registered in advance.

PKCE covers the rest: an app that claims your scheme and intercepts the redirect holds a code it cannot spend, because the verifier never left your app.

Registering your app

Registration is an email to developers@birchfeed.app rather than a signup form. There will not be hundreds of these, each one is a promise we make to the person tapping Approve, and a self-serve registration form for custom-scheme clients is a phishing vector with extra steps.

We needRules
A client idLowercase letters, digits, and hyphens. 3 to 40 characters. Public.
A display nameWhat the approve page calls your app. Use the name users know.
A vendorWho ships it. Shown next to the name so users can tell apps apart.
One redirectA custom scheme or an https URL. No query string, no fragment, never cleartext http. The code is appended as ?code=.
Where it shipsAn App Store app gets sign-in only pages, as above. Say so if yours is not one and you want the sign-in page to offer account creation.

Until you are registered you can still build and test everything else on this page against a token created by hand in Settings. The connect flow is the only part that needs us.

Reader API reference

A FreshRSS-compatible subset of the Google Reader API, which is what Reeder Classic, NetNewsWire, and most others speak. Base URL https://birchfeed.app/api/greader.php.

Authenticating

Every call under /reader/api/0/ carries the API key in the Authorization header:

Authorization: GoogleLogin auth=bf_your_token_here

Clients that want to look up that value the traditional way can call ClientLogin. It returns the same token back, because the token is already the credential and there is no session to create. You can skip it if you got your token from the connect flow.

GET/POST /accounts/ClientLogin

Trade an email address plus an API key for the auth value.

Email
The account email address. Case insensitive.
Passwd
The API key.

Accepts the fields in a form body or the query string. Answers text/plain with SID, LSID, and Auth lines, all set to the token. A bad token or an email that does not own it is 403 Error=BadAuthentication. Rate limited to 10 per minute.

GET /reader/api/0/token

The legacy write token some clients fetch before a POST.

Returns a fixed string. The GoogleLogin header is the real authentication, and this value is not checked on writes. Call it if your client insists; it costs one round trip and nothing else.

Reading the account

GET /reader/api/0/user-info

Account id, email, and signup time.

GET /reader/api/0/subscription/list

Every subscription, with its folder as a category.

iconUrl is always empty. Feed icons are not served over this API; the Fever endpoint does serve them inline if you need them.

GET /reader/api/0/tag/list

The folders, plus the starred state stream.

GET /reader/api/0/unread-count

Unread counts per feed, per folder, and in total.

newestItemTimestampUsec is the real arrival time of the newest unread item, so diffing it against what you saw last sync is a reliable way to decide whether a stream needs refetching.

Fetching articles

GET/POST /reader/api/0/stream/items/ids

Item ids in a stream. The first half of the usual two-step sync.

s
Stream id. Required. See the stream id table below.
xt
Exclude a stream. In practice user/-/state/com.google/read, which is how you ask for unread only.
n
How many. Defaults to 1000, capped at 10000.
r
o for oldest first. Anything else is newest first.
ot
Only items that arrived at or after this Unix time (seconds).
nt
Only items that arrived at or before this Unix time (seconds).
c
Continuation, taken from the previous response.

Returns itemRefs with short (decimal) ids, plus continuation when more remain. directStreamIds is always empty.

GET/POST /reader/api/0/stream/items/contents

Full article content for a set of ids. The second half.

i
An item id. Repeat the parameter for each one.

Accepts ids in any of the three forms below. Order of the response is not guaranteed to match the order you asked in.

GET /reader/api/0/stream/contents/<stream id>

Ids and content in one call, for clients that sync that way.

Takes the same xt, n, r, ot, nt, and c parameters as items/ids. The stream id goes in the path, URL encoded.

Writing

POST /reader/api/0/edit-tag

Mark items read, unread, starred, or unstarred.

i
An item id. Repeat for each item.
a
A state to add. Repeat as needed.
r
A state to remove. Repeat as needed.

Only the read and starred states are honoured. Adding a state wins over removing the same one in a single call. Answers OK as plain text.

POST /reader/api/0/mark-all-as-read

Mark a whole feed, folder, or the reading list as read.

s
Stream id to mark. A feed, a label, or the reading list.
ts
Only items that arrived before this timestamp. Seconds, milliseconds, microseconds, and nanoseconds are all detected.

Send ts. Without it, articles that arrived between the user's last refresh and their tap get marked read unseen. An unknown feed or folder answers OK rather than an error, because there is nothing to do either way.

POST /reader/api/0/subscription/quickadd

Subscribe to a feed by URL, with autodiscovery.

quickadd
A feed URL, or a site URL to discover one from.

Answers numResults: 0 when nothing resolves. When a site offers several feeds the first is taken, because this call has no way to show a picker.

POST /reader/api/0/subscription/edit

Subscribe, unsubscribe, rename a feed, or move it between folders.

ac
subscribe, unsubscribe, or edit.
s
The feed stream id.
a
A label stream id to file it under. The folder is created if it is new.
r
A label stream id to remove it from, which leaves it uncategorised.
t
A new title for the subscription.

POST /reader/api/0/rename-tag

Rename a folder.

s
The current label.
dest
The new name, bare or as a label id.

Renaming onto a name that already exists answers 409 rather than merging the two.

POST /reader/api/0/disable-tag

Delete a folder. Its feeds survive as uncategorised.

s
The label, or send it bare as t.

Deleting a folder that is already gone answers OK, so a repeated sync does not surface an error for work that is done.

POST /reader/api/0/subscription/import

Import an OPML subscription list.

The body is the OPML file itself, not a form field, with an XML content type. Capped at 5 MB. Answers OK <count>.

Stream ids

StreamId
Everythinguser/-/state/com.google/reading-list
Readuser/-/state/com.google/read
Starreduser/-/state/com.google/starred
One feedfeed/https://example.com/rss.xml
One folderuser/-/label/Tech

A user/<anything>/ prefix is normalised to user/-/, so echoing back the user id from user-info works. Scoping comes from the authenticated request, never from the stream id.

Item ids

The same article has two spellings, and which one you get back depends on the call. All three of these are accepted as input, everywhere an id is taken:

FormExampleReturned by
Longtag:google.com,2005:reader/item/00000000000004d2contents, stream/contents
Short1234items/ids, continuation
Bare hex00000000000004d2Nothing, but accepted

A negative decimal id is wrapped into unsigned space, because some clients round-trip these through a signed 64-bit integer.

Timestamps, and the one that catches people

There are two clocks, and they are not the same one.

FieldMeans
publishedThe article's own date, from the feed. Show this to the user.
timestampUsec, crawlTimeMsecWhen Birchfeed first saw the article. This is the sync clock.

Ordering, continuation, and the ot, nt, and ts parameters all run on the arrival clock, never on the published date. That is what makes an incremental sync correct: an article backdated by its publisher still arrives after the ones you already have, so it will not be skipped. Feed your "newest seen" timestampUsec straight back into ot and it round-trips.

ts on mark-all-as-read round-trips the same way. It names a whole millisecond, and everything that arrived within that millisecond is included, so the newest timestampUsec you were handed is always a safe bound to send back.

Not implemented

These are absent deliberately. They will not appear by accident, so code against their absence:

  • Search of any kind.
  • Reading from the read stream as a source. It answers empty.
  • Arbitrary per-item tags. Only read and starred exist.
  • Feed icons over this API. iconUrl is always empty.
  • Friends, likes, sharing, and the rest of the social half of the original API.

Fever API reference

A much smaller protocol, and the one Unread speaks. One endpoint at https://birchfeed.app/api/fever.php, every operation named by a valueless query parameter, and syncing done by exchanging whole lists of ids rather than per-item deltas.

Authenticating

The key is never sent. The client hashes it locally and sends only the digest:

api_key = md5( lowercase(email) + ":" + birchfeed_key )

Send it as POST data. Query string and multipart bodies are both read too, because clients disagree about which to use.

A bad key answers 200 with "auth": 0, not 401. That is the protocol's design, not an oversight: clients read the body, and a status code they do not expect reads as "server broken" rather than "wrong password".

The one endpoint

GET/POST /api/fever.php

Every read and every write. Combine as many as you like in one request.

api
Always present. Valueless.
groups
Folders, plus the folder-to-feed mapping.
feeds
Subscriptions, plus the folder-to-feed mapping.
favicons
Feed icons, inline, as mime;base64,....
items
Up to 50 articles. Page with since_id or max_id, or ask for specific ones with with_ids.
unread_item_ids
Every unread id, comma separated.
saved_item_ids
Every starred id, comma separated.
links
Always an empty list. Fever's hot links have no equivalent here.
mark, as, id, before
The write. See below.

Writes are applied before the reads in the same request, so ?api&unread_item_ids&mark=item&as=read&id=1 returns the id list as it is after the mark. Clients rely on exactly that.

Writes

markasNotes
itemread, unread, saved, unsavedNeeds id.
feedreadNeeds id. Takes before.
groupreadid=0 is everything. id=-1 is Sparks, which marks nothing. Takes before.

before is a cutoff on arrival time, and you should send it for the feed and group marks for the same reason as the Reader API's ts.

Worth knowing

  • Items come back 50 at a time. That is the protocol's number, not a knob. Page until you get fewer than 50 back.
  • Fever has no way to add or remove a subscription. If your app manages feeds, use the Reader API or send the user to the web reader.
  • is_spark is always 0 and links is always empty.
  • This endpoint is not on the strict auth rate limit, because a single refresh makes several calls. The shared limit still applies.

Rate limits and errors

WhatLimit
Everything, per IP300 requests per minute
ClientLogin, connect approve, connect exchange10 requests per minute

Over the limit is 429. These are per deployment and may be tuned; treat them as the shape of the thing rather than a contract, back off on a 429, and do not poll faster than a person could plausibly want. We fetch the feeds on our own schedule, so syncing more often than every few minutes gets your users nothing.

Error shapes

The two protocols answer differently, because their clients expect different things.

SurfaceLooks like
JSON endpoints{"error":"unauthorized"} with a matching status code
Malformed JSON request{"error":"invalid_request","issues":[...]} with 400
Reader APIOK or Error=BadAuthentication as plain text
Fever200 with "auth": 0

An unknown path answers JSON unless the request actually prefers HTML, so a client probing for endpoints during setup will not suddenly start receiving web pages.

Behaviour to design around

  • Article HTML is already sanitised and capped at 512 KB. You still want your own renderer to be careful, but you are not being handed raw feed markup.
  • Read and starred state is per account, shared across every app connected to it. Two of your users' devices will see each other's taps, which is the entire point of the service.
  • We poll on our own schedule. There is no "refresh this feed now" call in either protocol, and a sync loop that runs every 30 seconds will see the same articles as one that runs every 10 minutes.
  • Feeds are deduplicated across accounts. Two users subscribed to the same URL share one copy of the article, with separate reading state.
  • Tokens can be revoked at any time by the account owner. Handle a sudden 403 or auth: 0 by sending the user back through connect rather than retrying forever.

There is also a private JSON API under /v1/ that the web reader uses. It is not documented here and not versioned in practice: it changes whenever the reader needs it to. Build against the two protocols above.