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.
| Path | What you build | What the user does |
|---|---|---|
| Drop in | Nothing, if you already support FreshRSS or Google Reader. Point at our URL. | Pastes an email address and an API key. |
| Fever | Nothing, if you already support Fever. | Pastes an email address and an API key. |
| Full integration | A "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
| Protocol | URL |
|---|---|
| Google Reader / FreshRSS | https://birchfeed.app/api/greader.php |
| Fever | https://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.
| Protocol | Username field | Password field |
|---|---|---|
| Reader API | The account email address | The API key |
| Fever | Not sent | api_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:
- 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.
- 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> - 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.
- 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> - Exchange the code for a token.
Answers with the token, the account email, and the name the token was given: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" }'{ "token": "bf_...", "email": "you@example.com", "name": "Reeder on an iPhone 17" } - 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:
400with{"error":"invalid_code"}. Expired, already used, wrong verifier, wrong client, and never existed are indistinguishable on purpose. Do not try to branch on them. deviceNameis 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.clientis 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 (onOpenURLorapplication(_:open:)), not through the completion handler of theASWebAuthenticationSessionit started. Handle both, and cancel the session when the URL arrives. Passkey sign-ins complete inside the session as usual. A session withprefersEphemeralWebBrowserSession = falseshares 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 need | Rules |
|---|---|
| A client id | Lowercase letters, digits, and hyphens. 3 to 40 characters. Public. |
| A display name | What the approve page calls your app. Use the name users know. |
| A vendor | Who ships it. Shown next to the name so users can tell apps apart. |
| One redirect | A custom scheme or an https URL. No query string, no fragment, never cleartext http. The code is appended as ?code=. |
| Where it ships | An 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.
rofor 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.
acsubscribe,unsubscribe, oredit.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
| Stream | Id |
|---|---|
| Everything | user/-/state/com.google/reading-list |
| Read | user/-/state/com.google/read |
| Starred | user/-/state/com.google/starred |
| One feed | feed/https://example.com/rss.xml |
| One folder | user/-/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:
| Form | Example | Returned by |
|---|---|---|
| Long | tag:google.com,2005:reader/item/00000000000004d2 | contents, stream/contents |
| Short | 1234 | items/ids, continuation |
| Bare hex | 00000000000004d2 | Nothing, 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.
| Field | Means |
|---|---|
published | The article's own date, from the feed. Show this to the user. |
timestampUsec, crawlTimeMsec | When 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
readstream as a source. It answers empty. - Arbitrary per-item tags. Only read and starred exist.
- Feed icons over this API.
iconUrlis 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_idormax_id, or ask for specific ones withwith_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
| mark | as | Notes |
|---|---|---|
item | read, unread, saved, unsaved | Needs id. |
feed | read | Needs id. Takes before. |
group | read | id=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_sparkis always 0 andlinksis 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
| What | Limit |
|---|---|
| Everything, per IP | 300 requests per minute |
| ClientLogin, connect approve, connect exchange | 10 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.
| Surface | Looks like |
|---|---|
| JSON endpoints | {"error":"unauthorized"} with a matching status code |
| Malformed JSON request | {"error":"invalid_request","issues":[...]} with 400 |
| Reader API | OK or Error=BadAuthentication as plain text |
| Fever | 200 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
403orauth: 0by 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.