Skip to content

Latest commit

 

History

History
1079 lines (849 loc) · 270 KB

File metadata and controls

1079 lines (849 loc) · 270 KB

Nextcloud Social API Reference

This document is written by hand but mechanically checked: tests/DocumentationTest.php asserts that the set of routes documented here matches the set of routes the app registers, which it reads the way the server does — the #[FrontpageRoute] attributes on the controller methods, plus what is left in appinfo/routes.php. Every route below appears in a table row with its URL exactly as written in the attribute. Paths that are not routes of this app (the .well-known discovery documents handled by the Nextcloud WellKnown API) are deliberately written without code spans so that check stays exact.

Rate-limit headers. Every response from a route that carries a rate limit also carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds since the epoch, at the end of the window this request fell in), so a client can pace itself rather than hammer until it is refused. The refusal itself is Nextcloud's and is a 429; the count in the header is this app's own and is advisory. A route with no limit carries no headers, and an instance with no memcache configured carries Limit without Remaining or Reset — there is nowhere to keep a counter, and a number that was never measured is worse than no number.

Contents


Overview

The Social app exposes four groups of endpoints. Each is registered as a #[FrontpageRoute] attribute on the controller method that answers it, with one exception noted at the end of this section:

  • Mastodon-compatible REST API (ApiController, TagController, OAuthController) — a partial implementation of the Mastodon client API. Several endpoints are stubs; each is marked below.
  • Custom Local API (LocalController, ConfigController) — the endpoints the app's own Vue frontend calls. They are not Mastodon-compatible and their response envelope differs (see Error Responses).
  • ActivityPub Federation API (ActivityPubController, SocialPubController) — server-to-server ActivityPub, plus the HTML profile/post pages served on the same URLs.
  • Frontend, document, OStatus and queue endpoints (NavigationController, OStatusController, QueueController) — HTML pages and internal plumbing.

All URLs are relative to the app's route base, i.e. index.php/apps/social + the URL from the route table (for example, index.php/apps/social/api/v1/statuses).

None of these are OCS routes: #[ApiRoute] would put them under /ocsapp, which is not where any of these paths are published.

GET /api/v1/accounts/{id} is the one route still declared in appinfo/routes.php. Its {id} accepts slashes, so it also matches /api/v1/accounts/{account}/lists and /api/v1/accounts/{account}/featured_tags, and it has to be offered to the matcher after them; those two belong to other controllers, and attribute routes are contributed one controller at a time in filesystem order. The array file is loaded after every attribute route of the app, which is the guarantee that route needs.

Removed: the superseded half of the Custom Local API

The Custom Local API predates the Mastodon-compatible one, and the frontend moved off most of it. Nineteen of LocalController's thirty-one routes had no caller anywhere in src/ and were documented here as deprecated for a release; as of 0.19.39 they are gone, and with them the getStream*() / getTimeline*_dep() query layer that only they used. A caller of one of these gets a 404 and should move to the Mastodon route beside it:

Removed Use instead
GET /api/v1/stream/home GET /api/v1/timelines/home
GET /api/v1/stream/timeline GET /api/v1/timelines/public?local=true
GET /api/v1/stream/federated GET /api/v1/timelines/public
GET /api/v1/stream/tag/{hashtag}/ GET /api/v1/timelines/tag/{hashtag}
GET /api/v1/stream/direct GET /api/v1/conversations
GET /api/v1/stream/liked GET /api/v1/favourites
GET /api/v1/stream/notifications GET /api/v1/notifications
GET /api/v1/account/{username}/stream GET /api/v1/accounts/{account}/statuses
GET /local/v1/post, GET /local/v1/post/replies GET /api/v1/statuses/{nid}, …/context
POST/DELETE /api/v1/post/like POST /api/v1/statuses/{nid}/favourite, …/unfavourite
GET /api/v1/current/info GET /api/v1/accounts/verify_credentials
GET /api/v1/current/followers, …/following GET /api/v1/accounts/{account}/followers, …/following
GET /api/v1/global/actor/info GET /api/v1/accounts/{account}
GET /api/v1/global/actor/header the header field of the Account entity
PUT /api/v1/account/summary PATCH /api/v1/accounts/update_credentials
GET /local/v1/search GET /api/v2/search

The twelve that the frontend still uses -- the banner uploads, the global/account and global/tags searches, the profile field and avatar endpoints, POST/DELETE /api/v1/post and /api/v1/current/follow -- have no Mastodon equivalent yet and stay.


Authentication

Two mechanisms exist, and which one applies depends on the controller:

  1. OAuth Bearer tokenApiController and TagController read it. The constructor parses the Authorization header, accepts a bearer auth type, and resolves the token through ClientService::getFromToken(). If no bearer token is present it falls back to the logged-in Nextcloud session user. Both declare their routes #[PublicPage] with #[NoCSRFRequired] and then require a viewer inside the handler: a Mastodon client has no Nextcloud session and no CSRF token, so #[NoAdminRequired] would refuse every real caller before the handler ran.
  2. Nextcloud sessionLocalController, ConfigController, NavigationController, OAuthController (authorize/authorizing) and OStatusController use the session userId only. They do not honour bearer tokens, so the Custom Local API is effectively usable only from the app's own frontend (or with a Nextcloud session cookie / app password + OCS-APIRequest).

