- Base path:
/api - JSON unless explicitly stated otherwise.
- Same-origin server-side session authentication.
- No JWT/localStorage authentication.
- State-changing requests use the CSRF strategy from
SECURITY.md. - Every user-owned resource is scoped server-side to the authenticated user.
- Cross-user resource IDs should generally behave as
404.
Success:
{"data": {}}Collections:
{"data": [], "pagination": {}}Errors:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request contains invalid data.",
"fields": {}
}
}Use correct HTTP statuses (200, 201, 204, 400, 401, 404,
409, 413, 415, 422, 429, 500, 502, 503 as appropriate).
Authentication is session based. Before dispatching, the kernel reopens the session from a valid "remember me" cookie when the current session is not already authenticated, so a session that timed out does not require a new password entry. The restore regenerates the session identifier, and the client transparently retries once with a refreshed CSRF token when the restored session invalidates the one it held.
POST /api/auth/login and POST /api/auth/logout are excluded from that
restore: explicit credentials must always win, and logout must not resurrect
the state it is closing.
Starts or resumes the same-origin session and returns its CSRF token:
{"data":{"csrf_token":"..."}}The frontend sends this value in the X-CSRF-Token header for every
state-changing request, including login. The token is rotated after
login and password change.
{"username":"user","password":"secret","remember":false}Creates the server-side session and returns the authenticated user plus
the rotated CSRF token. Repeated failures are rate limited with 429.
remember is optional and defaults to false; it must be a boolean when
present, otherwise the request fails with 422 and a remember field error.
When true, the response also sets a Set-Cookie header for the "remember me"
cookie; when false, it explicitly clears it and deletes any token already
stored for the presenting cookie. The token value is never returned in the
body.
Invalidates the session, deletes the presented "remember me" token and clears its cookie.
Returns the current user (id, username and nullable email) or 401.
Lists the current user's real categories, ordered by name. The virtual
Sans catégorie group is never stored or returned as a category.
{
"data": [
{
"id": 3,
"name": "Développement",
"created_at": "2026-09-24T12:00:00Z",
"updated_at": "2026-09-24T12:00:00Z"
}
]
}{"name":"Développement"}Returns the created category with 201. Names are trimmed, must contain 1 to
200 characters and are unique case-insensitively for one user. A duplicate
returns 409 CATEGORY_ALREADY_EXISTS.
{"name":"PHP"}Returns the updated category. A foreign or missing category returns 404; a
name conflict returns 409 CATEGORY_ALREADY_EXISTS.
Deletes the owned category and returns 204; feeds remain and become
uncategorized. A foreign or missing category returns 404.
All category writes require a valid CSRF token and reject unknown fields.
{"url":"https://example.org/"}Returns normalized candidates:
{
"data": {
"site_url": "https://example.org/",
"feeds": [
{"title":"News","url":"https://example.org/feed.xml","type":"rss"}
]
}
}Multiple candidates are returned to the frontend; the backend does not choose arbitrarily.
The endpoint requires an authenticated session and CSRF token. It accepts a
website or a direct RSS 2.0/Atom document, follows only redirects accepted by
the centralized safe HTTP client, resolves relative declarations and validates
every candidate against the SSRF policy. Unsafe declared candidates are
omitted. The request is limited to 10 attempts per authenticated user over
5 minutes (429 TOO_MANY_ATTEMPTS).
Expected discovery errors include NO_FEED_FOUND and
UNSUPPORTED_REMOTE_CONTENT (422), and REMOTE_FETCH_FAILED (502).
Optional filters:
category_id
active
Returns one owned feed.
{
"feed_url": "https://example.org/feed.xml",
"category_id": 3,
"name": "Optional custom name"
}Backend validates remote URL/security, retrieves the feed, rejects duplicates, creates the subscription and performs initial import.
The URL is fetched through the centralized safe HTTP client before the
database transaction starts. name is optional; when omitted, the validated
feed title is used. A successful creation imports the valid initial articles
and records the retrieval metadata. An invalid or unreachable initial feed
does not create a subscription. Expected errors include INVALID_FEED (422),
FEED_FETCH_FAILED (502) and FEED_ALREADY_EXISTS (409).
Supports documented editable fields such as:
{"name":"New name","category_id":null,"is_active":true}Changing feed_url is allowed only if implemented according to
RSS.md.
The local-subscription milestone rejects feed_url changes. Supported
fields are currently name, category_id and is_active.
Deletes feed, articles and associated unused media.
Refresh one active owned feed.
{
"data": {
"feed": {},
"imported_articles": 3,
"not_modified": false
}
}A disabled feed returns 409 FEED_DISABLED. A feed already being
synchronized, by another request or by the cron, returns 409 FEED_BUSY
without contacting the remote source and without recording a fetch attempt. A
foreign feed behaves as 404. Conditional retrieval can return
not_modified: true with zero imports.
Refresh all active feeds of the current user. Partial failures are
reported without cancelling successful feeds. A feed already being
synchronized is reported as an error entry with code FEED_BUSY, so it is
retried by the next refresh or by the cron.
{
"data": {
"results": [
{"feed_id":1,"status":"success","imported_articles":2,"not_modified":false},
{"feed_id":2,"status":"error","error":{"code":"FEED_FETCH_FAILED","message":"..."}}
]
}
}Parameters:
filter=all|unread|read|favorites
category_id=<id>
category=uncategorized
feed_id=<id>
page=<n>
per_page=<allowed value>
Response:
{
"data": [],
"pagination": {
"page": 1,
"per_page": 25,
"total_items": 0,
"total_pages": 0
}
}List responses should avoid returning full article content when unnecessary.
The frontend always requests the default batch of 25 articles. The API also
accepts an explicit per_page value of 10, 25, 50 or 100 for API
clients that need another batch size; this value is not taken from user
settings. Invalid filters and batch-loading values return 422. A referenced
category or feed owned by another user behaves as 404.
Each list item contains the article identifier, source feed summary (including
its nullable category object with id and name), title,
original URL, author, publication/discovery dates, summary, read-only
tags array (feed-provided article tags, possibly empty), optional controlled
image_url, and boolean read/favorite states. The content field is omitted.
Returns the list fields plus full sanitized content without changing the read
state. A foreign or missing article returns 404. Marking an article read is an
explicit CSRF-protected PATCH operation.
Supports:
{"is_read":true}or:
{"is_favorite":true}Both boolean fields may be supplied together. At least one is required,
unknown fields are rejected, and CSRF protection applies. The response is the
updated detailed article representation. A foreign or missing article returns
404.
Returns up to forty-eight unread articles from the user's own feeds that resemble
their recent favorites. The 48 articles are selected by a score-weighted random
draw from the 96 best eligible suggestions, computed only from local data
(weighted FTS match against favorite titles and tags, plus candidates and
affinity from shared tags and categories). Stronger suggestions are more likely
to be selected, while repeated calls can still vary. FTS
weights title, summary and content equally, while author matches receive a
lower weight to avoid incidental author-only matches. Recent articles receive
a small progressive freshness bonus, capped at one point over 15 days.
No single feed may occupy more than five of the returned articles, so the
response may contain fewer than forty-eight articles when the user follows fewer
than ten sources with eligible articles. Candidate retrieval is itself capped
per feed before its global bound is applied, preventing a prolific source from
hiding eligible candidates from other feeds. The returned articles are ordered from
the most recent to the oldest publication, falling back to the discovery date.
The response is
{data: [<article>]} with the same article representation as
GET /api/articles and is returned in one response. The list is empty when the user has no
favorite history or no relevant unread candidates exist. Repeated calls may
return different articles from the pool. No other user's data or external
service is involved.
Optional query parameters category_id=<id> or
category=uncategorized scope the suggestions to one owned category (or the
virtual group). A page parameter is accepted and ignored because the list
is returned in one response. Unknown other fields are rejected with 422 and a foreign
or missing category behaves as 404.
The response uses the same compact article representation and load metadata as
GET /api/articles. Optional parameters are filter, category_id,
category=uncategorized, feed_id, page and per_page, with the same
validation and ownership rules as the article list.
Search covers title, summary, content and author. Input is converted to at
most 20 literal Unicode terms and prefix-matched with SQLite FTS5; client input
cannot inject FTS operators. The query must contain at least one searchable
term, is limited to 200 characters, and returns 422 when invalid.
Search is always scoped to the current user.
Returns the authenticated user's global article counts and unread counts for every owned category and feed, including resources whose count is zero:
{
"data": {
"all": 12,
"unread": 7,
"read": 5,
"favorites": 2,
"categories": [{"category_id": 3, "unread": 4}],
"uncategorized": 1,
"feeds": [{"feed_id": 8, "unread": 3}]
}
}All aggregations are scoped server-side to the authenticated user.
Updates the authenticated user's optional profile email address:
{"email":"user@example.org","recommendation_email_frequency":"weekly"}The email is trimmed and must be valid and at most 254 characters. The
frequency is required and must be never, daily, weekly or monthly.
An empty email is accepted only with never and clears the address. The
address is not a login identifier and does not need to be unique. Both values
are changed atomically. The endpoint returns the updated public user and
frequency, requires a valid CSRF token, rejects unknown fields and never
accepts a user identifier.
Returns the authenticated user's V1 application settings. The
articles_per_page field is retained for API compatibility and does not alter
the frontend's fixed 25-article batches:
{"data":{"articles_per_page":25,"theme":"light","recommendation_email_frequency":"never"}}No user identifier is accepted; settings are always resolved from the server-side session.
{"articles_per_page":25,"theme":"dark"}articles_per_page must be the JSON integer 10, 25, 50 or 100 for
backward-compatible API clients; the frontend ignores this value and always
loads 25 articles at a time.
theme must be the JSON string light or dark. At least one field must
be present and valid; both may be sent at once and only supplied fields
are changed. The endpoint returns the updated settings, requires a valid
CSRF token and rejects unknown or missing fields with 422.
{
"current_password":"...",
"new_password":"..."
}Accepts a multipart/form-data upload in the file field. The file is limited
to 1 MiB and 100 subscriptions. Filenames and client MIME declarations are not
trusted. XML containing a DOCTYPE/entity declaration, malformed XML and a
non-OPML document return 422; an oversized file returns
413 OPML_TOO_LARGE.
Each URL passes the normal URL normalization, SSRF-safe retrieval and RSS/Atom validation pipeline. Valid entries are retained when another entry fails. Existing normalized URLs are reported as duplicates for that user.
{
"data": {
"imported": 3,
"duplicates": 1,
"failed": 2,
"categories_created": 2
}
}The endpoint is authenticated, CSRF-protected and rate limited.
Returns an application/xml OPML 2.0 attachment containing only the current
user's subscriptions and category organization. Real empty categories are
included; uncategorized subscriptions are direct children of <body>.
Local media is exposed through controlled application URLs, never raw filesystem paths.
Media access must preserve user isolation.
Returns the locally stored favicon of an owned feed with its validated image
content type. A missing favicon or a feed owned by another user returns 404.
Feed representations expose this route as favicon_url; storage keys are not
part of the public API.
Returns the locally stored image of an owned article. A missing image or an
article owned by another user returns 404.
Both responses are authenticated, use Cache-Control: no-store and never
accept a filesystem path or storage key from the request.
- Validate all input server-side.
- Prefer rejecting unknown write fields.
- Remote-fetching endpoints use timeouts, size limits and SSRF controls.
- No permissive authenticated CORS.
- Do not expose stack traces or internal exception names.
- Authenticated API responses must not be indiscriminately cached by the PWA.
The V1 exclusion list is owned by PROJECT.md. API consequences: no
endpoints for public registration, email password recovery, user-managed
tags, sharing, comments, AI features, cross-user or AI recommendations, or
push notifications. Feed-sourced article tags are exposed read-only, and
local favorite-based recommendations are exposed as described above.