Mastodon (mastodon)
Mastodon is a federated social network: every account lives on one instance and
the OAuth app is registered on that instance. Rustiz’s mastodon provider
uses one pre-registered app on MASTODON_URL (default
https://mastodon.social); to connect an account on any other instance without
server-side credentials, use mastodon-custom.
- Rust source:
crates/rustiz-providers/src/mastodon.rs - Upstream port:
mastodon.provider.ts - Editor: Normal
- Connection mode:
oauth - Related provider:
mastodon-custom(externalUrl, shares the same Rust implementation and the same redirect path)
Overview
| Property | Value |
|---|---|
| Identifier | mastodon |
| Display name | Mastodon |
| Connection mode | oauth (a human opens a login URL) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; extra posts are published as replies with in_reply_to_id |
| Pending / async publishing | Yes. supports_pending = true; media is uploaded first, then polled, then the status is created |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 10 (Mastodon limits are per user, not per app) |
Character limit (max_length) | 500 characters, hard-coded |
| OAuth scopes | write:statuses, profile, write:media |
| Redirect URI | {FRONTEND_URL}/integrations/social/mastodon |
Server Environment Variables
required_env() returns exactly:
#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
&["MASTODON_CLIENT_ID", "MASTODON_CLIENT_SECRET"]
}
}
required_env_alternatives() is not overridden.
| Variable | Required | Default | Purpose |
|---|---|---|---|
MASTODON_CLIENT_ID | Yes | — | Client id returned by POST /api/v1/apps on MASTODON_URL |
MASTODON_CLIENT_SECRET | Yes | — | Client secret returned by the same call |
MASTODON_URL | No | https://mastodon.social | Instance that hosts the app and the accounts. Trailing slashes are stripped. Read with Config::get_or, not base_url. |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the redirect URI |
JWT_SECRET | Yes in practice | empty | Encrypts the per-connection client information used by mastodon-custom, and is used by the rest of the server for stored secrets. Not in required_env(). |
There is no MASTODON_BASE_URL override in this provider. The instance is
MASTODON_URL; tests point that variable at a mock server. MASTODON_URL is
not part of required_env(), so rustiz providers --status reports mastodon
as configured even when the URL is unreachable or the app was registered on a
different instance.
rustiz providers --status only checks that MASTODON_CLIENT_ID and
MASTODON_CLIENT_SECRET are non-empty. It cannot tell whether they belong to
the instance in MASTODON_URL.
Step-by-Step Developer App Setup
1. Pick the instance
The standard provider is single-instance by design:
generate_auth_url()sends the human to{MASTODON_URL}/oauth/authorize.authenticate()exchanges the code at{MASTODON_URL}/oauth/token.post()andcomment()call{MASTODON_URL}/api/v1/....
An app registered on mastodon.social cannot authorize an account on
fosstodon.org. If you need multiple instances, either run one Rustiz
deployment per instance and change MASTODON_URL, or use
mastodon-custom, which registers an app on each user’s instance.
2. Register the app on the instance
Postiz’s docs register the app through the API; the instance’s web UI (Preferences → Development → New application) produces an equivalent app.
Using the API (replace the redirect URI with your deployment’s exact value):
curl -X POST -sS "https://mastodon.social/api/v1/apps" \
-F "client_name=Rustiz" \
-F "redirect_uris=https://social.example.com/integrations/social/mastodon" \
-F "scopes=write:statuses profile write:media" \
-F "website=https://social.example.com"
The response contains client_id and client_secret:
{
"id": "1234567890",
"name": "Rustiz",
"redirect_uris": ["https://social.example.com/integrations/social/mastodon"],
"scopes": ["write:statuses", "profile", "write:media"],
"client_id": "…",
"client_secret": "…"
}
Alternatively, sign in to the instance and open Preferences → Development →
New application (https://<instance>/settings/applications/new). Set the
redirect URI and check exactly the three scopes above. The web form and the API
produce the same kind of app.
The client_name is what users see on the consent screen. Rustiz’s
mastodon-custom hard-codes it to Rustiz; for the standard provider you can
name it anything. There are no products or API subscriptions to enable;
Mastodon’s scopes are the only permission gate.
3. Set the environment and restart
MASTODON_CLIENT_ID="…"
MASTODON_CLIENT_SECRET="…"
MASTODON_URL="https://mastodon.social"
Restart every process that reads the configuration (rustiz serve,
rustiz worker, rustiz mcp). Check:
rustiz providers --status
4. Redirect URI (exact)
Mastodon::redirect_uri() is:
#![allow(unused)]
fn main() {
format!(
"{}/integrations/social/mastodon",
self.ctx.config.frontend_url()
)
}
Config::frontend_url() trims trailing slashes from FRONTEND_URL, so the
registered value must be without a trailing slash:
https://social.example.com/integrations/social/mastodon
Local examples:
http://localhost:5000/integrations/social/mastodon # Docker / rustiz serve default
http://localhost:4200/integrations/social/mastodon # upstream Postiz dev UI
There is no redirectmeto.com wrapper for Mastodon. mastodon-custom
reuses this exact path (see its page), because the Rust code hard-codes
mastodon, not mastodon-custom.
Mastodon requires the redirect_uri sent to /oauth/authorize and
/oauth/token to be one of the URIs registered on the app. If you register a
trailing slash, or http instead of https, the token exchange fails with
invalid_grant / The provided redirect_uri is not valid.
5. Scopes requested and checked
Mastodon::scopes() returns, in this order:
#![allow(unused)]
fn main() {
vec!["write:statuses", "profile", "write:media"]
}
| Scope | Why Rustiz needs it |
|---|---|
write:statuses | POST /api/v1/statuses (posts and replies) |
profile | GET /api/v1/accounts/verify_credentials during connect |
write:media | POST /api/v1/media and GET /api/v1/media/:id |
The profile scope was added in Mastodon 4.3. It only grants
GET /api/v1/accounts/verify_credentials (and GET /oauth/userinfo in 4.4+),
which is exactly what the connect flow needs. An instance older than 4.3 does
not know the scope: app registration or the authorize request can fail with
invalid_scope, or the token may be issued without access to
verify_credentials. The provider has no fallback to read:accounts; on an
older instance, change Mastodon::scopes() to read:accounts and re-register
the app.
The authorize URL joins them with +:
{instance}/oauth/authorize?client_id=…&response_type=code
&redirect_uri={urlencoded redirect}
&scope=write:statuses+profile+write:media
&state=…
The token request sends the same three scopes space-separated in a multipart
form. The app must be registered with all three. Mastodon refuses a token
request whose scope is not a subset of the app’s registered scopes.
check_scopes() is never called by this provider. The Rust port requests
the scopes but does not verify the token response’s granted scopes. If an
administrator edits the app and removes write:media, the connection still
succeeds and only media uploads fail later.
6. Sandbox / testing vs production
Mastodon has no sandbox, no test-mode app and no app review.
- Test against a small instance or a dedicated test account. The first post is
public on the account’s profile unless the account is locked or the instance
is configured otherwise (Rustiz always asks for
visibility=public). mastodon.socialallows self-service app creation; some instances restrict registrations, require admin approval for new accounts, or enable authorized-fetch mode. Those are instance policies, not Mastodon app review.- There is no “development mode”: the app works for every account on the instance as soon as it is registered.
- Production requires the same three env vars and a reachable
MASTODON_URL. Nothing is submitted to Mastodon gGmbH for review.
Connecting a Channel (CLI & MCP)
CLI
rustiz channels connect mastodon --open
- Prints the login URL to stderr;
--openlaunches the browser. - With a local
FRONTEND_URL(http://localhost:…/http://127.0.0.1:…) and no separate server, the CLI serves the callback itself on the frontend port and waits up to--timeoutseconds (default 300) for the channel to appear. - With a public HTTPS
FRONTEND_URL, the CLI prints the URL and exits; the server handles the callback. Pollrustiz channels connect-status <id>orrustiz channels list.
Do not pass --external-url to mastodon: Mastodon::external_url() is
only implemented when self.custom == true, so the standard provider returns
Unsupported("externalUrl"). Use mastodon-custom for that.
MCP connectChannelTool
{
"provider": "mastodon",
"externalUrl": null,
"customFields": null
}
The tool returns either a completed connection or:
{
"connected": false,
"pending": true,
"requiresHuman": true,
"connectionId": "…",
"url": "https://mastodon.social/oauth/authorize?...",
"message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}
Give the url to a human, then call again with:
{ "connectionId": "…" }
connectChannelTool with an MCP client that supports URL elicitation sends the
login URL through elicitation/create instead of returning it as text.
integrationSchema reports connection: {type:"oauth", headless:false, requiresHuman:true, requiresExternalUrl:false}. There is no follow-up
triggerTool / channelSetupTool: the account is stored directly and
is_between_steps is false.
What not to send
externalUrlis not supported bymastodon:begin()callsMastodon::external_url(), which returnsUnsupported("externalUrl"). Usemastodon-custom.customFieldstakes precedence for any provider inconnect.rs. Sending an object tomastodonroutes toconnect_custom(), which encodes it as thecodeofauthenticate(); that expects an OAuth authorization code, so the connection fails. Never sendcustomFieldstomastodon.
Post Settings & Media Validation
settings_schema()
Mastodon does not override settings_schema(). The trait default is:
{ "type": "object", "properties": {}, "additionalProperties": true }
There are no provider-specific settings. The engine adds "__type": "mastodon" to the stored settings. Visibility, content warnings, sensitive
media, polls and language are not exposed and are not read by the provider.
validate_settings()
Not overridden: every settings object is accepted.
check_validity()
Not overridden: no pre-flight media rules. Mastodon’s own API enforces them.
Media requirements
Rustiz uploads every media item with POST /api/v1/media (the v1,
synchronous endpoint) before creating the status:
| Media | Mastodon limit | Notes |
|---|---|---|
| Images | Up to 4 per status, 16 MB each (PNG, JPG, HEIF, WEBP, AVIF) | Downscaled by the instance to about 8.3 MP |
| Animated GIF / GIFV | 1 per status, 16 MB | Converted to a soundless MP4; < 1 MP |
| Video | 1 per status, 99 MB (MP4, M4V, MOV, WebM) | Transcoded to H.264 MP4 |
| Audio | 1 per status, 99 MB | Transcoded to MP3 |
| Alt text | Per item | Sent as the multipart description only when non-empty |
The provider sends all files in one multipart request per media item. The file
part carries the filename from rustiz_core::media::file_name(path); the code
does not set an explicit MIME type on the part.
A status cannot mix a poll with media. Rustiz does not send polls at all.
Threads
supports_comments = true, so a multi-entry value array is published as:
- The first entry as a status.
- Each later entry as a reply, with
in_reply_to_idset to the previous status id (or the root post id for the first reply). - Every reply uses
visibility=public.
Each reply uploads its own media before posting. There is no thread-root
metadata beyond in_reply_to_id, which is exactly what Mastodon needs.
Plugs
No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the
trait defaults and return ProviderError::Unsupported.
Implementation Notes & Gotchas
- Pending handshake.
post_pending()uploads all media, stores{url, message, mediaIds, idempotencyKey}inpending_data, and returnsPostStatus::Pending. The engine then pollscheck_post_status()every 20 seconds (up to 90 checks, about 30 minutes). When every media id answers200, it callsfinalize_post()to create the status. - Media polling semantics.
GET /api/v1/media/:idreturns206 Partial Contentwhile processing and200 OKwhen ready.401becomes a refresh error.404/410are treated as “already attached by an earlier finalize” and skipped, not as a hard failure.422means the upload expired and is reported asThe uploaded media expired before the post was published, please post again. - Idempotent finalize. The finalize request sends
Idempotency-Key: <postId>-<5 random chars>, generated once inpost_pending(). Mastodon stores idempotency keys for about one hour; a retry inside that window replays the original response instead of creating a second status. A retry after the window can duplicate the post. - Comments are not idempotent.
comment()does not send anIdempotency-Key. A retried comment can appear twice. - Two posting paths.
post()is the blocking compatibility path (post_pending→ poll →finalize, 8-minute timeout, 20-second sleeps). The engine usespost_pending()becausesupports_pending = true. - Always public. Both
finalize_post()andcomment()hard-codevisibility=public. There is no setting forunlisted,private,direct, content warnings, sensitive media, language or polls. - Hard-coded 500 characters.
max_length()returns 500 regardless of the instance’sconfiguration.statuses.max_characters(some instances allow 5,000). The engine counts characters after HTML stripping. Mastodon counts every URL as 23 characters; Rustiz counts the whole URL, so a post with a long URL can be rejected locally even though Mastodon would accept it. - Media limit not pre-checked.
check_validity()is the default no-op. Uploading five images succeeds for each upload, thenPOST /statusesfails. Check the account’s instance limits before scheduling large carousels. - The v1 media endpoint is deprecated. The code uses
POST /api/v1/mediaand accepts200,201or202. Mastodon 3.1.3+ documentsPOST /api/v2/mediaas the replacement; v1 still works and blocks until the attachment is processed. The follow-upGET /api/v1/media/:idpoll remains correct for both. - Media MIME type. The multipart
filepart is built withPart::stream(...).file_name(...)and noContent-Type. Mastodon’s docs ask for a part with a MIME type. Reqwest may sendapplication/octet-stream; if an instance rejects an otherwise valid image withFile content type is invalid, set the part’s MIME type by content sniffing. - No token refresh. Mastodon access tokens normally do not expire.
authenticate()storesrefresh_token: Some("null")(the literal string) andexpires_inof 100 years plus 25 days.refresh_token()returns an empty access token andexpires_in: 0. A401therefore cannot be repaired automatically; reconnect the channel. A401during media polling is explicitly converted to a refresh error. MASTODON_URLis read at publish time. For the standard provider,instance_url()always returns the currentMASTODON_URL, not the instance used at connect time. ChangingMASTODON_URLafter connecting makes every existing channel post its stored token to the new host.MASTODON_URLdefault.https://mastodon.social. If you leave it unset and register the app on another instance, the login will fail with aninvalid_clientpage on mastodon.social.- Error mapping.
handle_errors()maps three body substrings:Your login is currently disabled→ refresh/reconnect,not finished processing→ “The media was still processing…”,could not be found→ “The uploaded media expired…”. Other Mastodon errors pass through the generic HTTP layer, which retries429/5xxup to three times with the configured delay. - Rate limits. Mastodon defaults: 300 requests per 5 minutes per account
and per IP, and 30 media uploads per 30 minutes.
max_concurrent_job = 10is optimistic for a media-heavy queue because each post spends at least one request per attachment plus the status call. - Release URL shape. The provider builds
{instance}/statuses/{statusId}. Mastodon’s API returns a canonicalurl(usuallyhttps://{instance}/@{user}/{id}); the constructed URL is a redirect-style path, not necessarily the exact permalink. Analytics or link-checking code should follow the redirect. - SSRF and local instances. OAuth and API calls go through the shared HTTP
client, which blocks private IPs by default. A Mastodon instance on a private
network needs
DISABLE_SSRF_PROTECTION=true(global, dangerous) or a deployment that can resolve the host publicly. check_scopes()absent. As above: granted scopes are never compared with the required list. An app edited after registration is only discovered when the missing endpoint is called.
References
- Mastodon OAuth: https://docs.joinmastodon.org/spec/oauth/
- Registering an app (
POST /api/v1/apps): https://docs.joinmastodon.org/methods/apps/ - Posting a status: https://docs.joinmastodon.org/methods/statuses/
- Media upload and the 206 processing status: https://docs.joinmastodon.org/methods/media/
- Rate limits: https://docs.joinmastodon.org/api/rate-limits/
- Postiz Mastodon setup: https://docs.postiz.com/self-host/providers/mastodon
- Postiz Mastodon settings: https://docs.postiz.com/public-api/providers/mastodon