Access control is declared with PHP attributes (#[PublicPage], #[NoCSRFRequired], #[NoAdminRequired], and #[BruteForceProtection] on the OAuth token/revoke endpoints); the legacy PHPDoc annotations are gone. The "Auth" column in the tables below records these attributes:

  • public#[PublicPage]: reachable without a Nextcloud login (an endpoint may still fail later if it needs a viewer).
  • user#[NoAdminRequired]: any logged-in user.
  • admin — no attribute at all: Nextcloud requires an admin session.
  • no-csrf#[NoCSRFRequired]: no CSRF token needed, which matters for non-browser clients.

Note that many ApiController endpoints are annotated @PublicPage but call initViewer(true) internally, which throws when there is neither a session nor a valid bearer token; those return HTTP 401 with {"error": "the access_token was revoked"}.


Mastodon-compatible API

Instance and app metadata

Method Route Auth Parameters Description
GET /api/v1/instance/ public, no-csrf Mastodon's V1::Instance entity, built by InstanceService::getLocal() from the live configuration on every request (the stored row is written once at install and only marks that the instance exists). Carries uri, title, version, short_description, description, email, urls, stats, thumbnail, languages, registrations (always false — the client API cannot create an account), approval_required, invites_enabled, configuration, rules and contact_account. urls, stats and configuration are always JSON objects, never []. configuration reports statuses (max_characters 5000, max_media_attachments, characters_reserved_per_url), media_attachments (supported_mime_types, asked of CacheDocumentService::filterMimeTypes() so it cannot drift from what an upload is actually allowed to be, plus image_size_limit (max_size) and video_size_limit (max_video_size), in bytes — and the pixel/frame-rate ceilings), polls (max_options, max_characters_per_option, the expiry bounds) and accounts.max_featured_tags. urls is an empty object: there is no streaming API, and a client handed a URL that never upgrades to a websocket falls back to polling only after a timeout. stats.status_count and stats.domain_count are counted from the database and memoised for a few minutes (InstanceService::countedStats()); user_count is the number of Nextcloud users, since an actor is created for a user the first time they open the app. Nothing is read back from the stored instance row, which holds whatever was true on the day the instance was set up. rules comes from one line per rule in the rules app value. The version field is Pleroma-style — 4.2.0 (compatible; Nextcloud Social <app version>) — because clients gate features on it; NodeInfo keeps reporting the real app version. It said 3.5.0 for a long time and that was hiding finished work: editing with /api/v1/statuses/{nid}/source and /api/v1/statuses/{nid}/history, v2 filters, /api/v2/instance and /api/v1/notifications/unread_count are all 4.x features a client never asks for below that version. What is not here is announced rather than left to fail — urls is empty (no streaming), and there is no Web Push, so a client falls back to polling. configuration.translation.enabled is true only where this Nextcloud has a translation provider (TranslationService).
GET /api/v1/instance/rules public, no-csrf The instance's rules, as Mastodon's Rule entities. They were already served inside the instance entity, out of the rules app value (occ config:app:set social rules --value "..."), so the data was here and the route a client reads it from was a 404. An instance that has set none answers [], which is the truthful answer rather than an error.
GET /api/v1/instance/domain_blocks public, no-csrf The instances this one has decided not to federate with, as {domain, digest, severity, comment}. [] unless an admin opts in with occ config:app:set social publish_blocks --value 1: Mastodon publishes the list so somebody choosing a server can see who it will not talk to, and whether this server wants that read by anybody is a disclosure decision rather than a default. Only ever the deny list — in allow-list mode the same column holds the instances this server does talk to, and publishing that as a block list would be exactly backwards, so that mode answers [] too. severity is always suspend (the only one this list has) and comment always "" (not stored).
GET /api/v1/instance/extended_description public, no-csrf The long form of what this instance is, as {updated_at, content}, from the extended_description app value. Falling back to the short description the instance entity already carries: an empty page where a server has written a description elsewhere is worse than repeating it.
GET /api/v1/instance/translation_languages public, no-csrf For each language this server can translate from, the languages it can translate to, as Mastodon's {"de": ["en", …]}. Read from the translation provider Nextcloud has, so it is this server's truth rather than a list written here; a server with no provider answers {}, which is the same thing configuration.translation.enabled: false says.
GET /api/v1/instance/privacy_policy public, no-csrf The server's privacy policy as {updated_at, content}. Nextcloud's own, from Theming's privacy policy link, rather than one kept by this app: a server has one privacy policy and a second one here would be a second answer to the same question. Nextcloud stores a link where Mastodon's entity carries text, so the content is that link. 404 where the administrator has published none.
GET /api/v1/instance/terms_of_service public, no-csrf The same, from Theming's legal notice link. 404 where none is set.
GET /api/oembed public, no-csrf, rate-limited url (required), format (json) oEmbed for one of this server's own public posts, so a site handed a link can attribute it without scraping. type is link, not Mastodon's rich: a rich response is an <iframe> and this app has no embed page to put in one — every post URL here opens the whole app, and framing that would publish something nobody meant to. Unlisted, followers-only and remote posts are a 404; a format other than json is a 501, as oEmbed prescribes.
GET /api/v2/instance public, no-csrf The same facts as Mastodon's V2::Instance: uri becomes domain, thumbnail an object, and the flat contact/registration fields move under contact and registrations. It also adds source_url and usage, and folds urls and a translation block into configuration. Newer clients ask for this one first and only fall back to v1 on a 404.
GET /api/v1/instance/peers public, no-csrf The hostnames of every instance this one has heard of, as a bare JSON array — what instance browsers and "about this server" pages read. Derived from the accounts of the cached remote actors, and from the same walk stats.domain_count uses, so the list and its size cannot disagree. Public, as Mastodon's is: it says who this instance federates with, not who its users are. Rate-limited per anonymous caller.
GET /api/v1/instance/activity public, no-csrf Twelve weeks of activity, newest first, in Mastodon's shape — every value a string, each week keyed by the unix time its Monday began. statuses is counted from the database. logins and registrations are always "0" and cannot be otherwise: an account here is a Nextcloud user, so both belong to the server rather than to this app. Rate-limited per anonymous caller.
GET /api/v1/preferences public, no-csrf (viewer required) The viewer's posting defaults, as Mastodon's Preferences entity: posting:default:visibility (the account's source[privacy]), posting:default:sensitive, posting:default:language (null when unset), and the two reading preferences. reading:expand:media is real: it is this account's choice about media somebody marked sensitive, or what the instance does for somebody who has not chosen (nsfw_policy). Its three values are PeerTube's three NSFW policies under the names Mastodon already has for the same three states — show_all is PeerTube's display, default its blur (covered, blurhash showing, one press away) and hide_all its hide (not drawn, and no button to draw it: opening the post is what it takes). reading:expand:spoilers is still Mastodon's false — a content warning is a different thing from sensitive media and this app keeps no preference about it. A client that cannot read this guesses, which is how it ends up posting publicly for somebody whose default is followers-only.
PUT /api/v1/preferences public, no-csrf (viewer required) expandMedia (show_all, default, hide_all, or '') Nextcloud extension, not a Mastodon route — Mastodon has no write for preferences and sets its own reading preferences on its web front end. The values are Mastodon's all the same, so a client that reads the route above and one that writes here agree about what the three words mean. '' is a fourth thing and not a fourth policy: it puts the account back to following the instance, which is different from choosing what the instance happens to do today — an administrator who later changes the default moves everybody who is following it and nobody who has chosen. Answers with the effective policy, the choice behind it ('' for following the instance) and the instance default. Anything else is a 422.
GET /api/v1/apps/verify_credentials public, no-csrf {"name", "website", "vapid_key"} of the client behind the bearer token; falls back to {"name": "Nextcloud Social", "website": "https://github.com/nextcloud/social/"} when the caller is the app's own session. vapid_key is always present and always empty: there is no Web Push endpoint, and that is the answer that makes a client stop asking. A missing or revoked token is a 401.
GET /api/v1/custom_emojis public, no-csrf The emoji this instance publishes, as Mastodon's CustomEmoji: shortcode, url, static_url, visible_in_picker, and category when one was given (omitted rather than null, which is how a client groups them). Only the ones marked visible are listed — that is what the field means, and a picker is what reads this route; a hidden emoji still renders wherever its shortcode is written. static_url is the same URL as url: a second, still rendering of every upload does not exist, and a static_url pointing at nothing would be worse than one pointing at the picture. Managed with occ social:emoji. Remote custom emoji are unaffected and always worked: Emoji tags on incoming statuses and actors are served in the emojis field of the status and account entities.
GET /emoji/{shortcode} public, no-csrf The picture behind a shortcode, with its stored media type and a day of cacheability (shared caches included — the route answers everybody the same bytes). Unauthenticated for the same reason /media/{uuid} is: this is what a remote server dereferences out of an Emoji tag on a post it received, and it has no token of ours to present. An emoji is published by definition. A shortcode nobody published is a 404.
GET /api/v1/trends/tags public, no-csrf limit (10, capped at 20), period (1h, 12h, 1d — the default —, 3d, 10d; anything else falls back to the default) The hashtags used most on this instance in that window, as Mastodon Tag entities. The counts are the ones the cron job already keeps for every hashtag (HashtagService::manageHashtags()), so this is a read of stored data rather than a query over the stream — specifically the sortable trend_* columns, which Version1000Date20260910000003 backfills on upgrade because the cron alone would never have filled them for hashtags whose counts had stopped moving. history carries a single bucket for the window that was asked for, and accounts in it is always 0: this instance counts uses, not distinct accounts. Hashtags unused in the window are left out.
GET /api/saved_searches/list.json public, no-csrf Not implemented. Initialises the viewer, then always returns [].
GET /.well-known/nodeinfo/2.0 public, no-csrf NodeInfo 2.0 document: version, software.name (instance title), software.version, protocols: ["activitypub"], rootUrl, usage, openRegistrations. Falls back to name Nextcloud Social and the installed app version if no local instance row exists.

Accounts

A Status entity carries view_count — how many accounts here have opened that post's own page — on the author's own copy only, and null on everybody else's: how many people read a post is the author's business. What is counted is narrow on purpose: a post's own page (GET /api/v1/statuses/{nid}), opened by a signed-in account that is not the author, one row per (post, viewer). Not an impression in a timeline, because a post scrolled past has not been read and counting it would both make the number meaningless and write a row for every post on every page of every timeline. It is never federated: a count that arrived from another server would be that server's readers added to this one's, meaning neither.

A Status entity also carries archived, which is true only on the author's own archived posts — nobody else is ever handed one, because no list this server builds contains one.

Two of the four profile fields are also sent as their own keys. An Account entity carries pronouns and support_link beside fields. Neither is a field of its own and deliberately so: a pronoun row is what people already write, it federates as a PropertyValue that every other network shows, and a property only this app understood would be a pronoun nobody else could read. What this app adds is recognising the row — matched case-insensitively against the spellings people use (Pronouns, Pronomen, pronoms, … and Support, Donate, Sponsor, …) — so that a client can draw the pronouns beside the name and the support address as a button without knowing any of them. pronouns is '' when the value is longer than 40 characters (a sentence in the wrong row), and support_link is '' unless the value is an https:// URL, because it is drawn as a button.

Method Route Auth Parameters Description
GET /api/v1/accounts/verify_credentials public, no-csrf The viewer's Person actor serialised in local format. 401 {"error": ...} when unauthenticated.
PATCH /api/v1/accounts/update_credentials public, no-csrf (viewer required, write scope) JSON/form body; display_name, note, locked, discoverable, indexable, bot, source[privacy], fields_attributes, and avatar / header (multipart) Sets the display name, the bio (note, at most 500 characters, stored as plain text and rendered to HTML on the way out — an absent note leaves the stored one alone rather than clearing it), the profile picture and banner, whether new followers need manual approval (manuallyApprovesFollowers), whether the account may be listed in directories and indexed for search, whether it is automated (bot, which also decides whether the actor document says Service or Person), the default audience for new posts, and the profile metadata fields (at most four name/value pairs, both halves required; a list or an object keyed by index — a fifth pair, and a pair with an empty half, are dropped without an error, and [] clears the table). Every field is optional and only what was sent is written, so a client that edits one thing leaves the rest alone. display_name, avatar and bot used to be accepted and dropped, which meant a profile editor — which sends the whole form in one PATCH — got a 200 and showed the old name and picture. The name and picture belong to the Nextcloud account, so they are written there and the actor cache is refreshed; a backend that owns either (LDAP, SAML, anything provisioned elsewhere) makes the request a 422 rather than a silent success. Other Mastodon profile fields are still ignored. Returns the refreshed account entity, which is the one place besides verify_credentials that carries source.
DELETE /api/v1/profile/avatar public, no-csrf (viewer required, write scope) Removes the account's picture, leaving Nextcloud's generated initials, and answers the refreshed account entity. update_credentials can only replace one picture with another — multipart has no way to send "none" — so without this a client can offer "change picture" and not "remove picture". The avatar is the Nextcloud account's, so a backend that owns it (LDAP, SAML) makes this a 422 rather than a silent success. Removing a picture that was never set is not an error.
DELETE /api/v1/profile/header public, no-csrf (viewer required, write scope) Removes the banner and federates the actor Update, so the profile does not keep its banner on every other server. Answers the refreshed account entity. The cached picture is left for the document sweep rather than deleted under readers who are mid-request on its URL.
GET /api/v1/follow_requests public, no-csrf (viewer required) Accounts with a pending follow request towards the viewer, serialised in local format.
POST /api/v1/follow_requests/{id}/authorize public, no-csrf (viewer required, follow or write scope) Accepts the pending follow request from account {id} (numeric id or full actor id; accepts slashes): federates the Accept and marks the follow accepted. Returns the updated relationship entity; 404 when no request is pending.
POST /api/v1/follow_requests/{id}/reject public, no-csrf (viewer required, follow or write scope) Rejects the pending follow request from account {id}: federates a Reject and deletes the follow row. Returns the updated relationship entity; 404 when no request is pending.
POST /api/v1/accounts/{id}/remove_from_followers public, no-csrf (viewer required, write:follows or follow scope) Removes account {id} from the viewer's followers (numeric id, actor id or handle; accepts slashes): federates a Reject of their follow and drops the row, which is the same activity a refused follow request sends — to the other server a follow rejected and a follow withdrawn after being accepted are one statement. This is not a block and not an unfollow: whether the viewer follows them, and every block and mute either way, are left as they were. An account that does not follow the viewer is not an error, so a client that lost the answer and retried gets the same one. Returns the updated relationship entity.
GET /api/v1/blocks public, no-csrf (viewer required) limit (40, capped at 50) Accounts the viewer has blocked. One page, and no Link header: neither route takes a cursor, so the "next" page a header advertised was the page just sent, and a client paging on it scrolled the same block of accounts for ever.
GET /api/v1/mutes public, no-csrf (viewer required) limit (40, capped at 50) Accounts the viewer has muted. Same shape, and no Link header, for the same reason.
GET /api/v1/accounts/relationships public, no-csrf id (array, default []) Relationship entries from FollowService::getRelationships(). Sent as id[]=… on the wire. The parameter carries a default because the dispatcher fills it in before the handler's own error handling can run: a request with no id[] at all used to produce a Nextcloud HTML page instead of {"error": …}. With none given the answer is an empty list.
POST /api/v1/accounts/{id}/follow public, no-csrf (viewer required, follow or write scope) notify, reblogs Follows the account ({id} is the numeric id or a full actor id; accepts slashes). A locked target leaves the relationship in requested until they decide. notify is the bell on a profile — a subscription separate from the follow, so that every post by that account raises a status notification; absent means "leave it as it is", because a client re-following to change something else must not silently turn the bell off. It is reported back as notifying on the relationship, which was hardcoded false before, so a client that had turned the bell on was told it was off and drew it that way. A reply is not one of these notifications: the bell means "tell me when they post", and a thread somebody else started is not that. reblogs is the other switch Mastodon sends with a follow — false keeps that account's boosts out of the home and list timelines while leaving their own posts where they are ("I want to read you, not what you pass on"), and it too is left alone when absent. It is reported back as showing_reblogs, which was hardcoded true before, so a client that had turned the boosts off was told they were on. Stored as a row in social_actor_relation only when the answer is no, and applied as a predicate of the timeline query rather than by dropping rows after the page was read. Returns the updated relationship entity.
POST /api/v1/accounts/{id}/unfollow public, no-csrf (viewer required, follow or write scope) Unfollows (federates Undo{Follow}). Returns the updated relationship entity.
POST /api/v1/accounts/{id}/block public, no-csrf (viewer required) Blocks the account ({id} is the numeric id or a full actor id; accepts slashes). Severs the follow relationship in both directions and, unless federate_blocks is 0, sends a Block activity to the account's server. Returns the updated relationship entity.
POST /api/v1/accounts/{id}/unblock public, no-csrf (viewer required) Lifts a block (federates Undo{Block} under the same setting). Returns the updated relationship entity.
POST /api/v1/accounts/{id}/mute public, no-csrf (viewer required) notifications (true), duration (0) Mutes the account — purely local, never federated. With notifications=true (default) the account's notifications are hidden too. duration is seconds until the mute lifts itself; 0 is "until it is lifted by hand", and re-muting with 0 drops the expiry a previous timed mute left behind. Returns the updated relationship entity, whose mute_expires_at says when a timed mute runs out.
POST /api/v1/accounts/{id}/unmute public, no-csrf (viewer required) Lifts a mute. Returns the updated relationship entity.
GET /api/v1/accounts/familiar_followers public, no-csrf (viewer required) id (one or more account ids) For each named account, which of the people the viewer follows also follow it — the "followed by X and 3 others you know" line on a profile. Answers [{id, accounts}] with at most ten accounts each, and at most twenty ids per call (Mastodon's caps). The overlap is found by one self-join in the database rather than by intersecting two follower lists in PHP: either list can be tens of thousands of rows. The viewer's own profile answers an empty list, as Mastodon's does.
GET /api/v1/accounts/search public, no-csrf (viewer required) q (required), limit (40, capped at 80), resolve (false), following (false) Mastodon's account search — what a composer calls to complete a @handle as somebody types. /api/v2/search answers accounts too, but no client uses it for autocomplete, so mention completion failed in every client that offers it. Searches the cached actors; with resolve it also asks the address or handle's own server, which is what makes completing a handle from another instance work at all. following=true narrows the answer to accounts the viewer follows, which is what a client asks when completing a reply rather than searching. An empty q is an empty list, not an error. Registered before /api/v1/accounts/{id}, which accepts slashes and would otherwise swallow it. Rate-limited per user and per anonymous caller.
GET /api/v1/accounts/{id} public, no-csrf One account, by the numeric id every API entity emits, an @user / user@host handle, a bare local username, or the actor's ActivityPub id — all resolved through resolveTargetAccount(). Returns the account entity in local format; an unknown reference is a 404. {id} accepts slashes (requirements: .+), so this route is registered last of the /api/v1/accounts routes and must stay there — otherwise it swallows lookup, relationships, verify_credentials and the {account} sub-routes. Rate-limited per user and per anonymous caller.
GET /api/v1/accounts/lookup public, no-csrf acct (required) The account behind a handle, from what is already cached — this route never fetches from another server. A leading @ is stripped. 404 when the handle is unknown here, 422 when acct is missing. Rate-limited per user and per anonymous caller.
GET /api/v1/accounts/{account}/statuses public, no-csrf limit (20), max_id (0), min_id (0), since_id (0), pinned (false), only_media (false), media_type ('') Statuses of {account}, which may be the numeric id or a handle; syncs the remote timeline first. {account} accepts slashes (requirements: .+). With pinned=true it returns that account's pinned posts, newest pin first, and skips the remote sync and the paging parameters. Pinned posts are read through the same visibility filter as any other status, so an anonymous caller sees only the public ones — this route is a #[PublicPage], and reading them unfiltered served a pinned followers-only post in full to the internet. only_media is Mastodon's own parameter and keeps just the posts carrying an attachment; media_type (image, video, audio) is a Social extension narrowing that to one kind, which is what the Photos and Videos tabs of a profile ask. It implies only_media, and anything that is not one of the three kinds an attachment can be is read as no preference. Sends a Link header.
GET /api/v1/accounts/{account}/followers public, no-csrf limit (20), max_id (0), min_id (0), since (0) Followers of {account} (numeric id or handle). For a remote domain the actor's followers collection is fetched over HTTP, each entry is resolved through the actor cache, and any actor with no numeric id is left out — a page of accounts all sharing id "0" is one a client cannot act on. Otherwise the local cache is probed and a Link header is sent. Note the fourth parameter is since, not since_id.
GET /api/v1/accounts/{account}/following public, no-csrf limit (20), max_id (0), min_id (0), since (0) Same as above for the following collection.

A Relationship carries id as a string, like every other id on the wire: a client that declares id: String (Ivory, Mona, anything built on Swift's Codable) could not decode the integer this used to be, so follow/block/mute button state broke after every action that returns one. note, languages (always null — there is no per-account language filter) and requested_by are also present; requested_by reports a pending incoming follow — the same rows /api/v1/follow_requests lists — and showing_reblogs is true, which is what the home timeline actually does. note carries the viewer's private note about the account, written through POST /api/v1/accounts/{id}/note (see below) and readable by nobody else. mute_expires_at is this app's own addition: an ISO-8601 date while a timed mute is running, null otherwise. Mastodon carries it on the account entities of GET /api/v1/mutes and not on the relationship, which leaves a client that has just taken a timed mute with no way of being told for how long; it is reported here as well, where every client already looks after a mute. Not to be confused with note on an Account, which is the bio and is implemented (see update_credentials above).

Search (Mastodon v2)

Method Route Auth Parameters Description
GET /api/v1/search public, no-csrf (viewer required) q (required), type, limit (20, capped at 40), resolve Mastodon's v1 search; identical to /api/v2/search, which it delegates to. This path used to be the app's own web-UI search, which answered a client with a Nextcloud envelope and a content key — a 200 a client could make nothing of. That search moved to GET /local/v1/search.
GET /api/v2/search public, no-csrf (viewer required) q (required), type (accounts/statuses/hashtags, empty = all), limit (20, capped at 40), resolve Mastodon's search entity: accounts (URI + name search over cached actors), statuses (the viewer-bounded full-text search), hashtags (Tag entities with an empty history). resolve asks the server to go and get something it has never seen, which is how a reader who pasted a link to a remote post can reply to it or boost it here at all. It only ever fires for a q that is an http(s) address and only when the local search found nothing; the document fetched has to claim the very address it was fetched from (a document is evidence about itself and nothing else) and has to be a Note or a Question, or it is refused and the answer is empty. The author is cached before the post is stored, so it renders as theirs. An account named by its actor URL is resolved whether or not resolve is set — that predates this parameter. The route requires a viewer, so no anonymous caller can use it to make this instance fetch for them, and it is rate-limited both per user and per anonymous caller. Pagination offsets are accepted but ignored. Rate-limited per user and per anonymous caller.

Statuses

Method Route Auth Parameters Description
POST /api/v1/statuses public, no-csrf (viewer required, write scope) Body (JSON or form-encoded): status, visibility, media_ids (array), in_reply_to_id, quote_id, spoiler_text, sensitive, post_as. Header: Idempotency-Key Creates a post. The body is read from php://input and parsed by Content-Type; a body that declares itself JSON and is not valid JSON is a 422, not the HTML error page a TypeError used to produce. Only status, visibility, media_ids, in_reply_to_id, quote_id, poll, spoiler_text and sensitive affect the created post — sensitive is stored in its own column and read back (a non-empty spoiler_text also reads as sensitive); spoiler_text is stored as plain text, so a warning containing an apostrophe stays an apostrophe on the wire. visibility maps to the stream type (public, unlisted, followers/private, direct); omitting it takes the account's default (source[privacy], public unless the account set otherwise), and a value this app does not know is a 422 rather than a post silently addressed to nobody. quote_id quotes another post: the id is resolved the same way in_reply_to_id is, and the post is published immediately while the quoted author's server is asked for permission in the background (see Quote posts in docs/Architecture.md) — so a quote starts out pending and becomes accepted only when the approval arrives. A status whose text and content warning together run past the instance's max_characters is a 422, counted in characters rather than bytes. An Idempotency-Key is remembered against the created status for an hour, scoped to the presenting token, and a repeat of the same key returns the same status instead of posting again. Attaching media also decides whether those attachments are world-readable: an attachment only becomes public when the post is public or unlisted. scheduled_at (ISO 8601) publishes the post later instead of now: the answer is a ScheduledStatus entity — id, scheduled_at, params, media_attachments, and no content, account or created_at — and nothing is posted until Cron\ScheduledPosts picks it up. It must be at least 5 minutes in the future; sooner is a 422, as is a value that is not a date (it used to be accepted and the post published at once). An account may have 300 scheduled statuses in total and 25 for any one day; past either is a 422. The visibility is resolved when the post is scheduled and stored resolved, so changing source[privacy] afterwards cannot move the audience of a post that is already waiting. Idempotency-Key does not apply — a scheduled post creates no status to remember. Rate-limited per user.
GET /api/v1/statuses/{nid} public, no-csrf One status by numeric id, local export format.
PUT /api/v1/statuses/{nid} public, no-csrf (viewer required, write scope) Body: status, spoiler_text, sensitive Edits an own post via PostService::editPost(). An empty spoiler_text is sent as null, meaning "leave it alone"; a given one is stored as plain text, the same as on create. sensitive is not treated that way — it is read as a boolean, so an edit that omits it clears the flag. Editing stamps published with the moment of the edit while created_at keeps the original, which is what the status entity's edited_at is derived from.
DELETE /api/v1/statuses/{nid} public, no-csrf (viewer required, write scope) Deletes one of the viewer's own statuses and answers with the status that was removed, which is what a client's "delete & redraft" puts back in the composer. Somebody else's status is a 404, the same answer an unknown id gets.
GET /api/v1/statuses/{nid}/delivery public, no-csrf (viewer required) This app's own route. Where one of the viewer's own posts got to, read from the delivery queue: delivered, sending, waiting, failing and abandoned counts, total, and instances — one {host, state, tries, last} per server, the ones that need the author's eye first. Answered only for the author; anybody else's post is a 404, the same 404 as a post that does not exist. Only as good as the retention: a delivered or abandoned request is kept for retention seconds (seven days) and then purged, so an old post reports nothing rather than reporting wrongly. Rows queued before the column existed carry no object and never appear.
GET /api/v1/statuses/{nid}/source public, no-csrf (viewer required) Mastodon's StatusSource: {"id", "text", "spoiler_text"} for one of the viewer's own statuses. The stored content is the HTML rendered from the original text, so it is turned back — <br> becomes a newline, the remaining tags are stripped and entities are decoded. Tusky and Ivory will not offer their edit button without this route, even though PUT /api/v1/statuses/{nid} has always worked.
GET /api/v1/statuses/{nid}/card public, no-csrf The link preview of one status, as Mastodon's PreviewCard. The card is already inlined in the status entity, which is what most clients read; this route was a 405 rather than a 404, because the path matched the POST-only action route and nothing answered a GET. A status with no link answers {} — Mastodon's own answer, and not an error, since most statuses have no card.
GET /api/v1/statuses/{nid}/context public, no-csrf Ancestors/descendants of the status.
GET /api/v1/statuses/{nid}/favourited_by public, no-csrf (viewer optional) limit (40, capped at 80) The accounts that favourited the status, newest first, as Account entities. The status is resolved through the visibility filter first, so one the caller may not read is a 404 and no reaction of it is looked at — who liked a post is as private as the post. An account this instance has never cached is left out rather than sent without a handle or an avatar. No Link header.
POST /api/v1/statuses/{nid}/react public, no-csrf (viewer required) emoji (required) Reacts to a status with an emoji and answers the status, its reactions rebuilt, so a client redraws the card from one response. Sends an EmojiReact to the author's instance — not an ActivityStreams type, but the one Misskey, Pleroma, Akkoma, Iceshrimp and Sharkey all use; Mastodon ignores an activity type it does not know, so a reaction to a Mastodon peer costs nothing there. Unicode emoji only: :shortcode: custom emoji are refused with a 400, because accepting one means drawing a picture from somebody else's server inside a reaction bar, which is a per-post request to an arbitrary host and an image this instance neither moderates nor caches. The emoji is a parameter rather than a path segment — one emoji can be a long grapheme cluster, and percent-encoding it into a URL to get it back out again gains nothing.
POST /api/v1/statuses/{nid}/unreact public, no-csrf (viewer required) emoji (required) Takes a reaction back and answers the status. The row goes whether or not the Undo reaches the peer: a reader who presses their own reaction again has taken it off, and leaving it because a server is unreachable would be the app disagreeing with what they just did.
GET /api/v1/statuses/{nid}/reactions public, no-csrf (viewer optional) The reactions on a status: {name, count, me} each, most used first, ties broken by the emoji so the bar does not reshuffle between two readers. The status is resolved through the visibility filter first, so one the caller may not read is a 404 and none of its reactions is looked at — who reacted to a post is as private as the post. me is false for an anonymous caller.
GET /api/v1/gifs public, no-csrf (viewer required) q The instance's shared picture library, or the part of it whose title or slug contains q, case-insensitively. Each entry is {slug, title, url, media_type}. A viewer is required: it is a picker inside the composer, and there is no reason to hand the whole library to anybody who asks. The filtering is done in PHP over the whole set rather than with a LIKE '%…%' — the set is tens of rows, one query returns all of them anyway, and a leading wildcard is a scan on all three databases this app supports.
GET /gif/{slug} public, no-csrf The bytes behind a slug, cached for a day. Unauthenticated, like /emoji/{shortcode}: the picker draws a grid of these and they are the same bytes for everybody on the instance.
POST /api/v1/media/from-gif public, no-csrf (viewer required) slug (required), description Attaches a library picture to a post being written, and answers the MediaAttachment the composer holds on to. A copy, through the same storeAttachment() an upload and a Files pick go through, so the sniffing, the size guard and the resizing are one path rather than three that can drift — and a copy rather than a reference for the reason /api/v1/media/from-file gives: a post keeps the picture it was published with, so an administrator taking something out of the library cannot empty a post that has already federated. With no description the picture's title is used, because a library picture arriving with no alt text at all is the thing the ALT badge exists to complain about.
GET /api/v1/statuses/{nid}/reblogged_by public, no-csrf (viewer optional) limit (40, capped at 80) The accounts that boosted the status, newest first. Same rules as favourited_by.
GET /api/v1/statuses/{nid}/quotes public, no-csrf (viewer optional) limit (20, max 40), max_id The posts that quote this one, newest first — Mastodon 4.5's. What comes back is what this server holds: a local quote, and a remote one that reached somebody here. A quote written on a server nobody here follows was approved and is real and is not in the list, because there is no status entity to put in it; Mastodon's own answer has the same edge. Read as the viewer, so a quote inside somebody's followers-only post is not handed to a reader by a list about their own post.
PUT /api/v1/statuses/{nid}/interaction_policy public, no-csrf (bearer write:statuses) quote_approval_policy: public, followers or nobody Who may quote one of the caller's own posts. Written onto the post (social_stream.quote_policy) and advertised on its ActivityPub document as interactionPolicy.canQuote.automaticApproval, which is what Mastodon 4.5 reads before it offers a quote button. It decides what happens to QuoteRequests that arrive from now on; it does not reach back and withdraw permissions already given, because a quote that has been published and read is not undone by a switch being flipped. Somebody else's post is a 404, the same answer an id that does not exist gets.
POST /api/v1/statuses/{nid}/quotes/{quoting}/revoke public, no-csrf (bearer write:statuses) nid the quoted post, quoting the post to detach Takes one quote of the caller's own post back. A Reject naming the QuoteRequest that was accepted goes to the quoting server — FEP-044f's way of withdrawing a permission, and what social_quote_grant was written for: the request has to have been recorded at the moment the grant was made or there is nothing to name. The quote then shows there as revoked rather than refused, which is what this app does with an incoming one. A local quoting post is withdrawn here directly, because there is nobody to tell. A post that does not quote this one, or a post that is not the caller's, is a 404.
POST /api/v1/statuses/{nid}/translate public, no-csrf (viewer required) lang (the language to translate into; defaults to the reader's Nextcloud language) One status in the reader's language, as Mastodon's Translation entity — {content, spoiler_text, media_attachments, poll, detected_source_language, provider}, not a Status: a translation has no id, no author and no counters, and a client shows it under the post rather than instead of it. Declared before {act}, which would otherwise match this path. Translated by whatever translation provider this Nextcloud has; a server with none answers 503 and says so in configuration.translation.enabled. It never answers with the original text — which is what this route used to do, and what a reader could not tell from a translation. The body and the content warning are always translated, then poll options and alt texts while the per-request budget lasts (TranslationService::MAX_TEXTS, 12 — each text is a round-trip to the provider); what is past the budget is left out of the entity rather than returned untranslated, so a client shows the original for it.
POST /api/v1/statuses/{nid}/{act} public, no-csrf act (path) Performs an action on a status — see the action table below.

{act} is validated against ActionService::$availableStatusAction. Accepted values are favourite, unfavourite, reblog, unreblog, bookmark, unbookmark, mute, unmute, pin, unpin; anything else throws InvalidActionException:

act value Effect
favourite, unfavourite Creates/deletes a Like (LikeService).
reblog, unreblog Creates/deletes an Announce (BoostService). The Mastodon-ish names boost and unboost are not accepted.
bookmark, unbookmark Toggles the viewer's local bookmark flag (social_stream_act.bookmarked). Purely local, never federated; the bookmarked posts are served by /api/v1/bookmarks.
pin, unpin Pins/unpins one of your own local, public or unlisted posts to your profile (PinService), and tells the followers with an Add or Remove naming the actor's featured collection — a peer has nothing else to prompt it to re-read that collection. At most 5 pins; pinning somebody else's post, a remote post, a followers-only or direct post, or exceeding the limit raises InvalidActionException (a 422). The visibility rule is Mastodon's pinnable set, and it exists because the featured collection is served to anonymous callers. A pin is stored as a Pin row in social_action and published in the actor's featured collection — it is never federated as an activity of its own.
mute, unmute Mutes the conversation the status belongs to, for the viewer. Mastodon's conversation mute is about being told: the thread's posts stay on every timeline and only the notifications it would produce stop, which is what somebody muting a thread they are in has asked for. The mute is recorded against the thread's root (social_convo_state.muted), so a reply that arrives tomorrow is covered by a mute taken today — the whole point of muting a conversation rather than a post. A post that replies to nothing, or whose parent this instance does not hold, is its own root.

The response is the status itself in local format.

In that format, in_reply_to_id and in_reply_to_account_id carry the parent status's numeric id and its author's, resolved from the stored ActivityPub id (memoised per request, so a thread's replies are one lookup). Both are null for a post that is not a reply and for a parent this instance has never seen. tags is Mastodon's [{name, url}]; Note::jsonSerialize() still emits the app's own hashtags: ["foo"] alongside it when the status is exported with complete details. edited_at is when the post was last edited, or null. quote is Mastodon 4.5's Quote entity — {state, quoted_status} — or null for a post that quotes nothing. state comes from the quoted author's approval, not from whether this instance holds the post: pending until they answer, then accepted, rejected, or revoked if they take it back. quoted_status carries the quoted post inline only when the quote is accepted and the viewer may read it — an accepted quote of a post this reader may not see is accepted with a null quoted_status, so a quote never becomes a way of reading somebody's followers-only post. Mention ids are strings even when the handle could not be resolved. attachment — an ActivityPub-named duplicate of media_attachments — is no longer part of the client format. Each MediaAttachment carries one key beyond Mastodon's: media_type, the full mime (image/png), which is what the ActivityPub Document the post is served as states; a Mastodon client ignores it.

Scheduled statuses

Method Route Auth Parameters Description
GET /api/v1/scheduled_statuses public, no-csrf (viewer required) limit (20), max_id (0), min_id (0), since_id (0) The viewer's waiting posts, soonest first, as ScheduledStatus entities with their attachments already expanded. Sends no Link header — a ScheduledStatus carries no status nid to page on — but the three cursors are honoured; they page on the scheduled-status row id.
GET /api/v1/scheduled_statuses/{id} public, no-csrf (viewer required) One waiting post. One that is not the viewer's is a 404, which is also the answer for one that does not exist.
PUT /api/v1/scheduled_statuses/{id} public, no-csrf (viewer required, write scope) Body (JSON or form-encoded): scheduled_at (required) Moves a waiting post to another time. The five-minute minimum applies again; a missing or unparseable scheduled_at is a 422, somebody else's post a 404. Moving a post inside the day it is already in is not counted against that day's cap.
DELETE /api/v1/scheduled_statuses/{id} public, no-csrf (viewer required, write scope) Cancels a waiting post and answers {}. Cancelling one that is gone, or one that is not the viewer's, is a 404.

A due post is published by Cron\ScheduledPosts down the same path an immediate post takes (PostService::createPost()), with the request replayed from params. The row is deleted before the post is attempted, so exactly one worker publishes it; a post that cannot be published is logged at warning and not retried, because the alternative federates it twice.

Lists

Method Route Auth Parameters Description
GET /api/v1/lists public, no-csrf (viewer required, read:lists scope) Every list the viewer owns, oldest first, as Mastodon List entities. Unpaged, as Mastodon's is: a client draws the whole sidebar from one call. Asking is also what makes the group lists the viewer is missing — one per Nextcloud group they are in — and removes the ones for groups they have left; a failure there is logged and the lists are answered anyway.
POST /api/v1/lists public, no-csrf (viewer required, write:lists scope) title (required), replies_policy (list), exclusive (false) Creates a list and returns it. A blank or whitespace-only title is a 422, and a replies_policy outside followed/list/none is a 422 rather than quietly stored as the default. Titles are not unique: two lists called "Friends" are two lists, as on Mastodon.
GET /api/v1/lists/{id} public, no-csrf (viewer required, read:lists scope) One List. A list that is not there and a list that is somebody else's are the same 404: telling them apart would say whether an id exists.
PUT /api/v1/lists/{id} public, no-csrf (viewer required, write:lists scope) title (required), replies_policy, exclusive Updates the list and returns it. title is required on an update as on a create, so an absent one is a 422 rather than "keep what is there". Omitted replies_policy/exclusive are left alone.
DELETE /api/v1/lists/{id} public, no-csrf (viewer required, write:lists scope) Deletes the list and its memberships, and answers {}.
GET /api/v1/lists/{id}/accounts public, no-csrf (viewer required, read:lists scope) limit (40, capped at 500), max_id (0), min_id (0) The list's members as Account entities, newest addition first. Sends a Link header whose cursor is the social_list_member row id, not the account: an account can be removed from a list and added again, so its own id does not move in one direction. A member whose actor is no longer cached is left out of the page rather than sent half-filled, but its row still decides the cursor, so paging does not stall on it. limit=0 is not accepted, although Mastodon documents it as "all accounts without pagination": Nextcloud's dispatcher applies a range of 1–500 to every parameter named limit (Dispatcher::ensureParameterValueSatisfiesRange) and throws before the route is reached, so a 0 comes back as a 500 and an HTML error page rather than JSON. That is true of every limit this app takes, on this endpoint and any other. Ask for 500 instead.
POST /api/v1/lists/{id}/accounts public, no-csrf (viewer required, write:lists scope) account_ids[] (required; a bare account_ids is accepted too) Adds accounts and answers {}. A list is a view of what the owner already follows, so an account they neither follow nor have a pending follow request to is a 404 — Mastodon's answer, because the follow the membership would attach to is what is missing. The owner may be in their own list without following themselves. Every id is resolved before anything is written, so a request naming one account that may not be added adds none of them. Adding an account already in the list is a no-op.
DELETE /api/v1/lists/{id}/accounts public, no-csrf (viewer required, write:lists scope) account_ids[] (required) Removes accounts and answers {}. No follow is required — unfollowing somebody must not leave them stuck in a list — and removing one that is not in the list is not an error.
GET /api/v1/accounts/{account}/lists public, no-csrf (viewer required, read:lists scope) Which of the viewer's own lists {account} is in. Never anybody else's: which lists a stranger put somebody in is not a thing either of them may read.

Group lists. Every Nextcloud group the viewer is in is a list of theirs, made on their behalf and kept in step with the group. The List entity carries one key beyond Mastodon's four: nextcloud_group, the group's id, or null for a list made by hand — a Mastodon client ignores it; this app's own client draws the group icon from it. A group list's title is the group's display name and its members are the group's members who have a Social account, so DELETE /api/v1/lists/{id}, POST /api/v1/lists/{id}/accounts and DELETE /api/v1/lists/{id}/accounts on one are a 422 naming the group; PUT /api/v1/lists/{id} keeps the title and applies replies_policy and exclusive. Leaving the group is what removes the list. Groups larger than 500 members get no list.

A list is private to the account that made it, and that is the whole of its access model — there is no sharing and no visibility flag. Every route above resolves its list through ListsRequest::getOwnedById(), which carries the owner as a predicate of the SQL statement rather than as a check made after the row was read.

replies_policy and exclusive are stored and handed back faithfully, and neither yet changes which posts a timeline selects: exclusive does not remove members from the home timeline, and replies_policy does not filter replies out of the list timeline.

Pixelfed's own routes

Pixelfed speaks the Mastodon client API for almost everything; what follows is the small surface that is its own. This is not all of Pixelfed's v1.1 — it is the part its official app calls to start up and to draw discover. Routes are added when something asks for them, not to fill in a namespace.

Nothing here ranks or selects anything of its own: discover is the same TrendService the Mastodon trend routes use and the same SuggestionService behind /api/v2/suggestions, so a post or an account cannot be popular on one route and absent from the other.

Suggestions come from three places, each filling only the slots the one before it left empty: the follow graph (friends_of_friends), the fediverse field of this Nextcloud's own profiles (featured — the handles the people here wrote down, honoured with the field's own visibility and never the ones marked private), and the local accounts that opted in to the directory (most_interactions). An account this app creates has its handle written into that field when the field is empty; a value already there is the person's own and is left alone.

Two spellings, one route. Pixelfed serves most of this surface at both /api/v1.1/… and /api/pixelfed/v1/…, and which one its app asks for depends on the version of the app. Fourteen of the routes below therefore carry the second spelling as well, listed in the table as an also — the same method, the same answer, no second implementation. Two routes Pixelfed's app calls are not served, because Pixelfed does not serve them either: GET /api/v2/media (its v2 media route is a POST upload, and a GET there is a 405 here as it is there) and api/pixelfed/v2/discover/posts/trending (there is no v2 under the pixelfed prefix; the trending route is the v1 one below).

Method Route Auth Parameters Description
GET /api/v2/config public, no-csrf The numbers and switches the Pixelfed app reads once, on launch, before it will draw anything. Answered without a viewer, because the app asks before anybody has signed in. Every value is derived, never restated: the ceilings come from the constants the server enforces and the mime list is asked of CacheDocumentService::filterMimeTypes(), so the client cannot be told a limit the server does not keep. open_registration is always false — an account here exists because a Nextcloud user does. The features block says what this app does, including the things it does not (live_streaming, push_notifications, circles are false): announcing a screen that is not there is worse than not announcing it.
GET /api/v1.1/teams public, no-csrf (viewer required, read:accounts scope) The team accounts the viewer may post as right now, as Account entities: {"teams": [Account, …]}. Asked of Nextcloud's group manager rather than of a stored membership — somebody who left the group this morning may not post as it this afternoon, and the only way to be sure of that is to ask. An empty list is the ordinary answer, because most instances have none.
GET /api/v1.1/portfolio public, no-csrf (viewer required, read:accounts scope) The viewer's own page of work, published or not, with the pictures it would show — as the internet will see them, not as their owner can. An owner previewing their draft was shown their followers-only pictures among the rest, and none of those would have been on the published page; a preview that shows a photograph which will not appear is worse than no preview. An account that has never opened the editor gets the defaults rather than a 404: there is nothing to find, and this is what lets an editor render without a "create it first" step nobody needs.
POST /api/v1.1/portfolio public, no-csrf (viewer required, write:accounts scope) active, title (128), intro (500), layout (grid or rows), source (recent or collection), collection_id, show_captions, show_places, show_dates, show_avatar Writes it. active is the moment its owner decides the internet may read it; a row without it is a draft. source: collection with a collection that is not the viewer's own is a 422, checked when it is asked for rather than rendered as an empty page afterwards. Returns the page as the GET above would.
GET /api/v1.1/portfolio/{handle} public, no-csrf Somebody's published page, as the internet reads it: the account, the title, the sentence, the display switches, and up to 60 posts. No viewer is resolved at all, deliberately — what a portfolio shows is what the whole internet may see, whoever happens to be reading, so the posts are read the way an anonymous reader reads them and a followers-only photograph cannot reach the page. A page its owner has not turned on is a 404, not an empty page with their name on it, and so is a handle this server does not know.
POST /api/v1.1/compose/tag public, no-csrf (viewer required, write:statuses scope) status_id (required), accounts (a list of handles or ids) Names the people in one of the viewer's own photographs — Pixelfed's MediaTag, and a staple of every photo network there has been. accounts is the whole list the post should end up naming, not what to add: anybody dropped from it is untagged, which is what makes a client that re-sends its list on every edit a no-op rather than a growing pile. Only the post's author may name anybody in it; anybody else asking gets the 404 a post that does not exist gets. A post with no picture is a 422, and so is a list longer than twenty — a photograph of a party is the case that has to fit, and a longer list is not naming people, it is addressing a mailing list. A name this server cannot resolve is dropped from the list rather than failing the rest of it. Each name is written onto the post as a Mention and the post is re-sent as an Update, which is what makes a remote account's own server tell them; it does not widen who may see the post. Returns {"tagged_people": [Account, …]}. Also at /api/pixelfed/v1/compose/tag.
POST /api/v1.1/compose/tag/untagme public, no-csrf (viewer required, write:statuses scope) status_id (required) Takes the viewer's own name off a photograph, which is Pixelfed's whole remedy for being named in somebody else's picture: being in one is not something to need their permission to leave. The post's author may also take a name off, and does so through the same route with the post as theirs. {"untagged": true}, or false when there was no such name. Also at /api/pixelfed/v1/compose/tag/untagme.
GET /api/v1.1/accounts/{account_id}/tagged public, no-csrf (viewer required, read:statuses scope) limit (20, max 40), max_id "Photos of you", for anybody: the posts that account is named in, newest first, as Status entities. Which of them the reader may see is not decided here — the ids come out of the tag table and each post is then read the way any other post is read for this reader, so one they may not see is simply not among them rather than being filtered out by a rule written twice. Also at /api/pixelfed/v1/accounts/{account_id}/tagged.
GET /api/v1.1/discover/categories public, no-csrf The subjects this instance says it is about, in the order an administrator put them in: {"categories": [{"id", "name", "hashtags": ["brutalism", …]}]}. Curated, not counted — every other route in this section ranks something, and this one is the instance's own claim about itself, which no trend counter can work out. Public, because an Explore page is the first thing somebody looking at a server sees, and empty ([]) until somebody names one, which is why the page renders nothing rather than an empty shelf. Pixelfed's own DiscoverCategory, minus its per-category cover picture.
GET /api/v1.1/discover/accounts/popular public, no-csrf (viewer required, read scope) limit (20, max 20) The same suggestions /api/v2/suggestions answers, unwrapped — Mastodon wraps each account in a {source, account} suggestion and Pixelfed sends the accounts themselves. Needs a viewer, as the Mastodon route does: there is no anonymous "accounts you might follow". Also at /api/pixelfed/v1/discover/accounts/popular.
GET /api/v1.1/discover/posts public, no-csrf limit (20, max 20), offset (0), period The same answer as /api/v2/discover/posts, at the path the app asks for — a second route onto one implementation, not a second implementation.
GET /api/v1.1/discover/posts/hashtags public, no-csrf limit (20, max 20), period The trending tags /api/v1/trends/tags answers, for the row of tags above the discover grid. Also at /api/pixelfed/v1/discover/posts/hashtags.
GET /api/v1.1/discover/posts/trending public, no-csrf range (daily, monthly, yearly), limit (20, max 20), offset The path the app's Discover screen actually calls, onto the same implementation as /api/v1.1/discover/posts. daily is this app's one-day trend window, monthly and yearly its ten-day one — the counters run no further back. Also at /api/pixelfed/v1/discover/posts/trending.
GET /api/v1.1/discover/posts/network/trending public, no-csrf range, limit, offset Pixelfed's "trending across the network". The same answer as above: what this server knows of the network is what it has cached, and the trend counters already cover it.
GET /api/v1.2/stories/carousel public, no-csrf (viewer required, read:stories scope) The same stories /api/v1/stories/carousel answers, in the shape the app's story bar draws: {self, nodes}, one node per account (id, user, nodes, url, seen), each story a node of id, pid, type (photo/video), src, duration, seen, created_at. Also at /api/pixelfed/v1/stories/carousel.
POST /api/v1.1/stories/seen public, no-csrf (viewer required, write:stories scope) id (required) POST /api/v1/stories/{id}/seen with the id in the body, which is where the app puts it. Answers {"code": 200}. Also at /api/pixelfed/v1/stories/seen.
POST /api/v1.1/stories/self-expire/{id} public, no-csrf (viewer required, write:stories scope) DELETE /api/v1/stories/{id} under the name the app uses. Answers {"code": 200, "msg": …}. Also at /api/pixelfed/v1/stories/self-expire/{id}.
GET /api/v1.2/stories/viewers public, no-csrf (viewer required, read:stories scope) sid (required) Who watched one of the viewer's own stories, newest viewer first, as Account entities. Somebody else's story is the same 404 as one that does not exist: who watched is told to the poster and to nobody else, like the view count. Also at /api/pixelfed/v1/stories/viewers.
POST /api/v1.2/stories/react public, no-csrf (viewer required, write:stories scope) sid (required), reaction (required, at most 20 characters) An emoji sent back to whoever posted a story. Only somebody who follows the poster may send one, which is the same condition as being able to see the story at all, and a story the viewer may not see is the same 404 as one that does not exist. At most five answers to one story from one account (Pixelfed's own number) — past that a 422, because what a story otherwise gives somebody is a private channel to the poster that the poster cannot close. A reaction to a story that came from another server is delivered to its author as a Story:Reaction; one to a story posted here notifies its author and goes nowhere else. Also at /api/pixelfed/v1/stories/react.
POST /api/v1.2/stories/comment public, no-csrf (viewer required, write:stories scope) sid (required), caption (required, at most 500 characters) The same, in words. Pixelfed calls this a comment and turns it into a direct message; here it stays beside the story and is deleted with it. It is not a post: there is no social_stream row behind one, so it cannot appear in a timeline, on a profile or in an outbox, and it does not outlive the thing it was about. Delivered to a remote author as a Story:Reply. Also at /api/pixelfed/v1/stories/comment.
GET /api/v1.2/stories/reactions public, no-csrf (viewer required, read:stories scope) sid (required) What has been said about one of the viewer's own stories, oldest first: {"reactions": [{"id", "story_id", "type": reaction or reply, "content", "created_at", "account"}]}. Somebody else's story is a 404, like the viewer list and the view count: who answered is the poster's business. Also at /api/pixelfed/v1/stories/reactions.
GET /api/v1.2/stories/mention-autocomplete public, no-csrf (viewer required, read scope) q (required) The accounts a @ in a caption could mean — the same search /api/v1/accounts/search runs, capped at 24.
GET /api/v1.1/collections/self public, no-csrf (viewer required, read:collections scope) The viewer's collections in Pixelfed's shape: id, pid, visibility (public, or private for followers-only), title, description, thumb (the first picture of the first post), url (this app's own page for it), post_count, published_at, created_at, updated_at. Also at /api/pixelfed/v1/collections/self.
GET /api/v1.1/accounts/username/{username} public, no-csrf An Account by its username — bare for a local account, name@host for a remote one this server already knows, an actor URL or a client id. Nothing is fetched from elsewhere: /api/v1/accounts/lookup with the name in the path.
GET /api/v1.1/accounts/mutuals/{id} public, no-csrf (viewer required, read scope) The accounts the viewer follows that also follow {id} — which is exactly Mastodon's familiar_followers, unwrapped, capped at 24.
DELETE /api/v1.1/accounts/avatar public, no-csrf (viewer required, write:accounts scope) DELETE /api/v1/profile/avatar under the app's name: removes the picture and answers with the account as it now is. Also at /api/pixelfed/v1/accounts/avatar.
POST /api/v1.1/report public, no-csrf (viewer required, write:reports scope) report_type (required: spam, sensitive, abusive, underage, violence, copyright, impersonation, scam, terrorism), object_id (required), object_type (required: post, user, story), message A report as the app files one, kept as the report this server keeps. The reason becomes Mastodon's category (spamspam, copyrightlegal, abusive/underage/violence/terrorismviolation, the rest → other) and is kept in the comment as well, because violation says less than underage did. A post is reported against its author and carried with the report; a story against its owner alone. Reporting yourself is a 422. Answers {"msg", "code": 200}. Also at /api/pixelfed/v1/report.
GET /api/v1.1/compose/settings public, no-csrf (viewer required, read scope) What the app's composer reads first: allowed_media_types, max_caption_length, default_license (1, all rights reserved — the only licence a post here carries), media_descriptions (false: alt text is asked for, not demanded), max_file_size, max_media_attachments, max_altext_length, default_scope (the viewer's posting default, in Pixelfed's words). Also at /api/pixelfed/v1/compose/settings.
POST /api/pixelfed/v1/archive/add/{id} public, no-csrf (viewer required, write:statuses scope) Puts one of the caller's own posts away. It leaves this server's profile, timelines, hashtag pages, search and outbox — every list this server builds — and is not deleted and not federated: the post is still on every server that received it, and taking it back from them is what DELETE /api/v1/statuses/{id} is for. Reversible, which is the point. A post that is not the caller's own, or not this server's, is a 404. {"code": 200}.
POST /api/pixelfed/v1/archive/remove/{id} public, no-csrf (viewer required, write:statuses scope) Puts it back. Same 404.
GET /api/pixelfed/v1/archive/list public, no-csrf (viewer required, read:statuses scope) limit (50, max 50), max_id The caller's own archive, newest first, as Status entities. The one read in this app that answers with archived posts: every other read is fail-closed (StreamRequestBuilder::hideArchived()), so a list written later shows none until somebody decides it should.
GET, POST /api/pixelfed/v1/app/settings public, no-csrf (viewer required, read scope) common (on the POST) The Pixelfed app's own switches, kept on the server so that a reinstall finds the app as it was left: timelines (show_public, show_network, hide_likes_shares), media (hide_public_behind_cw, always_show_cw, show_alt_text) and appearance (links_use_in_app_browser, themelight, dark or system). Answered as {id, username, updated_at, common}, with Pixelfed's own defaults and updated_at: null until the account has saved them once, which is how the app tells "never set" from "set to the defaults". Not one of them is a setting of this app: they decide what that client draws, and the server's part is to remember rather than to interpret. A POST keeps only the eight switches and drops anything else — a blob written by a client and handed back to a client that kept whatever it was given would be a place to park arbitrary content under somebody's account. A POST with no common is a read.
GET /api/v1.1/nag/state public, no-csrf (viewer required, read scope) {"active": false}. Pixelfed's nag is a banner its server raises when it wants something of the user; this one never does.
GET /api/v1.1/push/state public, no-csrf (viewer required, read scope) The push settings screen, told the truth: notify_enabled and has_token are false and every notify_* is false, because there is no Web Push here. The screen renders with everything off instead of erroring.
POST /api/v1.1/push/disable, /api/v1.1/push/update public, no-csrf (viewer required, read scope) The same state as above. Disabling what is already off changes nothing; asking to enable it answers with what is actually the case, so the toggle the app draws is the honest one.
POST /api/v1.1/push/compare public, no-csrf (viewer required, read scope) expo_token match: false, has_existing: false — there is no token here to compare against.
GET /api/v1/tags/{hashtag}/related public, no-csrf limit (20, max 40) The tags that travel with one — those on the same public posts, most often first — as Tag entities. A tag on a followers-only post is not public knowledge and does not count, however often it recurs. Pixelfed's route; Mastodon has no equivalent.
GET /api/v1.1/direct/thread public, no-csrf (viewer required, read:statuses scope) pid (required), max_id, min_id One direct-message thread as the app's chat screen draws it: the other party on top (id, name, username, avatar, url, isLocal, domain, lastMessage, timeAgo) and messages, oldest first, twenty a page — each id, isAuthor, type (text, photo, video), text (the post without its markup), media, carousel, created_at, timeAgo, seen, reportId. The messages are this app's direct posts, read through the same destination rows the direct timeline uses: a direct post writes one for every party to it, so the thread is every direct post with a row for both. Also at /api/pixelfed/v1/direct/thread.
POST /api/v1.1/direct/thread/send public, no-csrf (viewer required, write:statuses scope) to_id (required), message (required, 1–500), type (text, emoji) Sends one message: a direct post to the account, through the same path the composer's own direct messages take, with the recipient's handle in front of the text — which is how a direct post names who it is for here. Answers with the message as the thread shows it. Sending to yourself is a 422. Rate-limited to 60 a minute. Also at /api/pixelfed/v1/direct/thread/send.
DELETE /api/v1.1/direct/thread/message public, no-csrf (viewer required, write:statuses scope) id (required) Takes one of the viewer's own messages back — DELETE /api/v1/statuses/{id} under the app's name. Somebody else's is the same 404 as one that never existed. Answers [200], as Pixelfed does. Also at /api/pixelfed/v1/direct/thread/message.
GET /api/v1.1/direct/compose/mutuals public, no-csrf (viewer required, read scope) Who the app's "new message" screen offers: the accounts the viewer follows that follow them back, at most 50, as Account entities. A direct message to a stranger is the thing most people never want to receive, so the screen starts from the people who already talk to each other.

Starter packs

A named handful of accounts worth following. They answer the one question a new account has that no algorithm here can: SuggestionService works off the follow graph, and on day one there is none — it falls back to whoever posted recently, which is a list of strangers sorted by luck.

A pack is a list of user@host handles and nothing else. No table: the accounts are not this instance's to own, and the handles are the only durable reference to them.

Method Route Auth Parameters Description
GET /api/v1/starter_packs public, no-csrf Every pack, resolving nobody: names, descriptions and sizes. Turning a handle into a profile means a WebFinger lookup and an actor fetch against another server, and doing that for every handle of every pack to draw a list of names would make the page wait on the internet for nothing. Answered to a signed-out visitor — what an instance suggests is something it publishes about itself.
GET /api/v1/starter_packs/{slug} public, no-csrf One pack with its handles resolved to Mastodon Account entities — the same shape /api/v1/suggestions answers with, because what draws a pack draws a name, a handle and an avatar per account. This one does reach other servers, which is why it is its own route: the cost is paid when somebody opens a pack. Handles that will not resolve — host down, account moved or deleted — come back in unresolved rather than being dropped, because a pack that quietly shrinks looks like one somebody wrote badly. 404 for an unknown slug.
POST /api/v1/starter_packs/{slug}/follow public, no-csrf (viewer required, write:follows scope) Follows everyone in the pack who can be reached, and answers with followed: the handles that actually were. One unreachable host skips that account rather than failing the lot — the point of the button is that nobody has to follow six accounts by hand. The viewer's own account is skipped.

The shipped packs are an editorial choice and a deliberately narrow one: the official accounts of the projects this app federates with. They are organisations rather than people, they are unambiguously the accounts they claim to be, and none was chosen because somebody liked them. Anything broader is a judgement an instance should make for itself.

An administrator replaces or extends them with the starter_packs app value — a JSON array of {slug, name, description, handles}. Three states, deliberately distinguishable: unset is whatever ships; [] is no packs at all, which is the right answer for an instance that would rather suggest nobody; a list is the shipped packs with any same-slug entry replaced and the rest added, so a shipped pack can be edited rather than only added to. Malformed JSON is no packs and a log line, never an exception: a typo in a config value must not be why a page is blank. (A JSON object decodes to an array too, so each entry is checked as well as the whole.)

Collections

Pixelfed's albums: a set of the owner's own posts, in an order the owner chooses. Mastodon defines nothing equivalent, so these are Pixelfed's route shapes — a client that knows Pixelfed finds them where it expects them. Every collection answered by these routes carries its owner as account (a Mastodon Account entity), so a page for one has somebody to head it with and can tell whether the reader may manage it.

Method Route Auth Parameters Description
GET /api/v1/collections public, no-csrf (viewer required, read:collections scope) Every collection the viewer owns, newest first, each with its first three posts as a cover.
POST /api/v1/collections public, no-csrf (viewer required, write:collections scope) title (required), description, visibility (public) Creates a collection and returns it. A blank or whitespace-only title is a 422. A visibility outside public/followers is stored as public rather than refused — there are only two meaningful values and neither is destructive. An account may hold 200 collections.
GET /api/v1/collections/{id} public, no-csrf One collection with its cover. A public one is answered to a signed-out visitor, which is the point of publishing one; a followers one is answered to its owner and to accounts that follow them, and is a 404 to everybody else — the same 404 as an id that does not exist.
PUT /api/v1/collections/{id} public, no-csrf (viewer required, write:collections scope) title, description, visibility Updates only the fields that are sent, and returns the collection. Somebody else's collection is a 404.
DELETE /api/v1/collections/{id} public, no-csrf (viewer required, write:collections scope) Removes the collection and its contents. The posts themselves are untouched.
GET /api/v1/collections/{id}/items public, no-csrf limit (40, max 40), offset (0) The posts of a collection in the owner's order, as Status entities. Same visibility rule as the collection itself.
POST /api/v1/collections/{id}/items public, no-csrf (viewer required, write:collections scope) status_id (required) Adds one of the viewer's own posts, at the end. Adding a post that is already in the collection is a no-op rather than a second entry, so the route is safe to retry. A collection holds at most 100 posts.
DELETE /api/v1/collections/{id}/items/{status_id} public, no-csrf (viewer required, write:collections scope) Takes a post out. Removing one that is not in the collection is a no-op.
GET /api/v1/accounts/{account_id}/collections public, no-csrf The collections of an account, as the caller may see them: the public ones to anybody, all of them to the owner and to a follower. This is what a profile draws.

A collection may only hold posts its owner wrote. A collection of other people's pictures would re-publish them on a page with a visibility they never agreed to, and no amount of checking at read time takes that back off the peers that already mirrored the page. Pixelfed has the same rule.

Collections are local. They are not federated as ActivityPub collections and a peer does not see them; what a peer sees is the posts, which it already had.

A post that is deleted leaves every collection holding it, through the same cascade that removes its recipient and tag rows. Suspending or deleting an account removes its collections.

Places

Where a post was taken. Nothing here geocodes anything — no call goes to Nominatim, Google or anyone else. Sending somebody's location to a third party at the moment they are deciding whether to publish it is the same failure the Exif stripping exists to prevent, and doing it deliberately would be worse than doing it by accident.

A place is therefore either one this instance has already seen, or one the client names outright with coordinates it already had. The search is over social_place, which holds one row per distinct place anyone here has posted from.

Method Route Auth Parameters Description
GET /api/v1/places/search public, no-csrf (viewer required, read scope) q (required), limit (20, max 20) Places whose name begins with q. A prefix match, not a substring one: LIKE '%term%' cannot use an index and this is called on every keystroke. A viewer is required because the set of places an instance knows is a rough map of where its people go.
GET /api/v1/places/{id} public, no-csrf (viewer required, read scope) One place.
GET /api/v1/places/{id}/statuses public, no-csrf limit (20, max 40), max_id The public posts taken at a place, newest first, keyset-paged on the post id — what the place's page draws. Public only whoever asks: the page is a public page, and a followers-only post's location is as private as the post. Replies are left out, being fragments of somebody else's thread rather than pictures of the place. An unknown place is a 404 before any post is read. A viewer is optional and only marks the reader's own bookmarks and favourites on the posts.

A post carries one by sending either place_id (from the search route) or place_name with optional place_country, place_lat and place_long to POST /api/v1/statuses. A place_id that no longer exists means "nowhere" rather than an error — a post is worth more than its location, and refusing to publish over a stale id is the wrong trade. Coordinates outside ±90/±180 are dropped, and a place_country that is not two letters is dropped, because a country column holding "United Kingdom" in one row and "GB" in another cannot group anything.

The Status entity gains place, which is null for almost every post: a place is never inferred, only stated. It is filled in one query per page, like the link preview card, rather than joined into every timeline query — almost no post has one, and the join would cost every page regardless.

Places are local and are not federated: a peer sees the post, not where it was taken.

Stories

Pixelfed's stories: one picture that stops existing after a day. A story has no social_stream row — it is not a post and does not belong in a timeline — but it does travel. A local story is sent to the author's followers as an Add whose object is a Story, and is withdrawn with a Delete: Pixelfed's verbs, and the ones its inbox handles. A Create would put it where posts go, and a server that does not know the type ignores the activity, which is the right outcome.

An arriving story is kept only when somebody on this instance follows its author, and no longer than a day whatever the sender's expiresAt says — one already past is not stored at all. That a copy cannot be recalled after the day is up is why the expiry is bounded here rather than trusted: this instance's promise about its own copy is one it can keep.

Every route requires a viewer. There is no public story, so there is nothing here to answer a signed-out caller with, and whether an account even has a story up is told only to its followers — which is why "not yours to see" and "there are none" are the same 404.

Method Route Auth Parameters Description
GET /api/v1/stories/carousel public, no-csrf (viewer required, read:stories scope) The viewer's own live stories, then those of the accounts they follow, oldest first — the order a carousel plays them in. Each carries seen for this viewer.
GET /api/v1/stories/self public, no-csrf (viewer required, read:stories scope) The viewer's own live stories. Only here and in the carousel is view_count filled in: how many accounts watched is told to the poster and to nobody else.
POST /api/v1/stories public, no-csrf (viewer required, write:stories scope) media_id (required), caption, duration (5) Posts one of the viewer's own uploads. duration is clamped to 3–30 seconds and caption to 500 characters. An account may have 40 live at once. An unknown or someone else's media_id is a 422.
DELETE /api/v1/stories/{id} public, no-csrf (viewer required, write:stories scope) Removes it early, with the record of who saw it. Somebody else's is a 404.
POST /api/v1/stories/{id}/seen public, no-csrf (viewer required, write:stories scope) Marks it seen. Called as a client scrolls, so a repeat is a no-op rather than a second view.
GET /api/v1/accounts/{account_id}/stories public, no-csrf (viewer required, read:stories scope) The live stories of one account: its own, or those of somebody the viewer follows. Anything else is a 404.

The shape on the wire is Pixelfed's, because Pixelfed is the only network that has stories. Its inbox does not read a story out of the activity that carries it: StoryFetch requires object.object, decodes it as a bearcap (bear:?t=…&u=…, FEP-d8c2) and fetches what it names with the token. An Add without one is dropped without a word. So a published story carries the capability beside the fields themselves — the fields for this app's own inbox and anything else that would rather read than fetch, the capability for Pixelfed — its attachment is one object typed Image or Video rather than a list of Documents, and it states published, an expiresAt after it, and can_reply/can_react as false. Each of those is a gate in StoryFetch::validatePayload(); every one of them was measured against Pixelfed's source rather than guessed at.

One thing this cannot fix from here: Pixelfed fans its own stories out with FollowerService::softwareAudience($id, 'pixelfed'), so it sends them only to instances it has identified as Pixelfed. Stories from a Pixelfed account therefore do not arrive here at all, whatever this app implements, and the direction that works is ours to theirs.

Stories from followed remote accounts appear in the carousel beside local ones; a story of the viewer's own is always local, since this instance is where they post.

The expiry is enforced twice, by design. Every read filters on expires_at, and Cron\ExpiredStories deletes what is due, hourly. If the job never runs nothing expired is ever shown; if a read is ever written without the filter, the job has already removed the row. For a feature whose promise is that the thing goes away, "what you can see" and "what is stored" have to be the same statement.

Conversations

Method Route Auth Parameters Description
GET /api/v1/conversations public, no-csrf (viewer required, read:statuses or read scope) limit (20, capped at 40), max_id (0), min_id (0), since_id (0) The viewer's direct messages grouped into threads, as Mastodon Conversation entities — {id, unread, accounts, last_status} — newest message first. This is the screen Tusky, Ivory, Ice Cubes and Phanpy read direct messages from. accounts are the other participants and never the viewer, so a note to self has an empty accounts; last_status is nullable, as Mastodon declares it. Sends a Link header whose cursor is a message nid, not a conversation id: conversations are ordered by their newest message, and a conversation id does not move when a message arrives, so it cannot page.
POST /api/v1/conversations/{id}/read public, no-csrf (viewer required, write:conversations or write scope) Marks the conversation read up to its newest message and answers with the Conversation. The mark is a position, not a flag: a message arriving afterwards makes the conversation unread again. Marking read twice is a no-op, and the mark never moves backwards.
DELETE /api/v1/conversations/{id} public, no-csrf (viewer required, write:conversations or write scope) Removes the conversation from the list and answers {}. No message is deleted — Mastodon deletes its conversation row and leaves the statuses — and a later message in the same thread brings the conversation back.

A conversation here is derived, not stored. Mastodon keeps a conversation row and takes the id from it; this app keeps messages, and the only thing the messages of one exchange share is their chain of in_reply_to. A conversation is therefore the thread, and its id is the nid of the thread root — the message with no parent, or the topmost parent this instance stores. That is what makes the id survive the round trip: a client reads the list, the user taps a row minutes later, and the id they send back names the same thread.

Two consequences a client can see. A thread whose root this instance never received groups under the topmost message it does have, so its id changes if that parent arrives later. And because paging is by message, a thread with messages on both sides of the cursor can appear on two pages, the second time with an older last_status; a client keys on id and updates the row rather than growing a second one.

What an account has done with a thread is stored, since it cannot be derived: social_convo_state holds how far the account has read it and how far it has dismissed it, both as the nid of the newest message the action covered. A thread with no row has been neither read nor dismissed, which is what an instance upgrading into the table starts with — nothing to backfill. unread is false for a message the viewer sent themselves: writing a message is having read the conversation, as on Mastodon.

A conversation the viewer is no part of, and one that does not exist, are the same 404: membership is decided by the dm rows in social_stream_dest — the same predicate that put the message in the direct timeline — so telling the two apart would say whether a thread exists and who is in it.

Keyword filters

Method Route Auth Parameters Description
GET /api/v2/filters public, no-csrf (viewer required, read:filters or read scope) Every keyword filter of the viewer, newest first, as Mastodon v2 Filter entities. Not paged, as Mastodon does not page it: an account has a handful of filters and a client needs all of them to decide what to blur. statuses carries the posts the filter covers by name, as FilterStatus entities.
POST /api/v2/filters public, no-csrf (viewer required, write:filters or write scope) title, context[] (home, notifications, public, thread, account), filter_action (warn default, or hide), expires_in (seconds; absent means never), keywords_attributes[][keyword], [][whole_word] Creates a filter and answers with it. A missing title, a context naming none of the five, an unknown filter_action and an empty keyword are each a 422: a filter with no context applies nowhere, and one with an empty keyword would match every status there is.
GET /api/v2/filters/{id} public, no-csrf (viewer required, read:filters or read scope) One filter of the viewer. Somebody else's is a 404, not a 403: whether another account has a filter is not this route's to tell.
PUT /api/v2/filters/{id} public, no-csrf (viewer required, write:filters or write scope) title, context[], filter_action, expires_in, keywords_attributes[][id], [][keyword], [][whole_word], [][_destroy] Changes a filter. What is not named is left as it is, so a client sending only title does not thereby clear the contexts, the expiry or the keywords. keywords_attributes edits in place: an entry with an id changes that keyword, one with _destroy removes it, one without an id adds it. A keyword id belonging to another filter — the viewer's own included — is a 404.
DELETE /api/v2/filters/{id} public, no-csrf (viewer required, write:filters or write scope) Deletes the filter and its keywords, and answers {}.
GET /api/v2/filters/{id}/keywords public, no-csrf (viewer required, read:filters or read scope) The FilterKeyword entities of that filter.
POST /api/v2/filters/{id}/keywords public, no-csrf (viewer required, write:filters or write scope) keyword, whole_word Adds one keyword and answers with it.
GET /api/v2/filters/keywords/{id} public, no-csrf (viewer required, read:filters or read scope) One keyword of one of the viewer's filters; anybody else's is a 404.
PUT /api/v2/filters/keywords/{id} public, no-csrf (viewer required, write:filters or write scope) keyword, whole_word Changes it. What is not named is left as it is.
DELETE /api/v2/filters/keywords/{id} public, no-csrf (viewer required, write:filters or write scope) Removes the keyword; the filter stays. Answers {}.
GET /api/v2/filters/{id}/statuses public, no-csrf (viewer required, read:filters or read scope) The posts that filter covers by name, as FilterStatus entities.
POST /api/v2/filters/{id}/statuses public, no-csrf (viewer required, write:filters or write scope) status_id Adds one post to the filter — a client's "filter this post" — and answers with the entry. The post is not looked up: a filter is the reader's own list and may name a post this server has since deleted or never held, and a lookup would refuse to filter a post the reader is looking at. A missing or non-numeric status_id is a 422. Adding the same post twice makes a second entry, each with its own id, as it does on Mastodon.
GET /api/v2/filters/statuses/{id} public, no-csrf (viewer required, read:filters or read scope) One entry of one of the viewer's filters; anybody else's is a 404.
DELETE /api/v2/filters/statuses/{id} public, no-csrf (viewer required, write:filters or write scope) Stops the filter covering that post; the filter stays. Answers {}.
GET /api/v1/filters public, no-csrf (viewer required, read:filters or read scope) Mastodon's v1 filters, served over the v2 ones. v1 has no notion of a filter with several keywords — a filter is a phrase — so a v1 filter here is a v2 keyword, carrying its parent's contexts, expiry and action. That is the mapping Mastodon serves for clients that have not moved, and the reason the ids in the two APIs are different things. The entity is {id, phrase, context, whole_word, expires_at, irreversible}, where irreversible is v1's name for filter_action: hide.
POST /api/v1/filters public, no-csrf (viewer required, write:filters or write scope) phrase, context[], irreversible, whole_word, expires_in Creates a v2 filter whose title is the phrase, holding that one keyword, and answers with the v1 entity.
GET /api/v1/filters/{id} public, no-csrf (viewer required, read:filters or read scope) One v1 filter — one keyword of one of the viewer's filters. Anybody else's is a 404.
PUT /api/v1/filters/{id} public, no-csrf (viewer required, write:filters or write scope) phrase, context[], irreversible, whole_word, expires_in Changes it; what is not named is left as it is.
DELETE /api/v1/filters/{id} public, no-csrf (viewer required, write:filters or write scope) Removes the keyword, and the filter with it when that was its last one — a v2 filter with no keywords matches nothing, and leaving one behind would appear in the v2 list as an empty filter the user never made. Answers {}.

A filter matches on everything of a status a reader reads: the content warning, the text with its markup taken out and its entities decoded, the descriptions of the attachments and the options of a poll. A boost is matched on the status it boosts, which is the only reading under which a filter cannot be escaped by boosting. whole_word puts a word boundary on each side of the keyword, but only on a side that starts or ends with a word character — \b before a # can never hold, and anchoring it blindly would make #spoiler match nothing at all. Comparison is case-insensitive and Unicode-aware, and a keyword is never run as a pattern.

A filter stops applying the moment its expires_at passes: the expiry is a predicate of every read, not a row something deletes, so an instance with no working cron behaves like one that has.

Where each context is applied, since a filter that names a timeline nothing reads it in is a filter its owner believes is working: home on /api/v1/timelines/home, public on /api/v1/timelines/public and /api/v1/timelines/tag/{hashtag}, account on /api/v1/accounts/{account}/statuses, thread on /api/v1/statuses/{nid}/context, and notifications on /api/v1/notifications. /api/v1/favourites/, /api/v1/bookmarks and the direct timeline are read in no context — their statuses carry an empty filtered, because its absence is an answer of its own — and so is /api/v1/statuses/{nid}, which is one status a client asked for by id rather than a timeline. /api/v1/timelines/list/{id} applies no filter at all, though home is documented by Mastodon as covering lists: the route is ListController's and never reaches FilterService, so a home filter narrows the home timeline and not the list timelines drawn from it.

The web client edits these through the v2 routes only (src/components/FiltersSettings.vue); v1 is left for clients that have not moved. It also draws filtered: a status carrying one is folded behind the names of the filters that matched (TimelinePost.vue, .post-filtered) and its body is not rendered at all until the reader presses "Show anyway", the same treatment a content warning gets. Two places a warn filter therefore does nothing visible, both because the key never arrives rather than because the client ignores it: /api/v1/timelines/list/{id}, which applies no filter, and /api/v1/notifications, where Stream::exportAsNotification() serialises the nested status without filtered — there a hide filter drops the notification and a warn filter is inert.

Discovery, trends and relationships

Method Route Auth Parameters Description
GET /api/v1/trends/statuses public, no-csrf limit (20, capped at 40), offset (0), period (1h, 12h, 1d — the default —, 3d, 10d) The public statuses interacted with most in that window, most interactions first, with their link previews attached. Counted live from social_action — the rows a like and a boost already write — rather than from a stored counter, so the trend cannot drift from the counts a status reports. Public Notes only: this is an unauthenticated route, and an aggregate over followers-only posts would report on them to the whole internet even if it never showed one. A status nobody touched in the window is absent rather than a zero at the end. period is this app's own parameter, shared with /api/v1/trends/tags so a discover page sees one stretch of time; Mastodon has none and gets the default.
GET /api/v1/timelines/link public, no-csrf url (required), limit (20, capped at 40), max_id (0), min_id (0) The public posts carrying one link, newest first — what a reader gets by tapping a trending link rather than following it off the instance. The links themselves were already served at /api/v1/trends/links, so the data was here and the timeline that reads it was not. The url is matched exactly rather than by prefix: two pages of the same site are two links, and a prefix match would fold a whole domain into whichever of its pages happened to trend. A missing or unknown url is an empty timeline, not an error — the link a client holds may be one nobody here has posted since.
GET /api/v2/discover/posts public, no-csrf limit, offset (0), period Pixelfed's discover route: the trending statuses narrowed to the ones with a picture, because a discover screen is a grid of squares and a text post is a poor thing to put in one. The same ranking as /api/v1/trends/statuses, not a second one, so a post cannot trend there and not here. Public statuses only.
GET /api/v1/trends/links public, no-csrf limit (20, capped at 40), offset (0), period (as above) The links most often attached to a public status in that window, as Mastodon Trends::Link entities. Counted by url, not by card row: the same article posted by five accounts is one trending link. The card half is the stored preview, serialised by the same class the card on a status uses, so the two cannot disagree about a page. A url still being shared whose preview row went with the post it was fetched for is answered as a bare link rather than dropped. history carries a single bucket and accounts in it is always 0: this instance counts uses, not distinct accounts.
GET /api/v1/directory public, no-csrf offset (0), limit (40, capped at 80), order (active default, or new), local (accepted, no effect) The local profile directory: the accounts that set discoverable. Opt-in, and that is the whole access rule — the flag has been stored and federated since Version1000Date20260911000002 and was read by nothing, so turning it off changed nothing because there was no listing to be kept out of. It is a predicate of the deciding query, so an account that did not opt in is never read and then dropped. active orders by when the account last posted in public, accounts that never have at the end; new by when it was created. Silenced and suspended accounts are not listed — removing an account from the public timeline and leaving it in the shop window is the same mistake twice. Remote accounts are never listed, which is why local makes no difference.
GET /api/v2/suggestions public, no-csrf (viewer required, read scope) limit (40, capped at 80) Accounts to follow, as Mastodon Suggestion entities. Derived from two things the app already has: the accounts followed by the accounts the viewer follows, ranked by how many of them do (friends_of_friends), then — for a viewer whose graph has nothing to say — local accounts that opted in to the directory, most recently active first. No scoring model: both halves are facts that can be counted. The deprecated source field is sent beside sources, because clients in the wild read one or the other. Never the viewer, an account they already follow or have a pending request to, one they have blocked or muted, one that has blocked them, or one under a moderation decision — every exclusion applies to both halves, since they come from different queries.
GET /api/v1/suggestions public, no-csrf (viewer required, read scope) limit (40, capped at 80) The same list in Mastodon's v1 shape: bare Account entities without the source that explains them. Served rather than deprecated away, because a client that never moved to v2 would otherwise draw an empty "who to follow" panel.
DELETE /api/v1/suggestions/{id} public, no-csrf (viewer required, write scope) "Stop suggesting this account", and the reason the panel is usable at all: without it the same handful of accounts comes back on every visit, the ones already decided about included. Permanent — the ranking would otherwise put a dismissed account back at the top for the same reasons it put it there the first time. Answers {} whether or not the account was ever suggested: the client asked for a state and that state is what it gets. The account may be named by its numeric id, its actor URI or its handle.
GET /api/v1/featured_tags public, no-csrf (viewer required, read:accounts or read scope) The viewer's own featured tags — the hashtags they pin to their profile. Unpaged, as Mastodon's is. statuses_count and last_status_at are counted at read time over the account's public and unlisted posts, not stored, so they cannot disagree with the tag timeline a visitor gets by clicking through; last_status_at is a date and is null, not "", for a tag nothing has been posted with.
POST /api/v1/featured_tags public, no-csrf (viewer required, write:accounts or write scope) name (required) Pins a hashtag and answers with it. The name is stored as posts are tagged — no leading #, lowercased, at most 127 characters — and a name that is not a hashtag at all is a 422 rather than a row nobody can post with. Featuring one that is already featured answers the existing entity. One past configuration.accounts.max_featured_tags is a 422.
DELETE /api/v1/featured_tags/{id} public, no-csrf (viewer required, write:accounts or write scope) Unpins a tag and answers {}. A tag that is not there and a tag that is somebody else's are the same 404.
GET /api/v1/featured_tags/suggestions public, no-csrf (viewer required, read:accounts or read scope) The hashtags the viewer posts with most and has not featured, built by the same helper the trends use so they cannot mean something different here. At most ten.
GET /api/v1/accounts/{account}/featured_tags public, no-csrf Somebody's featured tags. Public, as the profile they are drawn on is, and the counts come from public and unlisted posts only.
GET /api/v1/accounts/{account}/highlights public, no-csrf The shape of somebody's posting history, for the top of their profile: since (when the account was created, as a unix time), weeks (how many public posts fell in each of the last twelve weeks, oldest first), week_starts (the unix time the first bucket begins at) and hashtags (the three tags they have used most over the last year, {name, count}, most used first). Public, and built from public posts alone, so it cannot differ per viewer — a chart that did would say which posts the viewer is allowed to see. A remote account answers available: false with empty arrays, and a client should then draw nothing: this instance holds a remote account's posts only from whenever somebody here started following them, so a chart of that would show a quiet year for an account that was busy, and there is nothing to tell that apart from an account that really was quiet. Bounded at 5000 posts, so a very prolific account cannot make a profile view an unbounded query.
GET /api/v1/statuses/{nid}/history public, no-csrf (viewer optional, read:statuses or read scope when a token is sent) Every version the status has been through, oldest first, as Mastodon StatusEdit entities — the first being what was posted and the last what is showing now. A revision is written on every edit, so the first entry is never the current text. Answered to anybody, like GET /api/v1/statuses/{nid} itself: the status is resolved through the visibility filter first, so one the caller may not read is a 404 and no revision is looked at. A token that is presented still has its scope checked — too little is a 403, not a silent downgrade to an anonymous read. poll, media_attachments and emojis are always null/[]: an edit here changes the text, the warning and the sensitivity flag and nothing else. A status edited before the revisions table existed is answered with the one version in the database; nothing is invented.
POST /api/v1/accounts/{id}/note public, no-csrf (viewer required, write:accounts scope) comment (empty clears) Keeps the viewer's private note about the account and returns the updated relationship, whose note carries it. At most 2000 characters, counted as characters. An empty or blank comment clears it rather than storing a blank. Never federated, and readable by nobody but its author — not by the account it is about.
POST /api/v1/accounts/{id}/pin public, no-csrf (viewer required, write:accounts scope) Features the account on the viewer's profile; endorsed becomes true. An account the viewer does not follow is a 422, and so is a follow the other side has not answered — featuring somebody who may still refuse would publish a claim the viewer has not earned. Your own account is refused. Featuring twice features once.
POST /api/v1/accounts/{id}/unpin public, no-csrf (viewer required, write:accounts scope) Stops featuring it. Unfeaturing one that was not featured is not an error.
GET /api/v1/endorsements public, no-csrf (viewer required, read:accounts scope) limit (40, capped at 80) The accounts the viewer features, newest first. The viewer's own and nobody else's.
GET /api/v1/domain_blocks public, no-csrf (viewer required, read:blocks scope) limit (100, capped at 200) The instances the viewer has blocked for themselves, newest first, as a flat list of domain strings — which is what Mastodon answers here, not entities. Not the admin's instance-wide access list (occ social:fediverse), which applies to everybody at once.
POST /api/v1/domain_blocks public, no-csrf (viewer required, write:blocks scope) domain (required) Blocks every account on that instance for the viewer. What is sent is normalised to the host it names — Example.COM, @user@example.com and https://example.com/@user are one instance. Anything outside a-z0-9.- is a 422 rather than a stored pattern, because the comparison is a LIKE and a stored % would be a block that quietly matched other instances; this instance's own domain is a 422 too. The instance's posts stop reaching every timeline, thread and notification list, matched on the host of the author's actor id — including posts boosted into view by somebody else. Nothing is federated and the instance is never told.
DELETE /api/v1/domain_blocks public, no-csrf (viewer required, write:blocks scope) domain (required) Lifts the block and answers {}. Unblocking one that was not blocked is not an error. Posts that arrived while it held come back: the block filtered the read, it did not delete anything.

Announcements

Method Route Auth Parameters Description
GET /api/v1/announcements public, no-csrf (viewer required, any token — no scope) The announcements that apply right now, oldest effective first, as Mastodon Announcement entities, each carrying read for the viewer. Unpaged, as Mastodon's is, and no scope is asked for: what the instance is telling everybody is readable by any user token. An announcement with a start and an end is served only between them, and the window is a predicate of the query rather than something a cron deletes — it starts and stops on time on an instance whose cron is broken. mentions, statuses, tags and emojis are always [] — an announcement is plain text and nothing is parsed out of it — and all four keys are sent because a client that declares them non-optional cannot decode the entity otherwise. reactions carries what accounts have put on it: most-reacted first, alphabetical within a tie so a redraw is stable, each with name, count, me for the viewer, and — only when the name is a custom emoji this instance publishes — url and static_url, which is how a client tells a picture to fetch from a character to render. content is HTML built from what the admin typed, escaped, one paragraph per blank line.
PUT /api/v1/announcements/{id}/reactions/{name} public, no-csrf (viewer required, write:favourites or write scope) Puts one emoji on the announcement for the viewer and answers {}. {name} is a single emoji or the shortcode of one occ social:emoji publishes — the same two things Mastodon accepts, and anything else is a 422: a label somebody wrote on an instance-wide notice, shown to everybody who reads it, is not a reaction but a second announcement. One emoji is often several code points (a flag, a ZWJ family, a skin tone, a keycap), and all of those are accepted, while two emoji are not. Reacting twice with the same emoji is a no-op; an account may put at most 8 distinct emoji on one announcement, and one at that ceiling can still take one back and put another on. An announcement outside its window can still be reacted to. An id that names nothing is a 404.
DELETE /api/v1/announcements/{id}/reactions/{name} public, no-csrf (viewer required, write:favourites or write scope) Takes it back and answers {}. Taking back one that was never there succeeds — a client that has lost track of what it sent should not be told the announcement does not exist. Per account: another account's reaction is neither changed nor removable. An id that names nothing is a 404.
POST /api/v1/announcements/{id}/dismiss public, no-csrf (viewer required, write:accounts or write scope) Marks it read for the viewer and answers {}. Per account: another account's read state is neither changed nor readable. Dismissing twice is a no-op, and an announcement whose window has closed can still be dismissed — a client that was showing it must be able to put it away. An id that names nothing is a 404.

Timelines and notifications

Method Route Auth Parameters Description
GET /api/v1/timelines/{timeline}/ public, no-csrf local (false), limit (20), max_id (0), min_id (0), since_id (0), only_media (false), only_video (false) One of home, account, public, direct, favourites (case-insensitive); anything else is a 422 (UnknownProbeException). only_media keeps just the posts that carry an attachment, which is what the Photos view asks of home, or of public with and without local when its switcher is set to Local or Global. It had been parsed off the request since the hashtag timeline gained it and never put to a query, so it used to be accepted and ignored; "no media" has three spellings in the stored column (NULL, '' and '[]') and all three are excluded. only_video is this app's own, not Mastodon's, and is what the Videos view asks the same three feeds; it is the narrower of the two and wins when both are sent, since every video is media. It keeps a post that carries an attachment whose Mastodon type is video, or that arrived as a PeerTube Video — the second counts whether or not this instance found a file in it a browser can play, because the post is a video either way. public is readable without a token, as Mastodon's is — a client asks for it before it has one — though a token that was presented still has to be a good one. Every other timeline needs a viewer. Sends a Link header. Rate-limited per user and per anonymous caller.
GET /api/v1/timelines/tag/{hashtag} public, no-csrf limit (20), max_id (0), min_id (0), since_id (0), local (false), only_media (false), only_video (false) Posts carrying {hashtag}.
GET /api/v1/timelines/list/{id} public, no-csrf (viewer required, read:lists scope) limit (20, capped at 50), max_id (0), min_id (0), since_id (0), only_media (false), only_video (false) The list's timeline: the home timeline narrowed to the list's members. A route of its own rather than a name in /api/v1/timelines/{timeline}/, because a list timeline is a name and an id. Narrowed, not widened — every visibility, block, mute and duplicate-boost filter the home timeline applies applies here unchanged, and the membership join can only take posts away, so a list never shows its owner a post their home timeline would not have. A list that is not theirs is a 404. Sends a Link header.
GET /api/v1/favourites/ public, no-csrf limit (20), max_id (0), min_id (0), since_id (0) The viewer's favourited posts.
GET /api/v1/bookmarks public, no-csrf limit (20), max_id (0), min_id (0), since_id (0) The viewer's bookmarked posts.
GET /api/v1/notifications public, no-csrf limit (20), max_id (0), min_id (0), since_id (0), types (array), exclude_types (array), accountId (string) Notification stream for the viewer. types/exclude_types keep or drop notification kinds. Nine are served: mention, reblog, favourite, update, follow, follow_request, and — added with the conversation-mute work — poll (a poll you voted in, or ran, has closed), status (an account whose bell you turned on has posted), moderation_warning (a moderator acted on your account) and severed_relationships (a domain block cut your follows). Notifications the account's notification policy holds back are not on this list; see /api/v1/notifications/policy and /api/v1/notifications/requests. A types naming only kinds this app has no notification for returns nothing, and a stored notification whose sub-type has no Mastodon name is left out of the page rather than sent with an empty type. admin.sign_up, admin.report and annual_report are among the ones it does not have: the first two describe a sign-up and a report queue that reach an administrator through Nextcloud's own notifications instead. Sends a Link header. Also at /api/v2/notifications/policy, which is where a Mastodon 4.3 client looks and the only place it looks; the v1 spelling stays for clients written against 4.2, the release this server used to announce.
GET /api/v1/notifications/unread_count public, no-csrf (viewer required) {"count": n} — notifications newer than the viewer's notifications marker. Counted up to 99; past that the answer stays 99, which is all a badge shows.
GET /api/v1/notifications/{id} public, no-csrf (viewer required, read:notifications or read scope) One notification, as /api/v1/notifications serves it. Read through the notification timeline itself, so one that is not the viewer's is a 404 rather than a refusal that would say it exists — as is one whose sub-type has no Mastodon name, which the list leaves out too.
POST /api/v1/notifications/{id}/dismiss public, no-csrf (viewer required, write:notifications or write scope) Dismisses one notification and answers {}. The row is deleted, not flagged: a dismissal is final in Mastodon and the list is built straight off these rows. The post and the Like or Announce behind it are untouched, so no counter moves. Dismissing one that is already gone is a 200 — the client is asking for a state that holds. The Nextcloud notification raised from the same row is withdrawn with it.
GET /api/v2/notifications public, no-csrf (viewer required, read:notifications or read scope) limit (40), max_id, min_id, since_id, types[], exclude_types[], grouped_types[], account_id Mastodon 4.3's grouped notifications: the same list, as GroupedNotificationsResultsnotification_groups plus the accounts and statuses they refer to, each carried once. A page of forty favourites of one post is one group and one copy of the post here, where v1 sends forty rows and forty copies. What groups: favourites and boosts of the same post, and follows. Mentions never group — two people writing to you are two things to read — and neither does anything about a poll, an edit or a moderation decision; each of those is its own group, keyed ungrouped-{id} as Mastodon keys them. The group_key is built from what the group is (favourite-{status id}, follow-all) and never from the ids in it, so it still names the same group after more arrive. page_min_id/page_max_id bound the group on this page, which is what makes paging work.
GET /api/v2/notifications/unread_count public, no-csrf (viewer required, read:notifications or read scope) How many groups are unread, not how many rows: the number on a bell should say how many things happened, and one popular post is one thing.
GET /api/v2/notifications/{group_key} public, no-csrf (viewer required, read:notifications or read scope) One group, in the same shape the list serves. A key nothing matches is a 404.
GET /api/v2/notifications/{group_key}/accounts public, no-csrf (viewer required, read:notifications or read scope) Everybody in the group, not only the eight the group samples — this is what "and 34 others" opens.
POST /api/v2/notifications/{group_key}/dismiss public, no-csrf (viewer required, write:notifications or write scope) Dismisses every notification in the group. A group is what the reader sees, so dismissing it has to mean the rows behind it; dismissing only the newest would leave the group on screen with one fewer in it.
GET /api/v1/notifications/policy public, no-csrf (viewer required, read:notifications or read scope) What this account does with notifications from people it has no relationship with: for_not_following, for_not_followers, for_new_accounts, for_private_mentions, for_limited_accounts, each accept, filter or drop, plus a summary with the two counts a client draws the badge from. Everything defaults to accept, which is what every account had before this existed. Also at /api/v2/notifications/policy, which is where a Mastodon 4.3 client looks and the only place it looks; the v1 spelling stays for clients written against 4.2, the release this server used to announce.
PATCH /api/v1/notifications/policy public, no-csrf (viewer required, write:notifications or write scope) any of the five keys Changes the policy and answers it. What is not named is left as it is, and a decision this app does not recognise leaves its key alone rather than failing the request — a client from a newer Mastodon sending a sixth key must not lose the five that work here. drop behaves as filter: the notification row is written by the inbox long before anybody reads it, and the policy is applied when the list is read, so a policy loosened on Tuesday can still show what was held on Monday. The reader sees the same thing either way; what differs is whether the decision can be taken back. Also at /api/v2/notifications/policy, which is where a Mastodon 4.3 client looks and the only place it looks; the v1 spelling stays for clients written against 4.2, the release this server used to announce.
GET /api/v1/notifications/requests public, no-csrf (viewer required, read:notifications or read scope) limit (40, capped at 80) The senders whose notifications the policy is holding, one row each with how many they have sent and their most recent post. One row per sender is the point: somebody held back has usually sent more than one thing, and being asked about each in turn is what makes a filtered inbox worse than an unfiltered one. Derived from the notifications themselves rather than stored, over the last NotificationPolicyService::LOOKBACK of them. The id is the sender's account id, since that is the only stable name a derived row has.
GET /api/v1/notifications/requests/{id} public, no-csrf (viewer required, read:notifications or read scope) One of those rows; an account that is not being held is a 404.
POST /api/v1/notifications/requests/{id}/accept public, no-csrf (viewer required, write:notifications or write scope) "Show me this account's notifications after all." The decision is about the account, so it settles what they have sent and what they send later. Stored as a relation; it is not a follow and federates nothing.
POST /api/v1/notifications/requests/{id}/dismiss public, no-csrf (viewer required, write:notifications or write scope) "Stop asking me about this account." What they sent stays where it is and stays hidden; what changes is that they are no longer offered as a decision. Mastodon deletes those notifications — nothing is deleted here, so a policy the reader loosens later still has something to show.
POST /api/v1/notifications/requests/accept public, no-csrf (viewer required, write:notifications or write scope) id[] The same, for several senders at once. An id naming no account is skipped rather than refused: these arrive from a client clearing a screenful, and one stale entry must not lose the rest of the decisions.
POST /api/v1/notifications/requests/dismiss public, no-csrf (viewer required, write:notifications or write scope) id[] The same, dismissing.
GET /api/v1/notifications/requests/merged public, no-csrf (viewer required, read:notifications or read scope) Always {"merged": true}. Mastodon answers false while it is still moving rows about after a policy change; nothing is moved here, because the policy is applied when the list is read, so there is never anything in flight.
POST /api/v1/notifications/clear public, no-csrf (viewer required, write:notifications or write scope) Dismisses every notification the viewer has and answers {}.
GET /api/v1/markers public, no-csrf (viewer required) timeline (array of home, notifications; all of them when omitted) How far through each timeline the viewer has read: {"notifications": {"last_read_id": "42", "version": 3, "updated_at": "…"}}. Always a JSON object, {} for an account with no markers yet — never []. Absent timelines have no marker yet.
POST /api/v1/markers public, no-csrf (viewer required, write scope) Body (JSON or form-encoded): home[last_read_id], notifications[last_read_id] Moves markers forward and returns the ones it changed. A marker never moves backwards: two clients reading the same account report their own positions, and the one further behind must not un-read what the other has seen.
GET /api/v1/annual_reports public, no-csrf (viewer required) The year an account had, Mastodon's #Wrapstodon — one report per year the account wrote anything in, newest first, in Mastodon's WrappedAnnualReports shape (annual_reports, accounts, statuses). Each report carries archetype (lurker, booster, pollster, replier or oracle), a time_series of twelve months with what was posted and who arrived, the top_hashtags used, and top_statuses by boosts, replies and favourites. Computed on demand and never stored: the same answer comes out of the posts already in the database, so a report cannot go stale and an instance that never runs its cron still has one. share_url is null — Mastodon's points at a public page of its own, this app has none, and inventing an address would be a link that 404s in somebody's post. schema_version is 1, the shape Mastodon 4.3 defined. At most 10000 posts and 5000 followers are walked.
GET /api/v1/annual_reports/{year} public, no-csrf (viewer required) year The one year, in the same wrapper. A year the account wrote nothing in answers with an empty wrapper rather than twelve empty months.
GET /api/v1/annual_reports/{year}/state public, no-csrf (viewer required) year {"state": "available"} or "ineligible". generating never comes back: the report is a query rather than a job, so there is nothing to wait for. A future year is ineligible.
POST /api/v1/annual_reports/{year}/read public, no-csrf (viewer required) year Marks one read, so a client stops offering it. Kept as a per-account preference (annual_reports_read), because that is all it is: a note about a report that is not stored either.
POST /api/v1/annual_reports/{year}/generate public, no-csrf (viewer required) year A no-op that answers 200. There is nothing to generate — the report is ready the moment it is asked for — and the route exists because a Mastodon client calls it before it reads, and a 404 there is a client that never asks again. 30 an hour.

All four return a bare JSON array of statuses (no envelope) together with a Link header — see Pagination.

The home timeline is two pages, not one query. A post belongs there if the viewer follows its author or follows one of its hashtags and the post is public; each half is a query over social_stream.nid, and they are merged, deduplicated and cut to limit before the rows are read. Written as one query the two halves would be an OR across two different joins, which no index can serve. Merging is exact rather than approximate: both halves are cut to the same limit, so anything belonging in the top limit of the union is in the top limit of its own half. A post that is both followed and tagged appears once, and the visibility, block, mute, silence and duplicate-boost filters apply to both halves — the hashtag half additionally reaches no further than a stranger can read, since a followed hashtag is not a relationship with the author.

Followed hashtags

Method Route Auth Parameters Description
GET /api/v1/followed_tags public, no-csrf (viewer required) limit (20, capped at 50), max_id (0), min_id (0) The hashtags the viewer follows, as Mastodon Tag entities with following: true. Newest follow first. Sends a Link header whose cursor is the social_followed_tag row id, not the tag: a tag can be unfollowed and followed again, so its name does not move in one direction and cannot page.
GET /api/v1/tags/{hashtag} public, no-csrf (viewer required) One Tag entity — name, url, history, following — for {hashtag}, with or without its leading #. A tag nobody has posted is not a 404: it is a real tag with an empty history and following: false.
POST /api/v1/tags/{hashtag}/follow public, no-csrf (viewer required, write or follow scope) Follows the hashtag and returns the Tag with following: true. Following one that is already followed is not an error, so a client that lost the answer and retried gets the same tag back.
POST /api/v1/tags/{hashtag}/unfollow public, no-csrf (viewer required, write or follow scope) Unfollows it and returns the Tag with following: false. Unfollowing what was never followed is not an error either.

Following a hashtag is what puts its public posts into the viewer's home timeline, as if their authors were followed — that is the whole of the feature, and the rest of it is how a client says which tags.

A hashtag is stored and compared in one form: the tag with no leading #, trimmed, lowercased, and cut to the 127 characters social_stream_tag.hashtag holds (FollowedTagsRequest::normalise()). So #NextCloud and nextcloud are one tag to follow, one tag to look up and one tag to unfollow, matching the case-insensitive comparison /api/v1/timelines/tag/{hashtag} already makes. Something that normalises to nothing — #, or spaces — is a 422, not a stored row that no post could ever match.

The history of a Tag from any of these routes is the one the trends endpoint sends: a single bucket for the default window (1d), uses from the counts the cron keeps, and accounts always 0 because this instance counts uses rather than distinct accounts. A hashtag nobody has posted has an empty history rather than a zeroed bucket, because a zero would be a claim about a day. following is present on every Tag these routes return and absent from /api/v1/trends/tags, which is a public route with no viewer to answer it for.

Polls

Method Route Auth Parameters Description
GET /api/v1/polls/{nid} public, no-csrf (viewer required) The Mastodon Poll entity of the status {nid}: options with vote counts, expires_at/expired, multiple, voters_count, and the viewer's voted/own_votes. 404 when the status is not a poll.
POST /api/v1/polls/{nid}/votes public, no-csrf (viewer required, write scope) choices (array of option indices) Votes on a federated poll: each choice is delivered to the poll's author as an ActivityPub vote note; the chosen indices are remembered locally so the poll renders as voted, and the authoritative counts arrive later as an Update{Question} from the origin. 422 on invalid or duplicate votes and on expired polls. A vote on a local poll is counted here instead of being delivered — this instance is the origin, so there is nobody to ask — and polls are created like any other post, by passing poll to POST /api/v1/statuses.

Incoming federated polls (Question objects) are stored like notes, appear in every timeline, and carry the poll entity in their status export; a remote Update{Question} refreshes the counts.

Link previews

Statuses carry Mastodon's card entity: url, title, description, type (always link), provider_name, image, and the fields this app cannot fill (author_name, html, width/height, embed_url, blurhash) as empty values so that clients reading them blindly keep working. It is null for a post that links nowhere.

There is no endpoint for cards — they are derived data, never federated, and every instance reads the linked page itself:

  • The first plain link of a post is what gets previewed; mentions and hashtags are skipped, and only http(s) links count.
  • The page is read by a background job (a LinkPreview item in the stream queue, drained by cron or occ social:queue:process), so posting and inbox delivery never wait for a stranger's web server. A post therefore gains its card shortly after it appears.
  • The fetch goes through CurlService, which means: http(s) only on the request and on every redirect, no local addresses, a download size cap, a 5 s timeout, and the instance access list — with an allow-list configured, previews only come from listed hosts.
  • The card is read from OpenGraph, then Twitter-card tags, then the plain <title> and <meta name="description">. Title and description are length-capped and stored as text, never as markup; a preview image must itself be an http(s) URL.
  • Cards live in social_stream_card, keyed by the post, and are deleted with it (including by the retention job).

Reports

Method Route Auth Parameters Description
POST /api/v1/accounts public, no-csrf 403 in Mastodon's registration-error shape (error plus details.base[].error/description), because an account here is a Nextcloud account: the server creates it through whatever provisioning it is configured with, and this app is handed one that already exists. The description says where to sign up instead — the server's own registration page when the registration app is enabled, and otherwise that an administrator creates accounts. A 404 was the wrong way to say this: a client reads it as "this server is broken" and shows a person nothing they can act on, while a 403 like this is decoded and displayed. registrations: false in the instance entity tells the same thing to a client that looks before it asks. The approval queue, the invites and the email confirmation Mastodon builds on top of its sign-up are the server's for the same reason, and this app has no route for any of them.
POST /api/v1/reports public, no-csrf (viewer required, write scope) account_id (required), status_ids (array), comment, category (spam, legal, violation or other; anything else becomes other), forward Files a moderation report about the account (numeric id or full actor id) for the instance admins, who are notified and review it in the Social section of the administration settings. Reporting yourself is a 422, a missing account_id too. With forward set and the account on another instance, the report is also delivered there as a Flaganonymised: the activity names this instance's own actor and is signed with its key, never the reporter, who is reporting an account on the very instance that would receive their handle. The comment travels as written. Returns the Mastodon Report entity (action_taken, category, comment, status_ids, target_account, …); forwarded is true only once the remote inbox has accepted the Flag, so a forward that could not be delivered reads false and the report still stands.

Incoming federated reports (Flag activities from other instances) are stored the same way and land in the same admin panel. A report that arrived that way is never forwarded on: passing it along would put this instance's name on somebody else's complaint, and two instances doing that to each other is a loop.

Admin API (Mastodon)

Mastodon's /api/v1/admin/*, over the moderation the admin panel has always had. Every route requires a moderator: the user behind the bearer token (or behind the session) is resolved first and asked of AdminApiService::isAdministrator(), and anyone else is a 403 having had nothing done on their behalf. A moderator is a Nextcloud administrator, or somebody an administrator has handed the Social settings section to under Administration privileges — Nextcloud's own settings delegation (IManager::getAllowedAdminSettings()), which is the same gate the admin panel and its buttons sit behind, so the two cannot disagree about who may act. Moderating otherwise meant administering the whole server, which is a great deal of power to hand somebody so they can act on a report. Nothing is delegated by default, so out of the box only administrators pass. A scope is not that check and cannot be — this app's OAuth registration stores whatever scope string a client asks for, so admin:write on a token says only that some client asked for it. The scope is required in addition, as Mastodon requires it: a bearer token needs admin:read (or the broad admin) to read and admin:write to write, and the read/write every timeline client holds satisfies neither. An administrator's own browser session (with its CSRF token) needs no scope, having no token to carry one.

Entities carry every key Mastodon documents. Where this app has nothing behind one it is sent as the empty value of its type rather than omitted, because a client that declares a field non-optional cannot decode the entity otherwise: on Admin::Account that is email ("" — the address belongs to the Nextcloud account and is not republished here), ip (null), ips ([]), locale (""), invite_request (null), role (null — this app has no roles, and Mastodon also sends null for an account it holds no user of), disabled and sensitized (always false, no state here corresponds to either), created_by_application_id and invited_by_account_id (null). confirmed and approved are true for a local account and false for a remote one, as on Mastodon: both describe a user of this instance.

Method Route Auth Parameters Description
GET /api/v1/admin/accounts public, no-csrf (admin required, admin:read scope) origin (local or remote), status (active, silenced, suspended, pending, disabled), username, display_name, by_domain, email, ip, limit (40, capped at 200), max_id (0), min_id (0) A page of accounts as Admin::Account, newest first, with a Link header. origin outside local and remote is a 422 rather than an unfiltered page. status=silenced and status=suspended are read from the instance's decisions rather than from the account table — a suspension deletes the cached actor, so the accounts a moderator most needs to find are the ones the account table no longer holds; those two pages are capped by limit, are not paged further, and carry no Link header. pending and disabled are states this app does not have (nothing awaits approval, and a fediverse account has no login to disable) and answer with no accounts, as do email and ip — this instance holds neither for a fediverse account, and a filter that was ignored would have shown the whole instance as the answer to a question about one account. An account whose cached actor cannot be read is left out of the page, but its row still moves the cursor.
GET /api/v1/admin/accounts/{id} public, no-csrf (admin required, admin:read scope) One Admin::Account. {id} is the numeric id, the account's ActivityPub id or a handle (it accepts slashes). The ActivityPub id is accepted because it is the only name left on an account whose cached actor a suspension purged — without it a suspension could be applied over this API and never lifted over it — and it is the id the entity itself reports for such an account. Nothing is ever fetched from another server to answer this. Unknown is a 404.
POST /api/v1/admin/accounts/{id}/action public, no-csrf (admin required, admin:write scope) type (silence, suspend, sensitive, none), text, report_id Applies the decision through ModerationService, which is the same call the admin panel makes: silence takes the account out of the public and global timelines and changes no data; suspend deletes what it has posted here, drops its cached actor and refuses what it sends afterwards. none is Mastodon's own "warn and take no action": it records a warning, tells the account if it is local, and leaves whatever stands standing — lifting is what the unsilence and unsuspend routes below are for. (It used to lift instead, because a warning is a strike in a history this app did not keep.) Every one of the three is recorded as a strike, with text and report_id, in a history a lift does not empty. text is kept as the comment on a decision that stands. sensitive marks everything the account posts from now on sensitive without taking it out of the timelines — the step between doing nothing and silencing, lifted by unsensitive below. It was a 422 by name until this app had the tier, and the refusal outlived the gap by a wave. disable is still a 422: it turns off a login, and on this server a login belongs to Nextcloud. With report_id the report is resolved in the same call, so the decision and the report it came from cannot disagree. Answers {}.
POST /api/v1/admin/accounts/{id}/enable public, no-csrf (admin required, admin:write scope) Answers the Admin::Account and changes nothing: nothing here can disable a login (a fediverse account has none of its own, and the Nextcloud account behind a local one is enabled where Nextcloud keeps it), so there is nothing to undo. It exists because a moderation client calls it unconditionally when clearing a strike, and a 404 there reads as "no such account".
POST /api/v1/admin/accounts/{id}/unsilence public, no-csrf (admin required, admin:write scope) Lifts a silence, and only a silence: a suspended account is left suspended, so a client that meant to unsilence cannot free one by accident. Returns the Admin::Account.
POST /api/v1/admin/accounts/{id}/unsensitive public, no-csrf (admin required, admin:write scope) Lifts "everything this account posts is sensitive", and only that. The counterpart of action with sensitive, which this app has had since the tier between doing nothing and silencing was added — without this route an admin client could apply it and not take it off.
DELETE /api/v1/admin/accounts/{id} public, no-csrf (admin required, admin:write scope) Deletes what the account posted here, leaving the account itself. Mastodon's route means the same thing — it removes the data, not the login — and here it could not mean anything else: an account on this server is a Nextcloud account and the server owns it. This is the suspension's destructive half without the refusal: the posts go, the cached actor goes, and a local account's deletion is federated as a Delete. Irreversible, which is why it is its own verb rather than a severity inside action.
POST /api/v1/admin/accounts/{id}/unsuspend public, no-csrf (admin required, admin:write scope) Lifts a suspension, and only a suspension. What the suspension deleted stays deleted — lifting stops the refusal of what the account sends from now on. Returns the Admin::Account.
GET /api/v1/admin/reports public, no-csrf (admin required, admin:read scope) resolved (absent = the open queue), account_id (the reporter), target_account_id, limit (40, capped at 200), max_id (0), min_id (0) A page of Admin::Report, newest first, with a Link header. The two account filters take the same references {id} does; one this instance has never heard of matches nothing rather than falling off the query. Each report carries the reporter and the reported account as Admin::Account — built from the id the report was filed against when the account itself is gone, which is what a suspension leaves behind — the reported posts as Status entities (one no longer here is left out, the commonest reason being that a moderator already took it down), rules always [] (a report here carries a category, never a rule id) and forwarded always false (nothing forwards a report to the reported account's own instance).
GET /api/v1/admin/reports/{id} public, no-csrf (admin required, admin:read scope) One Admin::Report; unknown is a 404.
PUT /api/v1/admin/reports/{id} public, no-csrf (admin required, admin:write scope) category (spam, legal, violation or other), rule_ids Files a report under a different category. A moderator reading one often finds it under the wrong one — spam for something that is harassment — and until this the category a reporter chose was the category for ever. A category this app has no name for is a 422; an unknown id is a 404, because the report is read before anything is written rather than updated blind. rule_ids is accepted and ignored, exactly as it is on POST /api/v1/reports: a report here carries a category and never a rule id, and two halves of one API disagreeing about that would be worse than the omission.
POST /api/v1/admin/reports/{id}/resolve public, no-csrf (admin required, admin:write scope) Marks the report handled through ReportService — the same write the admin panel makes — and records the administrator who did it and when, which is what action_taken_by_account and action_taken_at report.
POST /api/v1/admin/reports/{id}/reopen public, no-csrf (admin required, admin:write scope) Puts the report back in the queue and clears the record of who acted on it: it described a decision that no longer stands.
POST /api/v1/admin/reports/{id}/assign_to_self public, no-csrf (admin required, admin:write scope) Takes the report, so a second administrator can see it is being worked on — assigned_account. Stored on the report row by Version1000Date20260911000013; what is kept is the Nextcloud user id, because an administrator moderates as a user of this server and need not have a Social account at all (one who has none is reported as null rather than as a failure).
POST /api/v1/admin/reports/{id}/unassign public, no-csrf (admin required, admin:write scope) Gives the report back to the queue.
POST /api/v1/admin/measures public, no-csrf (admin required, admin:read scope) keys (array, required), start_at, end_at, instance, id One Admin::Measure per key: key, unit (null), total, previous_total and data, one point per day of the window whether or not anything happened on it — a chart with holes in it is a chart nobody can read. previous_total is the same span ending where this one starts, which is the comparison the arrow on the chart is drawn from. The window is snapped to whole days, must be a real range, and may span at most 370 days. The keys this instance can answer: active_users (distinct local accounts that posted that day — this app has no session of its own to count), new_users, interactions (likes and boosts), opened_reports, resolved_reports, and, with the instance named in instance, instance_accounts, instance_statuses and instance_reports; with a hashtag in id, tag_uses and tag_accounts. Any other key is a 422 naming the ones that work, rather than a row of zeroes: the rest of Mastodon's keys describe a sign-up, an invite system, an email address or a media store this app does not own, and answering 0 to "how many accounts signed up through an invite" reads as "none did", which is a different claim an admin would act on.
POST /api/v1/admin/dimensions public, no-csrf (admin required, admin:read scope) keys (array, required), start_at, end_at, limit (10, capped at 100), id One Admin::Dimension per key, each {key, data: [{key, human_key, value}]}. Answerable here: servers (the instances whose accounts posted most in the window — the host is read off the author's id in PHP, since there is no host column and four databases do not agree on how to cut one out of a string), tag_servers (the same for one hashtag, named in id) and software_versions (this app, the Nextcloud it runs in, PHP and the database — what an admin is asked for in a bug report; Mastodon lists its own dependencies here). Any other key is a 422, on the same reasoning as measures.
POST /api/v1/admin/retention public, no-csrf (admin required, admin:read scope) start_at, end_at Admin::Cohort rows: one per month of local sign-ups in the window, each carrying one value per month since — how many of that month's accounts posted in it, and that over the size of the cohort as rate. An account that never posted is in its cohort and in none of its buckets. Always monthly, whatever frequency asks for: a cohort is read over months, and a daily one on an instance with a handful of sign-ups a month is a table of zeroes.
GET /api/v1/admin/trends/tags public, no-csrf (admin required, admin:read scope) limit (10, capped at 100) The trending hashtags, exactly as /api/v1/trends/tags answers them. On Mastodon these carry a moderator's extra field — whether the trend is approved or awaiting review — and this instance reviews nothing: a trend here is what the counts say. The route exists because a moderation client asks for it by this path and a 404 reads as "this server has no trends".
GET /api/v1/admin/trends/statuses public, no-csrf (admin required, admin:read scope) limit (10, capped at 100), offset (0) The trending statuses, as /api/v1/trends/statuses answers them.
POST /api/v1/admin/trends/tags/{id}/approve public, no-csrf (admin required, admin:write scope) Records that a moderator has looked at a hashtag and is content for it to trend. Grants nothing: trending here shows everything nobody has objected to, which is what it did before these routes existed, so an approval is a note to the next moderator rather than a permission. {id} is the hashtag, with or without its #.
POST /api/v1/admin/trends/tags/{id}/reject public, no-csrf (admin required, admin:write scope) Keeps a hashtag out of every trending list and out of Explore. The counters keep counting it, so lifting the decision puts it back with the number it would have had — a decision that could only be undone by waiting for counters to refill is one nobody would risk making.
POST /api/v1/admin/trends/links/{id}/approve public, no-csrf (admin required, admin:write scope) The same for a trending link, named by its URL.
POST /api/v1/admin/trends/links/{id}/reject public, no-csrf (admin required, admin:write scope) Keeps a link out of the trending links.
POST /api/v1/admin/trends/statuses/{id}/approve public, no-csrf (admin required, admin:write scope) The same for a trending post, named by its ActivityPub id.
POST /api/v1/admin/trends/statuses/{id}/reject public, no-csrf (admin required, admin:write scope) Keeps a post out of the trending statuses, and out of the discover grid that reads the same ranking. It is not a takedown: the post stays where its author put it and everybody who follows them still sees it.
GET /api/v1/admin/tags public, no-csrf (admin required, admin:read scope) The hashtags a moderator has decided about, newest first, with who decided and when. Not every hashtag the instance has ever seen: a decision is the thing worth listing, and the counted ones are already at /api/v1/admin/trends/tags.
GET /api/v1/admin/tags/{id} public, no-csrf (admin required, admin:read scope) One hashtag as Mastodon's Admin::Tag: trendable is the one field that means anything here. usable and listable are always true — they describe a tag row that can be disabled for posting and for search, and this app has no equivalent, since a hashtag here is written by whoever types it and exists because a post carries it. Saying true is the honest answer to "may this be used", not a placeholder.
PUT /api/v1/admin/tags/{id} public, no-csrf (admin required, admin:write scope) trendable Sets whether the hashtag may trend. usable and listable are accepted and ignored, for the same reason rule_ids is on a report: a client sending all three should not have the one that works refused along with the two that do not.
GET /api/v1/admin/trends/links public, no-csrf (admin required, admin:read scope) limit (10, capped at 100), offset (0) The trending links, as /api/v1/trends/links answers them.
GET /api/v1/admin/ip_blocks public, no-csrf (admin required, admin:read scope) The addresses this instance answers nothing from, newest first, as Admin::IpBlock: ip (always a CIDR range — a bare address is stored as the range holding only itself, so there is one shape to match against), severity, comment, created_at, expires_at (null when it does not lift itself).
POST /api/v1/admin/ip_blocks public, no-csrf (admin required, admin:write scope) ip (required), severity (no_access), comment, expires_in (seconds from now) Refuses an address or range everything this app serves — enforced in one middleware rather than per route, because "no access" is a statement about the whole app and a block that held on the inbox but not on the API is not what an admin switched on. A refused request is a 403, so a peer stops redelivering rather than queueing for days. severity may only be no_access: Mastodon's sign_up_block and sign_up_requires_approval police a sign-up this instance has not — an account here is a Nextcloud account and the server decides who gets one — and asking for either is a 422 rather than a row nothing will ever read, since an admin told their rule was stored would believe it was being enforced. A ip that is not an address or range is a 422. Blocking a range already blocked replaces it.
GET /api/v1/admin/ip_blocks/{id} public, no-csrf (admin required, admin:read scope) One Admin::IpBlock. Unknown is a 404.
PUT /api/v1/admin/ip_blocks/{id} public, no-csrf (admin required, admin:write scope) severity (no_access), comment, expires_in Changes the severity, the comment and the expiry. The range is not among what can change — a block on a different range is a different block, as it is on Mastodon.
DELETE /api/v1/admin/ip_blocks/{id} public, no-csrf (admin required, admin:write scope) Lifts it and answers {}. Unknown is a 404.
GET /api/v1/admin/email_domain_blocks public, no-csrf (admin required, admin:read scope) The email domains this instance gives no fediverse account to, newest first, as Admin::EmailDomainBlock: domain, created_at, and history always [] — Mastodon counts the sign-up attempts it turned away and there is no sign-up here, but the key is sent because a client that declares it non-optional cannot decode the entity.
POST /api/v1/admin/email_domain_blocks public, no-csrf (admin required, admin:write scope) domain (required) Refuses a fediverse identity to Nextcloud accounts whose email is at that domain, checked once in AccountService::createActor(). Mastodon refuses a sign-up at one of these; there is no sign-up here, so this is the same question one step later and it is the decision this app does make. It matters on a server with open registration, which is where a throwaway-address domain turns up. Subdomains are covered, the way a domain block covers them. An account with no email address is allowed — there is nothing to check it against, and a server that stores no addresses would otherwise hand out no fediverse accounts at all. Anything that is not a domain is a 422.
GET /api/v1/admin/email_domain_blocks/{id} public, no-csrf (admin required, admin:read scope) One Admin::EmailDomainBlock. Unknown is a 404.
DELETE /api/v1/admin/email_domain_blocks/{id} public, no-csrf (admin required, admin:write scope) Lifts it and answers {}. Unknown is a 404.
GET /api/v1/admin/domain_allows public, no-csrf (admin required, admin:read scope) The instances this server will talk to at all, as Admin::DomainAllow entities. The other half of domain_blocks, and it means something only while access_type is none_butallowlist federation, which this app has had all along with no API over it, so an admin client on an allowlisted instance saw an empty block list and no way to tell why. id is derived from the domain (the first twelve hex digits of its md5) because the access list is a config array with no ids of its own, and giving it a table so a client could address a row by number would be storing something for the client's benefit alone; every route here takes the domain itself as well. created_at is the epoch, the list holding no timestamps. While the instance federates by a block list every route in this group is a 422 — the mirror of the rule on domain_blocks, and for the same reason: one app value holds both lists and only the mode says which it is, so served as allows the entries would read as their exact opposite, and a client removing one to "disallow" it would have unblocked it.
GET /api/v1/admin/domain_allows/{id} public, no-csrf (admin required, admin:read scope) One of them, by the derived id or by the domain itself.
POST /api/v1/admin/domain_allows public, no-csrf (admin required, admin:write scope) domain (required) Adds a domain to the allow list.
DELETE /api/v1/admin/domain_allows/{id} public, no-csrf (admin required, admin:write scope) Takes one off it.
GET /api/v1/admin/domain_blocks public, no-csrf (admin required, admin:read scope) Every domain this instance holds at arm's length, at whichever tier, as Admin::DomainBlock entities: the deny list — the one occ social:fediverse and the admin panel manage — as severity: suspend, and the silenced list as severity: silence. Not to be confused with /api/v1/domain_blocks, which is one user hiding a server from themselves. A suspension refuses the domain outright, so reject_media and reject_reports are true; a silence refuses nothing and only takes the domain's accounts out of the public timelines, so both are false. A domain on both lists is reported as suspended — the stronger tier is the one in force. private_comment and public_comment are null (the lists have no room for a reason), obfuscate is false (this instance publishes no list of what it blocks), and created_at is the epoch — the lists store no timestamps, and that is how this API says so rather than inventing a date. id is derived from the domain (the first eight hex digits of its md5, as a decimal string), so it is numeric like every other id here and stable for as long as the entry names the same domain; the routes below take the domain itself just as happily. While the instance federates by an allow list (none_but) every route in this group is a 422: the same app value holds the deny list and the allow list, and served as blocks its entries would read as their own opposite — a client that then "blocked" a domain would have added it to the list of the allowed.
POST /api/v1/admin/domain_blocks public, no-csrf (admin required, admin:write scope) domain (required), severity (suspend or silence; suspend when absent) Blocks a domain at the tier named and returns the entry. An address no hostname could be is a 422, and so is Mastodon's third severity, noop, which records a domain without doing anything to it: there is no such list here, and a 200 that had quietly applied something else would tell the client the domain was under a block it is not. Blocking one already blocked adds nothing and is not an error, so a client that lost the answer may retry. reject_media, reject_reports, obfuscate and the two comments are accepted and ignored — the lists have no room for any of them.
GET /api/v1/admin/domain_blocks/{id} public, no-csrf (admin required, admin:read scope) One entry, by the derived id or by the domain; unknown is a 404.
PUT /api/v1/admin/domain_blocks/{id} public, no-csrf (admin required, admin:write scope) severity (suspend or silence) Moves the block between the two tiers: a change of severity takes the domain off one list and puts it on the other, the same severity again changes nothing, and noop is the same 422 as above. Answers with the entry as it now stands.
DELETE /api/v1/admin/domain_blocks/{id} public, no-csrf (admin required, admin:write scope) Lifts the block at whichever tier it was, and answers with the entry that was lifted.

Media

Method Route Auth Parameters Description
POST /api/v1/media public, no-csrf (viewer required, write scope) Multipart: file (required, read from $_FILES['file']), description (the alt text) Stores an upload and returns the MediaAttachment (images get a resized preview and blurhash; video/audio are stored as-is with the media itself as preview_url). Refuses a mime type outside CacheDocumentService::filterMimeTypes() — images, video, audio and the document kinds in DOCUMENT_MIME_TYPES (PDF, text, Markdown, CSV, ZIP, EPUB, ODF, Office), which come back as type: "unknown" with no preview_url and, when no description was sent, the upload's file name as the description — and a file over its ceiling with a 422. Two ceilings: max_size (10 MB by default) for everything, and max_video_size (2048 MB) for video, because an image is read whole into memory to be stripped and resized while a video is copied to storage a chunk at a time and never held. The request-time check can only go on the type the client declared, so it is applied again after the content is sniffed — a file that claimed to be a video to get past the first check is refused by the second. The stored row is not public: public is what lets the unauthenticated /media/{uuid} route serve the file, and it is only set later, when a post attaches the media and that post is public or unlisted. focus is accepted and not stored. Rate-limited per user.
POST /api/v2/media public, no-csrf (viewer required, write scope) Same as v1 The same upload; modern clients POST v2 and only fall back to v1 on a 404.
POST /api/v1/media/from-file public, no-csrf (viewer required, write scope) Body (JSON or form-encoded): path (required, relative to the viewer's own files), description (the alt text) Nextcloud extension, not a Mastodon route. Attaches a file the viewer already has in Nextcloud, so a picture that is already on the server does not have to be downloaded and uploaded back. The path is resolved inside the viewer's own user folder and nowhere else — a share they can read is fair game, a traversal is a 422 no such file, and so is a folder. The bytes are copied, not referenced: a post keeps the picture it was published with, so moving or deleting the original later cannot empty a post that has already federated. Everything after that is the upload path — the same size ceiling, the same mime filter, the same resizing — and the answer is the same MediaAttachment, equally not public until a post says so. Rate-limited per user.
GET /api/v1/media/{nid} public, no-csrf nid (path), preview (default '', ignored) One of the viewer's own attachments, by the id the upload returned. 404 for an unknown id or someone else's attachment.
PUT /api/v1/media/{nid} public, no-csrf Body: description Updates the alt text of the viewer's own attachment and returns it. 404 for an unknown id or someone else's attachment.
GET /media/{uuid} public, no-csrf uuid (path, may carry a .ext suffix); the request's own Range header is honoured Streams a cached document by UUID. Either of a document's copies resolves it — the full one that url names and the resized one that preview_url names. Range-capable: answers Accept-Ranges: bytes always, and a Range request with 206 and a Content-Range (a suffix range is the last N bytes, an open range runs to the end, one past the end is a 416 carrying the length, and anything unparseable — the multipart form included — is answered with the whole file, which is always correct). Without this a video could not be seeked and asking for its duration alone cost the whole file. The Content-Type is the media type sniffed from the content at ingest, except for the resized copy of a video, which is its poster frame and is served as image/jpeg — a browser with nosniff on, which is every Nextcloud, refuses to draw an image served as video/mp4. The extension in the URL is ignored. Only public documents are served, since the route is unauthenticated. 404 when unknown or not public.
GET /media/stream/{nid} public, no-csrf nid (path); the request's own Range header is forwarded Nextcloud extension, not a Mastodon route. A federated video, passed through from the instance that holds it — a PeerTube Video is referenced rather than mirrored (see Architecture.md), so there is no local copy to serve and the bytes come from the origin as they are played. The page cannot point a <video> at that origin directly: Nextcloud's content security policy says media-src 'self', and widening it would also mean every reader who pressed play announcing themselves to a server they never chose to talk to. What keeps this from being an open proxy is that it takes a row id, not a URL, and only a social_cache_doc row this app itself wrote as streamed answers — anything with a local copy is served by /media/{uuid} and is a 404 here. Answers 206 with Content-Range when the caller sent a Range and the origin honoured it, Accept-Ranges: bytes either way; nothing is stored, in either direction. 404 for an unknown or non-streamed row, 502 when the origin cannot be reached.
GET /media/playlist/{nid} public, no-csrf nid An HLS playlist, with every URI in it pointed back through this server. A PeerTube transcoding to HLS — the default, and what a public instance federates — publishes a .m3u8 and nothing but Safari can open one, so the client loads hls.js and asks for this. The rewrite is the point: a playlist names its segments relative to itself, so handing one over verbatim would have every segment fetched straight from the origin — which is what /media/stream/{nid} exists to prevent, and worse, because it is one request per few seconds of video. Both plain URI lines and URI="…" attributes (the encryption keys among them) are rewritten, because a player follows both. Read whole, up to 2 MB. 60 an hour anonymous.
GET /media/playlist/{nid}/file public, no-csrf nid, u (the file's address) One file out of such a playlist — a segment, a key, or a nested playlist — fetched from the origin and streamed on with Range support. u is not trusted: it has to be on the same host, scheme and port as the playlist nid names, which is the property /media/stream/{nid} has by taking a row id rather than a url, one level further in. Generous limits, because a film is hundreds of segments.
GET /media/hls/{uuid}/master.m3u8 public, no-csrf uuid (path) Nextcloud extension, not a Mastodon route. The master playlist of a local video's ladder: which sizes it exists at. Keyed on the uuid, the same handle /media/{uuid} takes and for the same reason — a ladder is the same video, so it must be no easier to reach than the video; a route on a row id would make a followers-only post's video findable by counting. 404 when the video has no ladder, rather than an empty playlist: a player handed a master with no rungs reports a broken video, where one that gets a 404 falls back to the plain file, which is what should happen. Served as application/vnd.apple.mpegurl. Every address in this group ends in the extension its content actually has, and the three are at different depths so none can be read as another: browsers and hls.js go by the Content-Type, but ffmpeg's HLS demuxer checks the extension of every segment URI and refuses one it does not recognise.
GET /media/hls/{uuid}/{height}/index.m3u8 public, no-csrf uuid, height (path) One rung's playlist: where each segment is inside that rung's file. The stored playlist keeps a placeholder where the media URI goes and it is filled in here — the address is a route on this server, not known when ffmpeg writes the file and different if the instance moves. 404 for a height this video has no rung at.
GET /media/hls/{uuid}/{height}/video.mp4 public, no-csrf uuid, height (path); the request's own Range header is honoured One rung's fragmented MP4, whole, with ranges. Every segment of a rung is a byte range into this one file (-hls_flags single_file), so a player watching a ten-minute video asks this a few hundred times with a different Range each time — hence the generous limits. Cached on the same terms as the video itself: for ever in the reader's own cache, and in a shared one only where the post is public.
POST /api/v1/statuses/{nid}/watched public, no-csrf (viewer required) position, duration (seconds) Remembers where the reader got to in a video. A two-hour talk watched in three sittings is three sittings of finding the place again. A fact about the reader: never federated, never shown to anybody else, never counted into anything, one row per (post, viewer). A video watched to past 95% is forgotten rather than bookmarked at the credits, because a "continue watching" row that offers back a finished video is one nobody presses twice. 600 a minute — a player reports as it goes.
DELETE /api/v1/statuses/{nid}/watched public, no-csrf (viewer required) nid Takes a video off the reader's own list.
GET /api/v1/videos/continue public, no-csrf (viewer required) limit (20, max 40) The videos the reader was in the middle of, newest first — neither the ones they barely started (under 10 seconds) nor the ones they finished.

POST /api/v1/media and POST /api/v2/media are both served by ApiController::mediaNew(), and it and mediaFromFile() share the storing half (storeAttachment()) so the two ways in cannot drift apart on the things that matter — the mime filter, the resizing, and the row not being public. On the wire an attachment's alt text travels as the ActivityPub name, both incoming and outgoing.

Every key of a MediaAttachment is always present, null when there is nothing to put in it (preview_url, remote_url, meta, description, blurhash). meta is an object, never a list.

hls_url is a key of this app's own, alongside Mastodon's. Where an administrator has turned the ladder on (video_ladder) and the background job has been over a video, it is the address of that video's master playlist — the same video at two or three sizes, so a player can pick the one that fits the connection. null everywhere else, which is every video on an instance that has not turned it on, and a client that does not know the key plays url and gets the same video at one size. It is not a replacement for url: the whole file is always there beside it, and the bundled player falls back to it if the ladder cannot be loaded.

Search (Mastodon-adjacent, app-specific shapes)

Method Route Auth Parameters Description
GET /api/v1/global/accounts/search user search (required) {"result": {"accounts": [...], "exact": <actor or null>}, "status": 1}. A leading @ is stripped; an empty query returns empty lists.
GET /api/v1/global/tags/search user search (required) {"result": {"tags": [...], "exact": <tag or null>}, "status": 1}. A leading # is stripped.

Pagination

ApiController list endpoints accept the cursor parameters shown per route above (limit, max_id, min_id, and either since_id or since), all integers defaulting to 0 except limit (20 — 40 on /api/v1/blocks and /api/v1/mutes — capped at 50).

Every paged ApiController route except /api/v1/blocks, /api/v1/mutes and /api/v1/scheduled_statuses also sends a Link header in Mastodon's form, which is the only cursor masto.js — and therefore Elk and Phanpy — reads:

Link: <https://cloud.example/index.php/apps/social/api/v1/timelines/home?limit=20&max_id=41>; rel="next",
      <https://cloud.example/index.php/apps/social/api/v1/timelines/home?limit=20&min_id=60>; rel="prev"

next points below the lowest id on the page and is sent only while a further page may exist (a page shorter than limit is the last one); prev points above the highest id and is sent whenever the page is not empty. Every other filter the caller sent survives into both links. The routes that send one are /api/v1/timelines/{timeline}/, /api/v1/timelines/tag/{hashtag}, /api/v1/notifications, /api/v1/favourites/, /api/v1/bookmarks, /api/v1/followed_tags, /api/v1/accounts/{account}/statuses, /api/v1/accounts/{account}/followers and /api/v1/accounts/{account}/following. /api/v1/followed_tags takes no since_id and pages on the followed-tag row id rather than a status id. A remote follower collection fetched over HTTP has no local ids to page by and carries no header, /api/v1/blocks and /api/v1/mutes send none because they take no cursor to page with, and /api/v1/scheduled_statuses sends none because a ScheduledStatus carries no status nid for the header to point at.

"A page shorter than limit is the last one" is decided on what the query returned, not on what survived any filtering the controller then did. /api/v1/notifications drops entries whose sub-type has no Mastodon name; counting those out would have made a filtered page look like the end of the list and stopped a paging client with the rest of it still in the database.

LocalController stream endpoints use a different pair: since (a numeric cursor, default 0) and limit (default 5, not 20), and send no Link header.

PeerTube's own routes

The same move as the section above, for the same reason: the official PeerTube app, Tubelab and Fedilab all exist and are good, and a client somebody already has is worth more than one nobody has written. PeerTube's API is not Mastodon's — different names, different ids, a different idea of what a video is — so this is a translation and not an alias. The shapes are built in PeerTubeApiService.

Read the two caveats before judging this by what is missing.

It is read-only. PeerTube's upload is a resumable protocol with a transcoding state machine behind it, and a half-built one that took somebody's file and lost it would be worse than none. Comments are read and not posted, because a client posting through this route would go round the review queue the composer goes through — the same reason the Pixelfed routes do not post either.

And it is gated on the domain root. No PeerTube client will ask under /apps/social/: they all build https://<host>/api/v1/… from the address a person types. Every route here is correct and none of them is reachable by a real client until this instance answers at its own root, which is item 1 of Mastodon-Compatibility.md and a decision for whoever runs the server rather than something this app can do to a Nextcloud. Until then these serve curl, the interop harness, and anybody willing to configure a rewrite.

Every list answers PeerTube's {total, data}. total is the size of this page, not of everything there is: this app's timelines are keyed on a cursor and have no count to give, and a number invented for the shape's sake is one a client would draw a pager from.

Method Route Auth Parameters Description
GET /api/v1/config public, no-csrf What a client reads once, on launch, to decide whether it can talk to this server at all — answered without a viewer, because it asks before anybody has signed in. Every switch says what is true here rather than PeerTube's default: signup.allowed is false (an account here is a Nextcloud account and is not made through this API), the import and upload switches are false (nothing here writes), transcoding.hls and enabledResolutions are the real state of video_ladder, and user.videoQuota is the real video_quota in bytes with -1 for no quota, which is PeerTube's own way of saying it. instance.defaultNSFWPolicy is this instance's nsfw_policy translated back into PeerTube's three words (display, blur, do_not_list). serverVersion is the PeerTube version whose API this answers; a software object beside it says what is actually running, so a client that reports a version to its user is not told this server runs PeerTube.
GET /api/v1/oauth-clients/local public, no-csrf The client id and secret to log in with. PeerTube hands out one pair per instance, the same one to everybody for ever; this app mints a pair per client and hashes the secret, deliberately and as a fix to a real problem, so there is no stored plaintext to hand back a second time. A fresh registration is answered instead — which satisfies what the route is for, since a client fetches a pair immediately before logging in and uses it at once, and is exactly what a Mastodon client does through /api/v1/apps. Registered with the out-of-band redirect urn and read write follow. 10 an hour.
GET /api/v1/videos public, no-csrf start (default 0), count (default 20, max 100) The videos this instance has, newest first: the public timeline narrowed with the same only_video the app's own Videos timeline uses, so the two cannot come to disagree about what a video is. Paging is by cursor here and by offset there, so start is applied to the page that was read rather than turned into a cursor that would drift as posts arrive — a client asking for a deep page gets fewer rows rather than wrong ones.
GET /api/v1/videos/{id} public, no-csrf id (numeric only) One video, with its description, tags, support line, files and streaming playlist. Not addressable by uuid, although the listing carries one: that uuid is derived from the post's address by a one-way hash — it is the same one this app publishes over ActivityPub, which is the point of it — so there is nothing to look it up by without a column to store it in. A client that lists and then fetches has the id already. The \d+ requirement is also what keeps this from swallowing /api/v1/videos/continue, this app's own "continue watching" route at the same depth.
GET /api/v1/videos/{id}/comment-threads public, no-csrf id (numeric), count (default 20, max 100) The replies to one video, out of the same thread the app's own status context is drawn from — so a reply a client sees here is one the web page shows too. totalReplies on each is 0 rather than a count: this app threads replies to replies and PeerTube draws a tree from that number, and a wrong one is a tree with branches that lead nowhere.
GET /api/v1/video-channels public, no-csrf count (default 20, max 100) The channels of this instance. Capped rather than paged: an instance with more channels than one page is one where a directory is the right answer.
GET /api/v1/video-channels/{handle} public, no-csrf handle (alice_channel, or alice_channel@host — the host half is dropped) One channel with its description, owner and follower count. A handle is an actor's preferredUsername and is not on the channel row, so the actor is resolved first — which is also what decides that a handle belonging to an ordinary account is not a channel, rather than a channel nobody can find. 404 otherwise.
GET /api/v1/users/me public, no-csrf (viewer required) The signed-in account as PeerTube's User, with its channels, its NSFW policy (in PeerTube's words) and its video quota and usage in bytes. The email is null: PeerTube's own answer carries one, and a Nextcloud account's address is not this API's to hand to whatever client holds a token — nothing a video client does needs it, and an absent field reads as "this server does not say" rather than as a wrong address. 401 without credentials.
GET /api/v1/search/videos public, no-csrf search, count (default 20, max 100) Videos matching a word, from the same SearchService the rest of the app searches with and narrowed to videos afterwards — so a video findable on one route cannot be missing from the other. An empty search is an empty page rather than everything.

Ids: a video's id is the post's nid, which is what this app's own API gives a client; its uuid and shortUUID are both the uuid PeerTubeService::uuidFor() mints and already publishes on the wire, so a video seen through this API and the same video seen over ActivityPub carry one uuid rather than two nothing can tell apart. category, licence and language are {id: 0, label}: PeerTube's ids are indexes into its own lists, which this app does not have and must not guess at — a wrong id is a client showing the wrong category with confidence — and the label is the half that means anything off its own instance.



Custom Local API

These endpoints exist to serve the app's own Vue frontend. They are session-authenticated only, mostly wrap their payload in the {"result": …, "status": 1} envelope, and are not part of any Mastodon client contract.

Posts

Method Route Auth Parameters Description
POST /api/v1/post user content (''), to (array), type (default public), replyTo (''), attachments (mixed, default []), hashtags (array), poll (object, optional), spoilerText ('', the content warning) Creates a post. Returns {"result": {"post": <object>, "token": "<request token>"}, "status": 1}.
DELETE /api/v1/post user id (required) Deletes an own post; {"result": [], "status": 1}. Rejects posts not attributed to the caller.

Boosting from a client goes through POST /api/v1/statuses/{nid}/{act} with reblog.

Current user

Method Route Auth Parameters Description
PUT /api/v1/account/fields user fields (list of {name, value}) Replaces the profile metadata fields (at most four name/value pairs; entries with an empty half are dropped, names capped at 255 and values at 500 characters). Federated as PropertyValue attachments on the actor. A verified link keeps its tick across the save; only a value that actually changed loses one, and it is checked again on the next cron pass rather than a day later. {"result": {"account": <Person>}, "status": 1}.
PUT /api/v1/current/follow user account (required) Follows an account; {"result": [], "status": 1}.
POST /api/v1/account/create user username (the handle; empty takes the one derived from the user id) Creates the reader's account — the one place an actor is made for a logged-in person; the page no longer creates one on its first load. The handle has to be theirs to take (AccountService::assertHandleAvailable(): the pattern, not another Nextcloud user's id, not a handle an actor holds); a refused handle is a 422 whose error says why. {"result": {"account": <Account>}, "status": 1}.
POST /api/v1/account/link user handle (user@server.example, a leading @ is fine) Writes an account the reader already has elsewhere into the fediverse field of their Nextcloud profile, where Discover's colleague suggestions read it, and creates nothing here. An address on this server, or anything that is not user@host, is a 422 with the reason. {"result": {"handle": "…"}, "status": 1}.
POST /api/v1/account/delete user confirm (required: the handle being deleted, with or without a leading @, either name or name@host, case-insensitive) Deletes the caller's own Social account, and only theirs. The same deletion occ social:account:delete performs: the posts go, the follows go, and a Delete federates to every server that knew the account. The Nextcloud user is untouched — this is for somebody who wants their fediverse presence gone and their Nextcloud account kept, which until now meant asking an administrator. The typed handle is the whole of the guard and is deliberately not a password: an account signed in through SSO has none to give. A confirmation that does not name this account is a 422 whose message says what to type. The handle is held for the retention hour (AccountService::TIME_RETENTION) so nobody else can take it at once; a new account under a different handle can be created straight away, because ActorsRequest::getFromUserId() skips deleted rows. Every app signed in to the account is signed out with it, because verify_credentials creates an actor for a user who has none and a phone left running would otherwise re-make the account a minute later. It cannot be undone. Rate-limited to 3 an hour.
DELETE /api/v1/current/follow user account (required) Unfollows an account; {"result": [], "status": 1}.

Account info

Method Route Auth Parameters Description
GET /api/v1/account/{username}/info user, public Local account with complete details, returned unwrapped as a Person; rebuilds the actor cache if it is missing.
GET /api/v1/global/account/info user, public account (required, e.g. user or user@domain) Local or remote account, returned unwrapped. A leading @ is stripped; remote accounts get follower/following/post counts fetched. A local actor is created on demand only when the logged-in viewer asks about their own account — the route is public, so creating for anyone would let anonymous visitors force a Fediverse identity onto any Nextcloud user. Rate-limited: 10 per five minutes anonymously, 120 per minute per user, because a handle this instance has never seen costs a host-meta, a WebFinger and four signed actor fetches against a host the caller names.
GET /api/v1/global/actor/avatar user, public, no-csrf id (required) Streams the cached avatar with a 24 h cache header; 404 (envelope shape) when the actor has no icon.

The followers and following lists of an arbitrary account are reachable through the Mastodon-compatible /api/v1/accounts/{account}/followers and /api/v1/accounts/{account}/following.

Banner

Method Route Auth Parameters Description
POST /api/v1/banner user, no-csrf file (multipart, $_FILES['file']) Stores the upload as the current user's header image, updates the actor cache and federates an Update. Returns {"result": {"url", "id"}, "status": 1}. A file that is not a readable image, is of a refused type, or is over the ceiling is a 400: the file is the problem and the reader can pick another one. It used to be a 500, which told them the server had broken and logged a fault against it.
POST /api/v1/banner/url user, no-csrf url (string, default '', required in practice) Downloads the image at the given url with cURL (follows up to 5 redirects, 30 s timeout, user agent Nextcloud-Social/0.10) and stores it as the current user's header image. Same response shape. An empty url, or a non-2xx response, fails.

Migration

The Migration page's three buttons: take a copy of the account's Social data, put one back, and bring the follows over from another server. Session routes with CSRF, not client-API ones — an archive of everything an account ever wrote is not something a third-party token should be able to ask for, and these are for the person in front of the browser.

Method Route Auth Parameters Description
GET /api/v1/migration/export user The account's Social data as a zip: social/actor.json, social/following_accounts.csv, social/followers.csv, social/blocked_accounts.csv, social/muted_accounts.csv, social/bookmarks.csv, social/likes.csv, social/outbox.json, plus a social/export.json manifest naming the app version, the account and the migrator version. Written by the same SocialMigrator Nextcloud's whole-account export uses, so the two archives hold the same files at the same paths. The private key is never in it — see the class comment on SocialMigrator — so an archive cannot be used to sign as the account it came from. Downloads as social-<uid>-<date>.zip. Rate-limited to 6 an hour.
POST /api/v1/migration/import user file (multipart, $_FILES['file'], ≤ 100 MB) Reads such an archive back into the signed-in account: the profile, the follows, the blocks, the mutes, the bookmarks and the favourites. Additive — nothing is deleted, and the posts in outbox.json are reported rather than re-published. An archive with no social/actor.json is a 400 that says so. Returns {"imported": true, "log": [...]}, the migrator's own account of what it did. Rate-limited to 6 an hour.
POST /api/v1/migration/follows user file (multipart, $_FILES['file']) A follows export — Mastodon's, GoToSocial's or Akkoma's following_accounts.csv, Pixelfed's pixelfed-following.json (a JSON array of actor URLs; Pixelfed offers no CSV), or the archive above — re-followed one account at a time through the ordinary follow path. A file that parses as JSON is read as JSON, anything else as CSV; a JSON entry may be a URL, a handle, or an object naming one under url/acct/account/id, and the array may sit under following or orderedItems. An actor URL is fetched and followed as the actor it resolves to. A follow is a relationship two servers have to agree on, so it cannot be carried in a file and is requested again from here. Returns {"followed", "skipped", "failed"}, where failed maps a handle to the reason. Rate-limited to 4 an hour.
POST /api/v1/migration/posts user file (multipart, $_FILES['file']), fetch_media (1) An account's own posts, brought over from the server it wrote them on — the one thing a Move has never carried. Reads this app's own archive (social/outbox.json in the zip, with the files under media_attachments/), Mastodon's and GoToSocial's (outbox.json at the root, the layout ours copied), an outbox.json on its own, Pixelfed's pixelfed-statuses.json, PeerTube's (the one its own My account → Import/Export offers), or Instagram's "Download your information" archive — content/posts_*.json (both the current your_instagram_activity/content/ layout and the older one) plus reels.json, igtv_videos.json and other_content.json, with the pictures out of the archive's own media/ folder. An Instagram post has no id, no visibility and no hashtags as data: an id is derived from the file it carries and the moment it was posted (so a second run is still a no-op), the importing account's own default visibility is used, and the hashtags are read out of the caption. Its captions are mojibake by construction — the exporter escapes each UTF-8 byte as a character — and are converted back. stories.json, archived_posts.json and recently_deleted_content.json are deliberately not read. Instagram's HTML download is refused with the sentence that says to ask for JSON. A PeerTube export is read from peertube/videos.json rather than the activity-pub/outbox.json beside it: the ActivityPub half names each video's file by its address on the old server while the PeerTube half names the copy inside the archive, so one import needs the old server still running and the other does not — and the PeerTube half states the privacy as a number, and carries the title, tags, category and licence, which is most of what a video is. Private, internal and password-protected videos are skipped (each is one its author decided not to publish, and there is no audience here that means "the people who had the password"), so are lives, and so is any video whose file is not in the archive. An export taken without its video files is refused by name, because the JSON alone is a catalogue and the run would otherwise report "nothing imported" about a perfectly valid archive that is simply not the one to ask for. View, like and dislike counts are not carried over: they are numbers about the old instance's readers. Each post is written as a new local post of the importing account, dated when it was written, with its pictures — from inside the archive where it has them, and from the old server (which therefore has to still be up) where the export named only their addresses, unless fetch_media is 0. Nothing is federated: not one delivery is queued, because re-publishing somebody's five years of posts would put five years of posts into every follower's timeline in one afternoon. Boosts and direct messages are skipped — the first is somebody else's post, the second is addressed to accounts on a server that is not this one. A reply keeps its parent where the export holds both. Answers {"imported", "skipped", "already", "media", "failed", "total", "capped"}; a repeat of the same file imports nothing, because social_import_post remembers what each post became. At most 2000 posts a run — capped says the run stopped there and another will carry on — and 2 runs an hour. A bigger archive than a browser will upload goes through occ social:account:import-posts.
POST /api/v1/migration/video user url (required), fetch_media (1) One video, by its address — the single-video half of the job above, which PeerTube also has and people use: an export is a heavy tool for one video, and somebody who has lost their account on the old server cannot take one at all while the video is still there to be fetched. The document is fetched, and has to be a Video that names the address it was fetched from among its own url links — the same evidence resolveStatus() requires, so a redirect cannot substitute one video for another. The playable file link is stored (the tallest one; an HLS playlist is passed over, since storing a list of segments on somebody else's server and calling it a video is not an import). The same three rules as the archive: nothing is federated, the id is ours with the original remembered so a second attempt is a no-op, and it is written as a new local post of the caller, keeping its title, running time, category and licence. Refuses a video already on this server (bringing a neighbour's post over as your own is not an import, and it is the one case the server can actually tell), and anything that is not a video. What it cannot check is whether the video is yours — neither can PeerTube's own importer, nor the archive import, which reads a file somebody uploaded; what stands in for it is the same thing: one deliberate act, rate-limited to 10 an hour, producing an ordinary post that moderation and reporting reach like any other. Answers the same tally. A refusal is a 422 whose message says which one it was.
POST /api/v1/migration/blocks user file (multipart, $_FILES['file']) The accounts an export blocks — Mastodon's blocked_accounts.csv, a bare list of handles, and the same file the archive above holds. A block is this account's own decision rather than a relationship two servers agree on, so each one is applied here directly, severing the follows both ways and federating a Block exactly as blocking somebody from the web client does. Its own route rather than a kind on the follows one, so a client that got the kind wrong cannot follow the people it meant to block. An account that does not resolve is named in failed rather than dropped. Returns {"blocked", "skipped", "failed"}. Rate-limited to 4 an hour.
POST /api/v1/migration/mutes user file (multipart, $_FILES['file']) The accounts an export mutes — Mastodon's muted_accounts.csv, whose Hide notifications column is the one thing a mute stores besides its target and is honoured here. A mute federates nothing. Returns {"muted", "skipped", "failed"}. Rate-limited to 4 an hour.
POST /api/v1/migration/lists user file (multipart, $_FILES['file']) The lists an export names — Mastodon's lists.csv, one list name,account address per row, with no header (one is skipped where it is there). Run it after the follows: a list here holds accounts this one follows, as Mastodon's do, so an account not followed yet is counted in skipped rather than followed on the quiet by a route that says lists. A list whose title is already there is filled rather than duplicated, so a second run changes nothing; a list that follows a Nextcloud group is left alone, because its members are the group's. Returns {"lists", "added", "skipped", "failed"}, where failed maps list/handle to the reason. Rate-limited to 4 an hour.
GET /api/v1/migration/export/{kind} user kind: following, followers, blocks, mutes or lists One of those lists of accounts as a single CSV, written the way Mastodon writes it — following_accounts.csv, followers.csv, blocked_accounts.csv, muted_accounts.csv or lists.csv — so another server's importer reads it without being handed a whole archive. The same files the zip above holds, one at a time. An account this server never cached has no handle to write and is left out, and so is the account's own handle — every local actor holds a loopback follow of itself, and a following_accounts.csv naming you is a row Mastodon's importer tries to follow you with. At most 5000 rows a file. Any other kind is a 404. Rate-limited to 30 an hour.

| GET | /api/v1/migration/aliases | user | — | The accounts this one also answers to — its alsoKnownAs. {"aliases": [...]}. | | POST | /api/v1/migration/aliases | user | alias (required, an actor id) | Adds one. This federates nothing: it is a statement this server makes about an account it owns, and it is what the old server demands before it will accept a Move pointing here — so it is the person's own to make rather than an administrator's. Idempotent. An address that is not an actor id (a handle, a hostname, this account's own id, an actor on this server) is a 422 naming the reason. 30 an hour. | | DELETE | /api/v1/migration/aliases | user | alias (required) | Stops answering to it. Idempotent, and answers with the list as it now stands. 30 an hour. |

Channels

The Group actor a video is published under. PeerTube resolves a video's channel by looking for a Group in its attributedTo and refuses the video with "Cannot find associated video channel" when there is none — so before these existed, every Video this app published was thrown away on arrival, silently and in PeerTube's log rather than ours. One is made for an account the first time it posts a video (PostService::createPost()), named after the account, so nobody has to learn the word; these routes are for the person who wants a second one or a better name on the first. Session routes, not client-API ones: making an actor here mints an address other servers will follow and cache, which is not something to hand to a third-party token.

Method Route Auth Parameters Description
GET /api/v1/channels user Every channel this account has, oldest first: {"channels": [{"id", "actor_id", "handle", "name", "description", "default"}]}.
POST /api/v1/channels user handle (required), name, description Makes one, with a Group actor of its own — its own key pair, inbox, outbox and followers, followable and moderatable like any other actor. The handle is an address on this instance and cannot be changed afterwards, which is why it is asked for separately from the name; one that is taken or is not a handle is a 422 naming the reason. At most 20 channels an account. 5 an hour.
PUT /api/v1/channels/{id} user id, name, description Renames one, or changes what it says it is about — on the row and on the actor document both. Somebody else's channel is a 422, the same answer an id that does not exist gets. 30 an hour.

The dangerous direction is deliberately not here. Move tells every server that knows you to follow somebody else instead, cannot be taken back, and stays occ social:account:move.

Posts held for review

The other end of the review queue: what of the caller's own is waiting, and a way to take one back. Session routes with CSRF, like the migration ones and for the same reason — unpublished text of the caller's own is not something a third-party token should be able to read, and there is no account parameter here, so there is nothing to point at anybody else. What a caller may reach is scoped in SQL on the way out, not checked after the row has been read.

A held post is not in social_stream. What is stored is the request, so a post waiting for a moderator appears in no timeline, no profile, no outbox and no search — not even its author's — and there is no read path that could be written without the filter that would leak one. Approving it replays the request through the ordinary posting path.

Method Route Auth Parameters Description
GET /api/v1/review user The caller's own held posts, newest first: {"held": [{"id", "account_id", "reason", "text", "spoiler_text", "visibility", "media_count", "created_at"}], "reasons": [...]}, where reasons is the same list in a sentence a person reads.
DELETE /api/v1/review/{id} user Takes one back. An author changing their mind about a post nobody has seen is not a moderation decision and leaves no record. Somebody else's, and one that does not exist, are the same 404.

What happens at the moment of posting is on POST /api/v1/statuses: a held post answers 422 with held_for_review: true, the rule under reason, and the held post itself — the vocabulary Mastodon's API has for "this did not become a status", with enough beside it for a client to say something better than "failed". The request has been kept, so writing it again only finds the first one: the queue is unique on (account, text).

Statistics

The Statistics page. A session route with CSRF rather than a client-API one, for the same reason the migration routes are: it is for the person in front of the browser, and an account's whole history of engagement is not something a third-party token should be handed. There is no account parameter — it only ever answers about the caller.

Method Route Auth Parameters Description
GET /api/v1/statistics user Everything the Statistics page shows, counted from this instance's rows at the moment it is asked for: nothing is stored and no cron precomputes it. See the shape below. Rate-limited to 30 an hour, because the answer is a walk rather than a lookup.
GET /api/v1/memories/on_this_day user The caller's own top-level posts from this calendar day in each of the last five years, newest first, at most six. Status entities in the local format, with their link previews and places attached, as a timeline page carries them. A session route and not a client-API one: it answers with the caller's followers-only and direct posts as well as their public ones, which is what a third-party token should not be handed. There is no account parameter and there must never be one — the query behind it does not filter by audience, because it is only ever asked about the caller. Each year is looked up as that calendar day in the reader's own timezone rather than as "about a year ago", so the 4th of March finds the 4th of March; a post written at 23:50 belongs to the day it was written on. A leap day is found in leap years only, rather than sliding to the 1st of March. Replies are left out: a reply read outside its thread is a fragment.
GET /api/v1/memories/recap user How the caller's week went: enabled, this_week and last_week. Off until the reader switches it on, and then it answers enabled: false with no counts at all rather than counts the client is expected to hide. The two counts are read off the same twelve-week chart /api/v1/accounts/{account}/highlights draws, so one place decides what a week is and the recap cannot disagree with the reader's own profile. There is no streak field and there should not be one: a run of consecutive weeks is a thing that can be lost, and telling somebody they have broken one is asking them for posts.
POST /api/v1/memories/recap user enabled (required, bool) Turns the weekly recap on or off for the caller and answers {enabled}.

The answer:

Key What it holds
account acct, display_name, created_at, followers, following
posts total, originals, replies, boosts, with_media, sensitive
engagement likes, boosts, replies and a *_per_post for each
rates total engagement and per_post; applause, amplification and conversation (likes, boosts and replies per post, under the names an agency reports them under); per_follower, engagement per post against the follower count as a percentage; median and best, because a mean is one viral post away from meaningless; silent and silent_share, the posts that got no answer at all
visibility how many posts went to each audience
by_month, engagement_by_month twelve months of posts, and of what they collected
by_hour twenty-four hours of posts
by_weekday seven days, each with posts, engagement and the average — the average is the useful one, since posting twice as often on a Monday collects twice as much on a Monday without Monday being better
best_hour {hour, average, posts}, or hour: null where no hour holds three posts yet: one lucky post is not a time of day that works
content one row per kind of post (with_media/text_only, with_hashtag/no_hashtag, original/reply, visibility_*), each with posts, engagement and average. A kind with no posts is absent rather than reported as zero
hashtags, hashtag_performance the tags used most, and separately the tags whose posts average best — the second only counts tags used twice or more
audience by_month followers gained, instances the hosts they are on, local_share the percentage on this server, counted and capped
best the three most-answered posts, each with an excerpt, its counts and its score
periods the last days (30) beside the 30 before them: current and previous, each with from, until, posts, reach, interactions, likes, boosts, replies and a series of 30 daily buckets for reach, interactions, likes and boosts; plus change, the percentage each moved by, or null where the earlier window held nothing — everything is infinitely more than nothing
timeline every post of the current window, newest first and at most 100, each with id, url, published_at, excerpt, likes, boosts, replies, score, media, visibility and reach
reach followers, and known_boosters/unknown_boosters: how many of the accounts that boosted a post in the window this server knows the audience of, and how many it does not
window counted, capped, max, first_at, last_at, followers_counted

reach is an estimate and the page says so: an account's own followers today plus the followers of everybody who boosted the post, as the boosters' own instances last reported them. Two audiences that overlap are counted twice, a booster this server knows nothing about counts as nobody (and is counted in unknown_boosters instead), a direct message has no follower audience at all, and nobody anywhere can count the people who read a post without touching it. A post is counted on the day it was published together with everything it has collected since — there is no record of when a like arrived, only that it did. Both windows are the same length and anchored to the start of today, so opening the page twice in an afternoon draws the same two lines.

Every engagement figure is a post's details — the local count plus whatever the origin reported — so all of them are a floor rather than a total, which is true of every Fediverse statistic. There are no impressions to divide by, so per_follower is against the follower count and says so on the page. A boost the account made is counted under posts.boosts and excluded from everything else, because its likes belong to whoever wrote it. The post walk stops at 2000 and the follower read at 5000, newest first, and window and audience each say whether they stopped early.

Pixelfed's administration routes

Pixelfed's app shows its administration screens to anybody the server calls an administrator, and they call Pixelfed's own /api/admin/* routes — not Mastodon's /api/v1/admin/*. They stand behind the same gate (AdminApiControllerBase: a Nextcloud administrator, or a group the Social section has been delegated to under Administration privileges, with a session and CSRF token or a bearer token carrying admin:read/admin:write) and are answered by the same service, so who may moderate and what a moderation does are decided in one place: a takedown from the Pixelfed app is the same takedown as one from the Mastodon API or the settings page. What has no equivalent here is answered as absent — an empty list, or a 422 that says why — never as a 200 that changed nothing. Every failure is worded as in the Mastodon admin API: 401 without credentials, 403 for a non-administrator (the same answer whether the user exists or not), 404 for an unknown thing, 422 for what this instance does not have.

Method Route Auth Parameters Description
GET /api/admin/stats public, no-csrf (admin required, admin:read scope) The four numbers on the app's admin home: users_count, posts_count and instances_count are this instance's own counts (the ones /api/v2/instance carries), autospam_count is 0 because there is no autospam queue here, and cached_at is now.
GET /api/admin/config public, no-csrf (admin required, admin:read scope) Pixelfed's five instance switches (federation.activitypub.enabled, pixelfed.open_registration, instance.stories.enabled, pixelfed.enforce_email_verification, pixelfed.bouncer.enabled), each with the state that is actually true of this instance, so the screen describes this server rather than a Pixelfed one.
POST /api/admin/config/update public, no-csrf (admin required, admin:write scope) key, value Always a 422 naming where the setting lives: every one of these is configured in Administration settings → Social or belongs to the Nextcloud server, and a 200 that flipped nothing would leave the administrator believing it had.
GET /api/admin/users/list public, no-csrf (admin required, admin:read scope) q (a username prefix), sort (asc/desc by creation, desc when absent) The local accounts as the app's user browser lists them, fifty at most, each id, username, email (always '' — the address is the Nextcloud account's, which this app does not read out to a client), created_at, is_admin (whether the user may administer this instance) and status (disabled for a suspended account, null otherwise).
GET /api/admin/users/get public, no-csrf (admin required, admin:read scope) user_id (required; a client id or a username) One account: the row above under data, and under meta the Account entity, report_count (reports filed against it), and the three per-account flags Pixelfed has and this instance does not (unlisted, cw, no_autolink), all false. dms_sent and remote_report_count are 0: nobody here counts direct messages sent, and a remote report is a report like any other.
POST /api/admin/users/action public, no-csrf (admin required, admin:write scope) id (required), action (required), value delete — the app's word for removing an account — suspends it, which takes its posts down here and sends the same Delete to every peer that the account's own deletion would. unlisted is this instance's silence tier and cw marks everything the account posts sensitive: both are words for things this instance does have, and were refused until 0.20.6 with a 422 that was true of the words and not of the instance. no_autolink is a state an account does not have here, and verify_email and refresh_stats are the server's: each a 422 that says so.
GET /api/admin/mod-reports/list public, no-csrf (admin required, admin:read scope) The open reports as the app's queue draws them: id, type (post when the report carries one, user otherwise), message (the reporter's comment, with the reason in front of it when it came from the app's own report route), object_id, object_type (App\\Status or App\\Profile, Pixelfed's class names), created_at, reported_by_account, reported_account, status (the first reported post) and parent (null).
POST /api/admin/mod-reports/handle public, no-csrf (admin required, admin:write scope) id (required), action (required) ignore resolves the report, in the signed-in moderator's name, and answers {"success": true}. cw and unlist would apply a per-post flag this instance does not have and are a 422: a post here is taken down or left.
GET /api/admin/autospam/list public, no-csrf (admin required, admin:read scope) This instance's review queue, fifty at most: the posts held because their author had published nothing here yet, or because one of the spam rules tripped. Each carries id, status_id (the same id — a held post has no status), account_id, username, content, is_nsfw, scope, created_at, and the rule that held it as both reason and reason_text. Pixelfed's screen shows a score here; a rule is what a moderator can act on and a score is not.
POST /api/admin/autospam/handle public, no-csrf (admin required, admin:write scope) id (required), action (required) approve (Pixelfed's "not spam", also not_spam) publishes the post as its author; delete (also spam) refuses it, which tells the author and is recorded against them as a strike. Anything else is a 422. {"success": true}.
GET /api/admin/instances/list public, no-csrf (admin required, admin:read scope) q (a domain fragment), sort (asc/desc), sort_by (id, user_count, status_count, domain), filter (all, unlisted, banned, auto_cw) The remote instances this one has heard of — every host a cached account is on — fifty at most, each with what this instance has decided about it in Pixelfed's words: unlisted is this app's silence, banned a deny-list entry, auto_cw a flag this instance does not have and always false. user_count is how many of the host's accounts are cached here; status_count is 0, because nothing here counts posts per host and a guess would be worse than a zero the docs explain. id is the same derived id the domain-block routes use, so an instance and its block name the same thing.
GET /api/admin/instances/get public, no-csrf (admin required, admin:read scope) id (required; the derived id or the domain) One instance, as above; unknown is a 404.
POST /api/admin/instances/moderate public, no-csrf (admin required, admin:write scope) id (required), key (unlisted, banned, auto_cw), value (a boolean) Writes the list the Pixelfed word names: unlisted silences or unsilences the domain, banned puts it on the deny list or takes it off. On an instance that federates by an allow list, banned is a 422 — the same reason the domain-block routes refuse there — and auto_cw is a 422 everywhere. Answers with the instance as it now stands.

Config and system

Method Route Auth Parameters Description
POST /api/v1/config/cloudAddress admin cloudAddress (required) Sets the app's cloud base URL. Returns a bare [].
GET /local/ public, no-csrf {"result": {"version": "<installed_version>", "setup": <bool>}, "status": 1}.
GET /test/{account}/ public, no-csrf account (path) WebFinger self-test. Only active when the social.tests system value is set — otherwise it returns exactly the same payload as /local/. On failure it returns the error envelope with HTTP 200 and a result key holding the test data.

Moderation (moderators only)

Backing routes of the Social section in the administration settings. All of them require a session and a CSRF token (they are deliberately not part of the bearer-token client API), and every one of them carries #[AuthorizedAdminSetting(settings: AdminSettings::class)]: a Nextcloud administrator passes, and so does a group the administrator has handed the Social section to under Administration privileges. That is the same gate core puts on the page itself, so whoever can open it can use the buttons on it.

Method Route Auth Parameters Description
GET /moderation/reports moderator, csrf resolved (false), page (1) One page of 50 reports, newest first — the open ones, or with resolved=true the ones already dealt with. {"reports": [{"id", "account_id", "account", "reporter", "local", "category", "comment", "status_ids", "creation", "resolved", "level"}], "total", "page", "perPage"}. level is what stands against the reported account now (silence, suspend or ""), so a report from last month says whether the account it named is still suspended. The page renders the first page of the open reports itself; this is what its "Show more" button and the folded-away resolved section read. A page past the end answers with no reports and the same total.
POST /moderation/reports/{id}/resolve moderator, csrf resolved (true) Marks the report resolved (or reopens it with resolved=false). Returns the report entity; 404 for an unknown id.
GET /moderation/review moderator, csrf page (1) One page of 50 held posts, oldest first — the order the queue should be drained in. {"held": [{"id", "account_id", "reason", "text", "spoiler_text", "visibility", "media_count", "created_at"}], "total", "page", "perPage", "reviewFirstPost", "autospam"}. The text is in the row because that is what there is to decide about: a held post is in no timeline, no profile and no outbox, so there is nowhere else to go and read it.
POST /moderation/review/{id}/approve moderator, csrf Publishes it, as its author, down the path an immediate post takes — dated now, because that is when it became a post. {"approved": "<id>"}; an id that is not waiting is a 404.
POST /moderation/review/{id}/reject moderator, csrf comment Refuses it. The request is deleted, the author is told through the same notification a takedown uses, and a delete_statuses strike is recorded against the account so the next moderator can see this was not the first time. Nothing else about the account changes — a refused post is not a silence. {"rejected": "<id>"}.
POST /moderation/review/settings moderator, csrf reviewFirstPost (required), autospam (required) Turns first-post review and the spam rules on and off (review_first_post, autospam; both 1 by default). Both are sent together because the section draws them together. Answers with both as they now stand.
POST /moderation/accounts/sensitive moderator, csrf actorId (required), sensitive (true) Marks everything this account posts sensitive, or stops. The step between doing nothing and silencing: an account can be asked to put a content warning on its pictures without being taken out of the timelines. Applied where a post is written (StreamRequest::save()), so it covers a local post, one that arrived in the inbox and one the importer restored alike — and it applies from the next post: rewriting somebody's old posts is a different and much larger decision. Pixelfed's app calls this cw.
GET /moderation/media/blocks moderator, csrf The pictures this instance refuses, by sha256 of the file as it arrived: {"blocks": [{"hash", "reason", "moderator", "blocked", "creation"}]}, where blocked is how many times that file has since been turned away.
POST /moderation/media/blocks moderator, csrf hash (required, sha256 hex), reason Adds one. Every other tool here acts on an account, and none of them stops a file coming back: the account is suspended, the picture is posted again by the next one, and a moderator is deleting the same image for the third time. Checked in CacheDocumentService::saveFromTempToCache(), which is the one place both an upload and a fetched remote attachment pass through. Hashed as the file arrived, before the metadata is stripped: it answers "this exact file, again", and does not claim to answer "a picture that looks like this one". Anything that is not a sha256 is a 422.
DELETE /moderation/media/blocks moderator, csrf hash (required) Removes one.
GET /moderation/trends moderator, csrf What is being kept out of Explore, and what is currently trending to choose from: {"tags", "links", "statuses", "trending"}, each decision carrying who made it and when.
POST /moderation/trends moderator, csrf kind (tag, link or status), ref (the hashtag, URL or post id) Keeps one thing out of every trending list and out of Explore. The counters keep counting it, so lifting the decision puts it back with the number it would have had. A kind this app does not review is a 422.
DELETE /moderation/trends moderator, csrf kind, ref Lets it back in.
GET /moderation/emojis moderator, csrf The instance's own custom emoji, which could only be managed with occ social:emoji until now.
POST /moderation/emojis moderator, csrf shortcode, category, and a picture upload Adds one. The upload is handed to the same service the command calls, so the checks on the shortcode, the size and the format live in one place rather than two that could drift. Anything it refuses is a 422 with the reason.
DELETE /moderation/emojis moderator, csrf shortcode Removes one. An unknown shortcode is a 404.
GET /moderation/rules moderator, csrf The rules this instance asks people to follow, as the one-per-line text they are stored as.
POST /moderation/rules moderator, csrf rules Writes them. Stored as typed, trimmed of surrounding blank lines: the reader splits on newlines and drops the empties anyway, and storing what somebody typed is easier to explain than storing a normalised version of it. They are what /api/v1/instance/rules serves and what every client shows on sign-up; before this they could only be set with occ config:app:set social rules.
GET /moderation/discover/categories moderator, csrf The subjects named so far, the same list the public route hands out: {"categories": [{"id", "name", "hashtags"}]}.
POST /moderation/discover/categories moderator, csrf name (required, at most 64 characters), hashtags Names one. hashtags is written the way somebody pastes it — separated by spaces or commas, with or without the # — because that is what an administrator will type; at most 12 are kept and a tag that is not a word is dropped. A subject with no name, one with no hashtags (it would link to nothing), and the twenty-fifth subject are each a 422 with the reason. Returns the whole list, so the caller never has to ask again.
DELETE /moderation/discover/categories moderator, csrf id (required) Removes one. Returns the whole list.
POST /moderation/fediverse/add moderator, csrf address (required) Adds an instance to the Fediverse access list (the list occ social:fediverse manages). Invalid addresses are a 422. Returns {"list": [...]}.
POST /moderation/fediverse/remove moderator, csrf address (required) Removes an instance from the access list. Returns {"list": [...]}.
POST /moderation/fediverse/access moderator, csrf type (required) Switches the access mode: all_but (blocklist) or none_but (allowlist). Anything else is a 422. Returns {"accessType": "..."}.
GET /moderation/accounts moderator, csrf query, origin (local, remote, or empty for both), status (active, silenced, suspended), maxId (0) A page of up to 40 accounts for the browser on the settings page, as {"accounts": [{"actor_id", "handle", "username", "domain", "local", "level", "strikes"}], "cursors": [nid, …]}. strikes is how many decisions have ever been taken about the account, counted for the whole page in one query. query is tried as all three of the things a moderator types: bob@instance.example (and @bob@instance.example) is an account on an instance, instance.example is the instance, and a bare bob is a username anywhere. Pass the last cursor back as maxId for the next page. An origin that is neither local nor remote means both, and a status outside the three above is passed on to the same reader the Mastodon admin API uses — so pending and disabled answer with no accounts, as they do there.
GET /moderation/accounts/history moderator, csrf actorId (required) What has been decided about one account before now, newest first, as {"strikes": [{"action", "text", "moderator", "report_id", "creation"}]} — up to 50. action is none for a warning, otherwise silence or suspend. moderator is the Nextcloud user who took it, or "" for one taken by a command or a job. A missing actorId is a 400.
POST /moderation/accounts moderator, csrf actorId (required), level (silence, suspend, or empty to lift), comment The instance's own decision about an account, as opposed to one user's block. Silence keeps the account reachable for the people who follow it and takes it out of the public and global timelines; it changes no data and is undone by lifting. Suspend deletes what the account has posted here, drops its cached actor and refuses everything it sends afterwards — lifting stops the refusal but does not bring back what was deleted. Returns the decision, or {"actor_id": …, "level": ""} when lifted.
POST /moderation/statuses/remove moderator, csrf streamId (required) Deletes one post, whoever wrote it. Returns {"stream_id": …}. Reached from the Take down button beside each post a report names — the lightest thing a moderator can do about a report, and until that button existed, the one thing the panel could not do.
GET /admin/announcements moderator, csrf Every announcement, newest first, as the administration page shows one: id, text (as typed, not HTML), starts_at, ends_at, all_day, published_at and active — whether it is being served at this moment. One that has not started and one that has run out are both listed, because those are the ones an admin has to act on.
POST /admin/announcements moderator, csrf text (required), starts_at, ends_at, all_day (false) Posts an announcement and answers with the whole list. Blank text, text past 10000 characters, a date the server cannot read, one bound without the other (Mastodon's own rule) and an end not after its start are each a 422. all_day rounds the window out to whole days and is ignored without a window. There is no edit route: an announcement people have already read is replaced by a new one, not changed under them.
DELETE /admin/announcements/{id} moderator, csrf Removes the announcement and every dismissal of it, for everybody, read or not, and answers with what is left. An unknown id is a 404.
POST /admin/server administrator, csrf contactEmail, extendedDescription, maxSize (10), maxVideoSize (2048), inboxThrottle (300), secureMode (false), publishBlocks (false), allowSelfSigned (false) The Server card of the settings page: the instance-wide settings that had no interface at all and could only be set with occ config:app:set social. Writes all of them or none, and answers with what stands afterwards — the same shape the page is rendered from. A refusal is a 422 naming the field: contactEmail must be an address (or empty) and at most 255 characters, extendedDescription at most 10000, maxSize 1–10240 MB, maxVideoSize 1–102400 MB, inboxThrottle 0–100000 requests per instance per minute (0 disables it). Unlike every other route in this table this one is not delegated: it carries no AuthorizedAdminSetting, so a group the Social section was handed to is refused. What it holds is a decision about the server rather than about a report. Since 0.20.6 it also takes imageMaxEdge (0, or 480–16384 pixels — the longest edge a stored picture may have; 0 keeps every upload exactly as it arrived, which is the default and the only value that loses nothing) and imageQuality (40–100), consulted only when the first is set.
GET /admin/relays administrator, csrf The relays this instance subscribes to, newest first: {"id", "actor_id", "host", "inbox", "status", "error", "created_at", "last_update"}. status is pending (the Follow has gone out), accepted (posts flow both ways) or rejected (the relay said no, or could not be reached — error says which).
POST /admin/relays administrator, csrf address (the relay actor, e.g. https://relay.example/actor) Subscribes. The actor is fetched first, because its inbox is what the subscription needs and there is no way to guess one; an address that answers with no actor, or with an actor that publishes no inbox, is a 422 saying so. A Follow{object: as:Public} — what Mastodon sends and what relay software reads as "subscribe me" — is then signed by the instance actor and delivered inline, because the delivery queue resolves its signing key from social_actor and the instance actor is deliberately not a row there. Answers with the row, which will say pending: the relay answers in its own time. Subscribing twice is the same row.
DELETE /admin/relays/{id} administrator, csrf id Unsubscribes and forgets it. The Undo{Follow} is sent first and its failure does not stop the row going — an administrator who pressed this wants to stop taking that relay's posts, and nothing further is taken from it either way, because taking one in needs a row. An unknown id is a 404.
POST /moderation/retention moderator, csrf days (required, 0–3650) Sets the retention_days app setting: remote statuses older than this that no local user cares about are pruned (0 disables). Returns {"retentionDays": n}.

OAuth

A partial, Mastodon-shaped OAuth 2 flow (OAuthController, ClientService).

Method Route Auth Parameters Description
POST /api/v1/apps public, no-csrf client_name (''), redirect_uris (string or array, ''), website (''), scopes ('read') Registers a client. Returns {"id", "name", "website", "scopes", "client_id", "client_secret"}. redirect_uris may be an array or, as Mastodon's own API takes it, several URIs newline-separated in one field — those are split into separate entries, trimmed, de-duplicated and stripped of blanks. They used to be wrapped into a single entry, which ClientService::confirmData() then compared a lone URI against, so a client registered with more than one could never authorize with any of them.
GET /.well-known/oauth-authorization-server public, no-csrf RFC 8414 discovery, which a Mastodon 4.3 client asks for before it registers an app: the authorization, token, revocation, userinfo and app-registration endpoints, scopes_supported (every scope this server understands), response_types_supported: ["code"] and the two client-authentication methods. The addresses are this app's real ones, under the app's own path rather than the domain root — the honest answer and the useful one, because a client that reads this document is told where the endpoints are rather than assuming Mastodon's root paths. A client that does not read it looks at the root, finds nothing and is no worse off than before.
GET /oauth/userinfo public, no-csrf (bearer token) Who the token belongs to, in OpenID Connect's shape: sub, name, preferred_username, profile, picture. Mastodon 4.3 added it so a client can show "signed in as …" without spending a read:accounts call on verify_credentials, and it needs no scope beyond having a token. sub is the actor's ActivityPub id, not the Nextcloud user id: it is the identifier that means the same thing to everybody, and an internal user id is not a thing to hand to every client that asks. An invalid token is a 401.
GET /oauth/authorize user, no-csrf client_id, redirect_uri, response_type (must be code), scope ('read'), state ('') Renders the oauth2 consent template. redirect_uri is checked against the client's registration before the page exists, so there is nothing to confirm on a forged link. state is carried through to the POST. Anything wrong with the request — an unknown client_id, a response_type that is not code, a redirect_uri or scope the client never registered — is HTTP 400 {"error": "..."}, the way the POST answers it; these used to escape as a Nextcloud HTML error page.
POST /oauth/authorize user client_id, redirect_uri, response_type, scope ('read'), state ('') Issues an authorization code. Answers with a redirect to redirect_uri (RedirectResponse, so HTTP 303) carrying code and, when one was sent, state — appended with http_build_query, so a redirect_uri that already has a query string or a fragment stays valid. state used to be dropped, which a spec-following client rejects and a lax one is open to code injection through. For urn:ietf:wg:oauth:2.0:oob the code (and state) come back as the response body instead. Errors: HTTP 400 {"error": "..."}.
POST /oauth/token public, user, no-csrf client_id, client_secret, redirect_uri, grant_type (only authorization_code), scope ('read'), code ('') Exchanges an authorization code for a bearer token: {"access_token", "token_type": "Bearer", "scope", "created_at"}. scope reports the scopes the token carries — what ApiController::checkTokenScope() will enforce on every request made with it — not the scope of this call. Echoing the latter had a client that omits it (Tusky does) told it had read and hiding its compose button while writes in fact worked. The code expires ClientService::TIME_CODE_TTL (600 s) after authorization. client_credentials is refused: falling through would have returned whatever token the last user's grant left in the client row. created_at is the client row's creation date, read as a date — it used to be an integer cast of a DATE column, which reported a timestamp in January 1970. Errors are HTTP 400/401 {"error": "..."}; credential failures are brute-force throttled.
POST /oauth/revoke public, user, no-csrf client_id, client_secret, token Revokes an access token (RFC 7009). Only the client the token was issued to may revoke it; an unknown or already-revoked token still returns HTTP 200 []. Wrong client credentials return a throttled HTTP 401.
GET /api/v1/authorized_apps user The apps this account has signed in to, newest first: {"id", "name", "website", "scopes", "created_at", "last_used_at", "signed_in"} per row. id is the social_client_auth row, created_at is when this account granted it (not when the app registered on the instance), and signed_in is false for an authorization whose code was never exchanged for a token — the browser came back and the app never asked. A session route, not a bearer one: a token must not be able to read the list of tokens. The token itself is never in the answer; it is stored hashed and nothing here wants it.
DELETE /api/v1/authorized_apps/{id} user id Takes one of them back — the app is signed out at once, because the token is the row and the row is gone. The authorization is looked up among this account's own rather than deleted by the id it was handed, so signing a stranger's device out is impossible rather than unlikely; an id that is not this account's is a 404, the same answer an id that does not exist gets.

Grant types: only authorization_code works. client_credentials returns HTTP 400 {"error": "unsupported_grant_type"}; any other value returns HTTP 400 {"error": "invalid value for grant_type"}.

Scopes: there is no fixed scope vocabulary — SocialClient::getScopesFromString() splits the string on spaces and ClientService::confirmData() checks that requested scopes are a subset of the ones stored at registration; the default everywhere is read. Bearer tokens are enforced per endpoint by ApiController::checkTokenScope(): creating, editing and deleting statuses, uploading media and updating its alt text, status actions, poll votes, moving markers, update_credentials and filing reports need write; follow/unfollow, block, mute and authorizing or rejecting follow requests need follow or write; /api/v1/apps/verify_credentials accepts any valid token; every other /api/ route needs read. A scope is satisfied by itself or a granular variant (write:statuses satisfies write). Session-cookie requests are not scope-restricted, but are only accepted together with a valid CSRF token.

Credential storage: client secrets, authorization codes and access tokens are stored as sha256:<hex> digests (SecretHasher); rows from before hashing hold the bare value, are still accepted, and are rewritten once by the HashClientSecrets repair step. A presented credential that already looks hashed is never tried as one of those legacy plaintext rows — offering it made the stored digest a working bearer token of its own, so a database dump held usable credentials.

Token lifetime: ClientService::TIME_TOKEN_TTL is 30672000 s (~1 year) since last use; a used token's last_update is refreshed at most once per TIME_TOKEN_REFRESH (300 s).


ActivityPub Federation

ActivityPubController decides per request, via checkSourceActivityStreams(), whether the caller is a Fediverse server: it splits the Accept header on commas, trims each entry at the first ;, and returns true if any entry equals application/ld+json or application/activity+json. Anything else (a browser) is treated as HTML.

JSON-LD responses are emitted through activityPubSuccess(), i.e. Content-Type: application/ld+json; profile="https://www.w3.org/ns/activitystreams". The controller also registers activity+json and ld+json; … responders for format-based negotiation.

Method Route Auth Content negotiation Description
GET /actor public, no-csrf always JSON-LD This instance's own Application actor — the identity every outbound signed fetch is made as, and the publicKey.id owner a peer dereferences to check one. It is not an account: no outbox, no followers, no following and no featured collection are named, because naming a collection no route serves makes a peer that follows it conclude the actor is gone; manuallyApprovesFollowers is true and discoverable/indexable are false, so nothing offers it as somebody to follow or index. inbox and endpoints.sharedInbox both point at the shared /inbox, which is a real endpoint and the right one — anything addressed to this actor is addressed to the server. The key pair lives in app config (instance_actor_public_key, and the private half sealed with the instance secret), generated on first use; an instance that does not yet know its own social URL 404s here and signs nothing rather than signing with a keyId nobody can resolve.
GET /users/{username} public, no-csrf JSON-LD for AP Accept, else HTML Actor object (with W3C security context). Unknown actor → error envelope with HTTP 404. Without an AP Accept header it delegates to SocialPubController::actor(), which answers a browser: the app for a reader with a session, the public page for a visitor, a 404 for an account this instance does not hold. The client-side router has no view for this path — it owns /@{username} — so a browser sent here lands on the app with nothing in it; the alias below is the address to link.
GET /@{username}/ public, no-csrf same as above Alias that calls actor(). For a browser (Accept without an ActivityPub type) SocialPubController answers: a reader with a session gets the app, the same page as /, since the client-side router has a view for the path; a visitor gets the public page; an account this instance does not know is a 404 — a small guest page for a visitor, the app with a 404 status for a reader. The lookup is of what is cached and does not go and fetch an unknown name, which is where an unknown name used to become a 500.
GET /@{username}/collections public, no-csrf HTML An account's collections, for a browser: a reader with a session gets the app, whose router owns the path, and a visitor the public page — the same page the profile itself answers with. Not an ActivityPub URL: a collection is local and federates nothing, so unlike the followers page there is no JSON to answer first.
GET /@{username}/portfolio public, no-csrf HTML An account's page of work, for a browser — and the point of the feature: a portfolio is what a photographer links from a CV, and a link that asks the reader to sign in first is not that. The client-side router owns the path; this exists so a link to it opened cold is not a 404.
POST /@{username}/inbox public, no-csrf JSON Per-user inbox. Rate-limited on the source address and read with a 2 MB ceiling (ActivityPubController::MAX_INBOX_BODY, Mastodon's figure) before any signature work — a body past it is a 413 and nothing is digested, fetched or verified — then verifies the HTTP signature, checks the Fediverse access list, spends the verified origin's own looser bucket, requires the local actor to exist, imports the activity, then async-processes the stream cache queue (inbox_throttle app setting, default 300/min, 0 disables; either bucket → HTTP 429). Returns {"result": [], "status": 1}; a gone signature also returns success. A refusal is answered with the status that says what was wrong — 401, 403, 400, 404 or 503, and 500 only for a fault of ours; see the rejection table in docs/Architecture.md.
GET /@{username}/inbox public, no-csrf JSON-LD Empty OrderedCollection (totalItems: 0) for the actor's inbox; bare [] with HTTP 404 if the actor is unknown.
POST /inbox public, no-csrf JSON Shared inbox, same processing (including both rate limits and the same rejection statuses) without the per-actor check.
GET /@{username}/outbox public, no-csrf always JSON-LD Outbox collection. The HTML fallback is commented out in the source, so browsers get JSON-LD too.
POST /@{username}/outbox public, no-csrf always JSON-LD Same route handler as the GET, registered as ActivityPub#outbox_post; posting an activity is not implemented — the method only returns the outbox collection.
GET /@{username}.rss public, no-csrf username An account's public posts as an RSS 2.0 feed. PeerTube publishes one per channel and Mastodon one per account, and PeerTube's users in particular live in feed readers and podcast apps: a channel with no feed is one they cannot subscribe to from outside. A video carries an <enclosure>, which is what makes such a feed usable in a podcast client at all. Built from an anonymous read — the same one a stranger visiting the profile gets — because a feed is the easiest possible way to leak a followers-only post: one wrong predicate and it is in somebody's reader and out of reach for ever, so FeedService refuses a non-public post a second time. Twenty posts, cached ten minutes.
GET /@{username}/followers public, no-csrf JSON-LD for AP Accept, else HTML Followers collection, or the page: the app for a reader with a session, the public page for a visitor, a 404 for an unknown account. The AP branch is refused with 401 under secure_mode when nothing signed the fetch; the page is not, since a browser has nothing to sign with.
GET /@{username}/following public, no-csrf JSON-LD for AP Accept, else HTML Following collection, or the page, the same way. The AP branch is refused with 401 under secure_mode the same way.
GET /@{username}/collections/featured public, no-csrf always JSON-LD The actor's pinned posts as an OrderedCollection whose orderedItems carry the full posts (at most 5). This is where remote servers read pinned posts from; the actor document points at it with featured. The route has no Accept gate and no viewer, so the posts are read through the anonymous visibility filter and only public ones appear; it is refused with 401 under secure_mode when nothing signed the fetch — PinService::pin() refuses anything narrower in the first place, but posts pinned before that rule existed stop being exposed here with no cleanup step. Unknown actor → error envelope with HTTP 404.
GET /@{username}/stories/{id} public, no-csrf always JSON-LD One story as a Story object — and the capability the Add carried. Two ways in: a request bearing the story's own token (Authorization: Bearer …), which is how Pixelfed fetches a story, or the rule the client API keeps — the author, or whoever signed the fetch and follows them. The token names one story, is derived rather than stored (HMAC of the story's address under an instance secret generated on first use), and stops working when the story does. Anybody else, and an expired story, is the same 404, because whether an account has a story up is told to its followers and to nobody else. {id} is numeric, which is what keeps it out of the way of the /@{username}/{token} catch-all below.
GET /@{username}/{token} public, no-csrf JSON-LD for AP Accept, else HTML Single post. {token} values outbox, followers and following (case-insensitive) are re-routed to those handlers first. For AP callers the Stream is returned as JSON-LD (HTTP 404 error envelope with a stream key when missing). A browser is answered by SocialPubController::displayPost(): the post is resolved by its address and then, when {token} is all digits, by the numeric id the app itself links with, and is rendered into the page as item in the client format, so it shows before the app has asked for anything. A reader with a session gets the app around it, the same page as /; a visitor gets the public page. A post that resolves to nothing is a 404: a small guest page for a visitor, the app with a 404 status for a reader, whose post view says the post is not available once it has asked.
GET /@{username}/{token}/quote_authorizations/{stamp} public, no-csrf always JSON-LD The FEP-044f approval a quote of {token} rests on, as a QuoteAuthorization naming the quoted author (attributedTo), the quoting post (interactingObject) and the quoted post (interactionTarget). {stamp} is the quoting post's id, base64url-encoded without padding — the same URI this instance put in the Accept that granted the approval, which is why nothing has to be stored for the document to be served. Answered from the quoted post's current policy, so a post narrowed after the fact stops being quotable and the approval 404s with it; a stamp this instance would not have written (a second base64 spelling, or one that does not decode to an address) is a 404 as well. No viewer is set: an approval is a public statement about a public post. Refused with 401 under secure_mode when nothing signed the fetch.
GET /@{username}/{token}/replies public, no-csrf always JSON-LD The replies to {token}, as an OrderedCollection whose first/last point at ?page=N; with ?page=N (or Mastodon's ?page=true, which means the first) it answers with an OrderedCollectionPage of at most OrderedCollection::PAGE_SIZE (40) items linked by next/prev. The note itself points here with replies. Items are the replies' ids, not the replies: a reply is its author's document, served by their instance, which may since have edited or deleted it. No viewer is set, so only public replies appear — a followers-only or direct reply is not listed even as an id, because an id is enough to fetch the reply from the instance that holds it. Oldest first, so a page offset stays stable as the thread grows. A post this instance does not hold, or one that does not exist, is an error envelope with HTTP 404. Refused with 401 under secure_mode when nothing signed the fetch.

Discovery documents (not app routes)

WebFinger, NodeInfo discovery and host-meta are served by lib/WellKnown/WebfingerHandler.php, registered through registerWellKnownHandler() in lib/AppInfo/Application.php — they are Nextcloud-level paths (.well-known/webfinger, .well-known/nodeinfo, .well-known/host-meta), not entries in this app's route table. Only the NodeInfo 2.0 document itself is an app route (see /.well-known/nodeinfo/2.0 above).

The handler first checks FediverseService::jailed() and passes the previous response through when the instance is not allowed to federate. For webfinger with a resource query parameter (acct: prefix stripped; the raw request URI is parsed as a fallback):

  • The app's own subject gets an extra link added to the existing response, carrying app, name and version properties.
  • For a local actor it returns a JRD document whose subject is the requested resource, with aliases for the actor URL and the Nextcloud profile page, a self link of type application/activity+json pointing at the /@{username}/ route, an http://webfinger.net/rel/profile-page link (text/html) to the Nextcloud profile page, and an http://ostatus.org/schema/1.0/subscribe link with a template of <social url>ostatus/follow/?uri={uri}.
  • acct:<host>@<host> — the instance's own handle, the one Mastodon gives its instance actor — returns a JRD with an alias and a self link (application/activity+json) pointing at /actor. It is answered before any local account is looked up, so a Nextcloud user whose id happens to equal the instance host cannot take the name the server signs under.
  • A missing or empty resource parameter is an empty JRD with HTTP 400 (RFC 7033 makes the parameter mandatory).
  • Unknown or non-local subjects produce an empty JRD with HTTP 404 (or hand back to the previous handler when the actor lookup fails outright).

The nodeinfo service returns a single link with rel http://nodeinfo.diaspora.software/ns/schema/2.0 pointing at the app's /.well-known/nodeinfo/2.0 route; host-meta returns XRD (application/xrd+xml) with an lrdd template pointing at the instance's WebFinger URL.


Legacy OStatus

Method Route Auth Parameters Description
GET /ostatus/follow/ user, no-csrf uri (required) Resolves uri as an account, then as an actor id, and renders the Vue app with account and currentUser in initial state. Requires a logged-in user; failures return the error envelope.
GET /api/v1/ostatus/followRemote/{local} public, user, no-csrf Renders the Vue app with the guest layout, providing local and account in initial state, so a remote visitor can follow the local account {local}.
GET /api/v1/ostatus/link/{local}/{account} public, user, no-csrf, rate-limited (10/5min per IP) WebFingers {account}, extracts its http://ostatus.org/schema/1.0/subscribe link template, substitutes {uri} with {local}'s account, and returns {"result": {"url": "<subscribe url>"}, "status": 1}.

Frontend / Document serving

These serve HTML or files for the app's own UI; they are not client API endpoints.

Method Route Auth Parameters Description
GET / user, no-csrf cloudAddress (read from the request during first-run setup, admins only) Renders the Vue app (main template) and provides serverData (public, firstrun, setup, isAdmin, cliUrl, cloudAddress, plus checks for admins). Creates the user's actor on first visit and tries to auto-configure the cloud address.
GET /timeline/{path} user, no-csrf path (default '', requirements: .+) Same page; path is accepted and then ignored — the method just calls navigate().
GET /follow_requests user, no-csrf Same page. The path belongs to the client-side router; the server answers it so that reloading or bookmarking the follow-requests page works instead of 404ing.
GET /blocked user, no-csrf Same page, for the blocked-and-muted-accounts view (Settings → Blocked and muted accounts in the app's sidebar).
GET /discover user, no-csrf Same page, for the Discover view — who to follow (suggestions and starter packs), and what is being looked at (pictures and hashtags). The client-side router owns the path; this route exists so that reloading or bookmarking it is not a 404.
GET /migration user, no-csrf Same page for the path Migration used to have. It is a section of Settings now, and the client-side router redirects there; the route stays so that a bookmark or a link somebody was sent is not a 404.
GET /statistics user, no-csrf Same page, for the Statistics view — what the account has posted and what came back. The client-side router owns the path; this route exists so that reloading or bookmarking it is not a 404.
GET /collections/{id} user, no-csrf Same page, for the collection view — one album, as a grid of its posts, with the owner's edit, remove-posts and delete controls. The client-side router owns the path; this route exists so that reloading or bookmarking it is not a 404.
GET /places/{id} user, no-csrf Same page, for the place view — the public posts taken at one place, as a grid. The client-side router owns the path; this route exists so that reloading or bookmarking it is not a 404.
GET /settings user, no-csrf Same page, for the Settings view — what this app holds about how the reader uses it, which for now is the keyboard shortcuts. The client-side router owns the path; this route exists so that reloading or bookmarking it is not a 404.
GET /search user, no-csrf Same page, for the Search view with nothing searched yet. The client-side router owns the path; this route exists so that reloading it is not a 404.
GET /search/{term} user, no-csrf term (accepted and ignored; the client-side router reads it off the address) Same page, for the results of one search. Reloading a search, or opening one somebody sent, used to be a 404: the client-side router had the route and the server did not.
GET /document/get user, no-csrf id (required) Streams a cached document with its stored mime type. Errors: error envelope, HTTP 500.
GET /document/public public, no-csrf id (required) Same for documents marked public.
GET /document/get/resized user, no-csrf id (required) Streams the resized/preview variant.
GET /document/public/resized public, no-csrf id (required) Same for public documents.

Async Queue

Method Route Auth Parameters Description
POST /async/request/{token} public, no-csrf token (path) Internal endpoint the app calls against itself to deliver queued federation requests for {token}. With nothing queued it returns an empty HTTP 200. Otherwise it closes the connection (async()) and processes standby requests for at most QueueController::MAX_DURATION (90 s) — whatever is left stays standby for the cron — then ends in exit(), since the connection is already gone.

Error Responses

There is no single error format; three shapes exist.

1. TNCDataResponse envelope (LocalController, ConfigController, ActivityPubController, OStatusController, NavigationController — everything using the trait):

{"result": {}, "status": 1}

on success (success(); more keys are merged in at the top level), and

{"status": -1, "error": "request failed"}

on failure (fail()) — the exception class and message go to the log, never into the response, since several callers are public pages. The HTTP status is whatever the caller passed — the default is 500, callers also use 404, and Config#remote deliberately returns the failure envelope with HTTP 200. Failures are logged as warnings unless the caller disables it. Two related helpers bypass the envelope: directSuccess() returns the object as-is with HTTP 200, and activityPubSuccess() does the same while setting Content-Type: application/ld+json; profile="https://www.w3.org/ns/activitystreams".

2. ApiController and TagController errors — a bare object, never the envelope:

{"error": "the access_token was revoked"}

from the private error() helper of each, which maps the failure to a status a client can act on — see the table below. TagController maps the same four cases it can raise: 403 for a token whose scope is too narrow (with WWW-Authenticate: Bearer error="insufficient_scope"), 401 for no or stale credentials (Bearer error="invalid_token"), 422 for something that is not a hashtag, and 500 for anything else — with the message withheld, since these are public routes. Failures raised with no message of their own get a wording that fits the status (the access_token is invalid, not found, the request could not be processed, request failed) rather than {"error": ""}. mediaOpen() is the one route that does not go through it: a missing or non-public document is a 404, any other failure a 400.

Every handler catches Throwable, not Exception. A TypeError — an empty or truncated JSON body was the way to raise one, on seven public endpoints — used to escape as a Nextcloud HTML error page, with a stack trace where debug is on, to a client that can only read JSON.

3. OAuthController errors{"error": "..."} with HTTP 400 (bad grant type, missing code, token generation failure) or HTTP 401 (unknown client_id, other exceptions).

ApiController status codes

ApiController keeps Mastodon's {"error": "..."} body and maps the failure to a status a client can act on. Every failure used to be a 401, which a client reads as a revoked token: a deleted status, a mistyped timeline name, a database hiccup and a slow remote all logged the reader out of their client.

Status When
401 No credential, or one that is no longer valid (ClientNotFoundException, AccountDoesNotExistException). Carries WWW-Authenticate: Bearer error="invalid_token".
403 A valid token whose grant does not cover the route (InsufficientScopeException, tested ahead of the list because it extends ClientException). Carries WWW-Authenticate: Bearer error="insufficient_scope". Also a fediverse access rule (UnauthorizedFediverseException).
404 The thing asked for is not here: StreamNotFoundException, CacheActorDoesNotExistException, ActorDoesNotExistException, ItemNotFoundException, CacheDocumentDoesNotExistException, HashtagDoesNotExistException, ReportNotFoundException, FollowNotFoundException, InstanceDoesNotExistException, OCP\Files\NotFoundException, and a remote that answered with nothing (RequestContentException).
422 The request was understood and refused, and retrying it unchanged cannot help: InvalidActionException (which now also covers a body that claims to be JSON and is not, and a visibility this app does not know), UnknownProbeException, InvalidResourceException, InvalidResourceEntryException, InvalidHandleException, ItemUnknownException, CacheContentMimeTypeException, ClientException.
429 TooManyRequestsException, and the #[AnonRateLimit] / #[UserRateLimit] limits on the write, search, account and timeline routes.
502 Another server let us down: RequestNetworkException, RequestServerException, RequestResultNotJsonException, RequestResultSizeException.
500 Anything unrecognised. The body is always {"error": "internal server error"} and the real message is logged with its stack trace — these routes are all #[PublicPage], and echoing getMessage() published whatever the failure happened to name.

Successful Mastodon-compatible responses are not wrapped: ApiController returns the object or array directly with HTTP 200. HTTP status codes in use across the app are 200, 303 (the actor-header and OAuth authorization redirects — RedirectResponse's default), 400, 401, 403, 404, 422, 429, 500, 502 and 503 (a refused inbox delivery whose signature could not be checked); no endpoint returns 201 or 204.