Your own scripts and tools can read your feeds, mark articles read and follow new feeds, through the same API the Feedmesh apps use. It's JSON over HTTPS, and you sign in with a personal access token. Making an app other people sign in to? Use OAuth instead.
A token looks like fm_pat_ followed by 43 letters, digits, - and _. Keep it like a password: out of code you share, and in an environment variable instead.
export FEEDMESH_TOKEN="fm_pat_..."
An account can have up to 20 tokens. Revoke one on the same page and it stops working.
A personal access token is for your own scripts: you make it, you paste it in, and it's your account. If you're making something other people sign in to (a reader app, a website, a bot), don't ask them for a token or their password. Use OAuth: each person signs in on feedmesh.com, sees what your app asks for, and can disconnect it whenever they like.
| Personal access token | OAuth | |
|---|---|---|
| For | Your own scripts and tools | Apps other people use |
| Signing in | Make a token on Account > Developer | The person signs in on feedmesh.com and allows your app |
| The token | Lasts until it expires or you revoke it | An access token for an hour, renewed with a refresh token |
| Taking it back | Revoke it on Account > Developer | Disconnect it under Connected apps on the Account page |
It's OAuth 2.1's authorization code flow with PKCE, the same one Claude and ChatGPT use to connect to Feedmesh. There's
nothing to apply for: your app registers itself. Everything else (scopes, limits, errors) is as on the rest of this page:
a connected app gets feeds.read and, if the person allows it, feeds.write, and never the account itself.
They're in the standard metadata (RFC 8414), so an OAuth library can find them itself:
curl https://feedmesh.com/.well-known/oauth-authorization-server
| Register | POST https://feedmesh.com/api/v2/oauth/register |
| Authorize | GET https://feedmesh.com/api/v2/oauth/authorize |
| Tokens | POST https://feedmesh.com/api/v2/oauth/token |
| Revoke | POST https://feedmesh.com/api/v2/oauth/revoke |
Dynamic client registration (RFC 7591). The name is what people see when they allow it.
curl -X POST -H "Content-Type: application/json" https://feedmesh.com/api/v2/oauth/register \
-d '{"client_name": "My Reader", "redirect_uris": ["https://myreader.example/callback"]}'
The answer has your client_id (it starts with fm_). Keep it; register again only if your addresses change.
https; http only to localhost or 127.0.0.1, for a desktop app; or your app's own scheme in reverse-domain form, like com.example.myreader:/callback, for a phone app.token_endpoint_auth_method and it gets no secret."token_endpoint_auth_method": "client_secret_post" (or client_secret_basic). The answer has a client_secret, shown only that once. It needs PKCE too.Make a PKCE pair for each sign-in (a random code_verifier, and its SHA-256 in base64url as the code_challenge), and a random state. Then open, in their browser:
https://feedmesh.com/api/v2/oauth/authorize?response_type=code &client_id=fm_... &redirect_uri=https%3A%2F%2Fmyreader.example%2Fcallback &scope=feeds.read%20feeds.write%20offline_access &state=... &code_challenge=... &code_challenge_method=S256
Ask only for what you need: feeds.read to read, feeds.write to change things, and
offline_access for a refresh token, so people don't have to sign in again every hour. They sign in to
Feedmesh as usual (email, Google or Apple), see your app's name and what it asks for, and choose Allow or Cancel. Then
they come back to your redirect address with ?code=...&state=... (check the state is yours), or with
?error=access_denied.
curl -X POST https://feedmesh.com/api/v2/oauth/token \ -d grant_type=authorization_code \ -d client_id=fm_... \ -d code=... \ -d redirect_uri=https://myreader.example/callback \ -d code_verifier=...
(A web service adds -d client_secret=....) The answer has an access_token for an hour and, with
offline_access, a refresh_token. Send the access token just like a personal one:
Authorization: Bearer .... Requests count against the person's own plan and limits.
curl -X POST https://feedmesh.com/api/v2/oauth/token \ -d grant_type=refresh_token \ -d client_id=fm_... \ -d refresh_token=...
Each refresh gives a new refresh token too: keep the new one, as the old one stops working. A refresh token lasts 90 days,
so an app that's used keeps working. A 401 on a request means the access token ran out: refresh and try again.
If the refresh fails, the person disconnected your app (or didn't use it for 90 days): send them to sign in again.
To sign someone out of your app, send their refresh token to POST https://feedmesh.com/api/v2/oauth/revoke with your client_id.
https://feedmesh.com/api/v2
Send the token (a personal one, or an OAuth access token) in the Authorization header of every request:
Authorization: Bearer fm_pat_...
For example, the feeds you follow:
curl -H "Authorization: Bearer $FEEDMESH_TOKEN" https://feedmesh.com/api/v2/subscriptions
HTTPS only. Every endpoint, with its parameters and answers, is in the API reference.
When you make a token, you choose what it may do:
| Access | Scope | What it can do |
|---|---|---|
| Read only | feeds.read | Every GET: your categories and feeds, their articles, full text, saved and recently read articles, article search, Discover, notification settings and Local News |
| Read and change | feeds.read feeds.write | Also everything else: mark read and unread, save, follow and unfollow, rename and move feeds, categories, notifications, Local News follows |
A request the token can't make gets 403 (see Errors). No token can change your account itself: email, password, sign-in, or deleting it.
The API is part of Feedmesh Plus, for personal tokens and connected apps alike. Reading in the
Feedmesh apps and on this website stays free. An account without Plus gets 403 with the code
plus_required (only GET /me/plan answers it, so a script can tell why).
| With Feedmesh Plus | |
|---|---|
| Reading and changes | Yes |
| Requests | 60 a minute |
| Searches (feeds and articles) | 30 a minute |
| Full-text articles | 300 a day, shared with MCP |
| Feeds followed | 20 a day |
GET /items/{id}?subscription={id}&fullText=true or GET /items/{id}/fulltext?subscription={id}.
There's no way to ask for several at once. Each one counts, and it's the same count as MCP's get_article:
300 a day on Plus for both together (10 on Free, which has MCP but not the API). Lists and search never carry it:
they have titles, links, authors, dates and pictures.
POST /subscriptions, since a new feed is one more for Feedmesh to check.GET /me/plan gives your plan, its limits, today's use (usedToday) and what's left (remainingToday, -1 for no limit); Account > Usage shows the same.The API is for your own use: see the rules.
camelCase names. Send Content-Type: application/json with a body.2026-11-02T14:03:00Z. IDs are GUIDs, as strings.GET reads, POST makes something, PATCH changes part of it, PUT sets a state (PUT /items/{id}/read) and DELETE removes or clears it. PUT and DELETE are safe to send twice.feeds lists them.GET answers carry an ETag. Send it back in If-None-Match and an unchanged answer is a short 304 Not Modified.Lists of articles (a feed's, saved, recently read, search) come a page at a time:
limit: how many on a page, 25 unless you say, at most 100.nextCursor in the answer: pass it back as cursor for the next page. It's null on the last page.limit articles (your mute words hide some) and still not be the last. Keep going until nextCursor is null.params = {"unreadOnly": "true", "limit": 100}
while True:
page = feedmesh.get(f"{API}/subscriptions/{sub_id}/items", params=params).json()
for item in page["items"]:
print(item["title"])
if not page["nextCursor"]:
break
params["cursor"] = page["nextCursor"]
Errors are problem details with a code to check in your code (the title is for people, and its words may change):
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://feedmesh.com/api/v2/errors/already-subscribed",
"title": "Already subscribed to that feed.",
"status": 409,
"code": "already_subscribed",
"subscriptionId": "6f1d6c3e-2b0a-4c47-9d0e-8a5b7e2f4c19"
}
| Status | What it means |
|---|---|
400 | Something in the request isn't right; code says what, like invalid_cursor or missing_subscription |
401 | No token, or it's expired or revoked (an OAuth access token lasts an hour: refresh it) |
403 | insufficient_scope: the token is read only. plus_required: the API is part of Feedmesh Plus, and the account doesn't have it (any more) |
404 | Not found, or not yours: another account's feeds and articles are simply not there |
409 | A conflict: already_subscribed, similar_feed (Feedmesh has a feed at a very similar address; the answer's feed is it), category_limit (10 categories at most) |
410 | feed_ended: the feed stopped for good; note says why |
429 | Too many requests this minute: wait the seconds in the Retry-After header. Or allowance_used: the day's full texts or follows are used up, until resetsAt |
In curl and in Python with requests. The Python examples follow on from the first one's feedmesh session.
curl -H "Authorization: Bearer $FEEDMESH_TOKEN" https://feedmesh.com/api/v2/subscriptions
import os
import requests
API = "https://feedmesh.com/api/v2"
feedmesh = requests.Session()
feedmesh.headers["Authorization"] = "Bearer " + os.environ["FEEDMESH_TOKEN"]
subscriptions = feedmesh.get(f"{API}/subscriptions").json()
for subscription in subscriptions:
print(subscription["id"], subscription["title"], subscription["status"])
Each one has its id, title, categoryId, feeds and status (ok, new, or broken with a statusNote saying why). Add ?category= with an ID from GET /categories, or none for the ones in no category.
curl -H "Authorization: Bearer $FEEDMESH_TOKEN" "https://feedmesh.com/api/v2/subscriptions/$SUB_ID/items?unreadOnly=true&limit=10"
sub_id = subscriptions[0]["id"]
page = feedmesh.get(f"{API}/subscriptions/{sub_id}/items", params={"unreadOnly": "true", "limit": 10}).json()
for item in page["items"]:
print(item["datePublished"], item["feedTitle"], item["title"])
The answer, shortened:
{
"items": [
{
"id": "3c1d2a8e-5b7f-4f0e-9a43-2a1f6e0b9c11",
"title": "The best laptops of the year",
"author": "Jane Doe",
"datePublished": "2026-11-02T13:05:00Z",
"wordCount": 812,
"isRead": false,
"feedTitle": "The Verge"
}
],
"nextCursor": "cDIuMTA",
"status": "ok"
}
The article itself, with its link and summary, is GET /items/{id}?subscription={id}. Add &fullText=true for the full text (it counts toward the day's full texts):
item_id = page["items"][0]["id"]
article = feedmesh.get(f"{API}/items/{item_id}", params={"subscription": sub_id}).json()
print(article["title"], article["link"])
# content is the summary as blocks: paragraphs and headings have spans of text (and links)
for block in article["content"] or []:
print("".join(span["text"] for span in block.get("spans", [])))
Needs a token that can change things. The answer is 204 No Content, and sending it twice is fine.
curl -X PUT -H "Authorization: Bearer $FEEDMESH_TOKEN" "https://feedmesh.com/api/v2/items/$ITEM_ID/read?subscription=$SUB_ID"
r = feedmesh.put(f"{API}/items/{item_id}/read", params={"subscription": sub_id})
r.raise_for_status()
DELETE on the same address marks it unread. To mark a whole feed read, POST /subscriptions/{id}/read with {} (everything up to now) or {"readTo": "2026-11-02T12:00:00Z"}.
Needs a token that can change things, and counts toward the day's follows.
curl -H "Authorization: Bearer $FEEDMESH_TOKEN" "https://feedmesh.com/api/v2/discover/search?q=the+verge"
curl -X POST -H "Authorization: Bearer $FEEDMESH_TOKEN" -H "Content-Type: application/json" \
-d '{"url": "https://www.theverge.com/rss/index.xml"}' https://feedmesh.com/api/v2/subscriptions
found = feedmesh.get(f"{API}/discover/search", params={"q": "the verge"}).json()
feed = found[0]
# Feeds Feedmesh already has come with a feedId; ones found on the web, with their url
body = {"feedId": feed["feedId"]} if feed["feedId"] else {"url": feed["url"]}
r = feedmesh.post(f"{API}/subscriptions", json=body)
if r.status_code == 409 and r.json()["code"] == "already_subscribed":
print("Already following it:", r.json()["subscriptionId"])
else:
r.raise_for_status()
print("Following", r.json()["title"])
A new follow answers 201 Created with the subscription. Add "categoryId" to put it in one of your categories.
import time
def get(path, **params):
while True:
r = feedmesh.get(f"{API}{path}", params=params)
if r.status_code == 429 and "Retry-After" in r.headers:
time.sleep(int(r.headers["Retry-After"]))
continue
r.raise_for_status()
return r.json()
Something missing, or not working as written? Email [email protected]. Your tokens are under Account > Developer.
The API, the MCP server and RSS app sync are for you: your own reading, your own AI assistant, and automating your own workflow, through your own account.
The legal version is section 5 of the Terms of Service. Feedmesh may limit or stop access that breaks these rules. Not sure whether something fits? Ask at [email protected].