Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

PropertyValue
Identifiermastodon
Display nameMastodon
Connection modeoauth (a human opens a login URL)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; extra posts are published as replies with in_reply_to_id
Pending / async publishingYes. supports_pending = true; media is uploaded first, then polled, then the status is created
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job10 (Mastodon limits are per user, not per app)
Character limit (max_length)500 characters, hard-coded
OAuth scopeswrite: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.

VariableRequiredDefaultPurpose
MASTODON_CLIENT_IDYes—Client id returned by POST /api/v1/apps on MASTODON_URL
MASTODON_CLIENT_SECRETYes—Client secret returned by the same call
MASTODON_URLNohttps://mastodon.socialInstance that hosts the app and the accounts. Trailing slashes are stripped. Read with Config::get_or, not base_url.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI
JWT_SECRETYes in practiceemptyEncrypts 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() and comment() 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"]
}
ScopeWhy Rustiz needs it
write:statusesPOST /api/v1/statuses (posts and replies)
profileGET /api/v1/accounts/verify_credentials during connect
write:mediaPOST /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.social allows 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; --open launches 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 --timeout seconds (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. Poll rustiz channels connect-status <id> or rustiz 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

  • externalUrl is not supported by mastodon: begin() calls Mastodon::external_url(), which returns Unsupported("externalUrl"). Use mastodon-custom.
  • customFields takes precedence for any provider in connect.rs. Sending an object to mastodon routes to connect_custom(), which encodes it as the code of authenticate(); that expects an OAuth authorization code, so the connection fails. Never send customFields to mastodon.

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:

MediaMastodon limitNotes
ImagesUp to 4 per status, 16 MB each (PNG, JPG, HEIF, WEBP, AVIF)Downscaled by the instance to about 8.3 MP
Animated GIF / GIFV1 per status, 16 MBConverted to a soundless MP4; < 1 MP
Video1 per status, 99 MB (MP4, M4V, MOV, WebM)Transcoded to H.264 MP4
Audio1 per status, 99 MBTranscoded to MP3
Alt textPer itemSent 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:

  1. The first entry as a status.
  2. Each later entry as a reply, with in_reply_to_id set to the previous status id (or the root post id for the first reply).
  3. 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} in pending_data, and returns PostStatus::Pending. The engine then polls check_post_status() every 20 seconds (up to 90 checks, about 30 minutes). When every media id answers 200, it calls finalize_post() to create the status.
  • Media polling semantics. GET /api/v1/media/:id returns 206 Partial Content while processing and 200 OK when ready. 401 becomes a refresh error. 404/410 are treated as “already attached by an earlier finalize” and skipped, not as a hard failure. 422 means the upload expired and is reported as The 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 in post_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 an Idempotency-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 uses post_pending() because supports_pending = true.
  • Always public. Both finalize_post() and comment() hard-code visibility=public. There is no setting for unlisted, private, direct, content warnings, sensitive media, language or polls.
  • Hard-coded 500 characters. max_length() returns 500 regardless of the instance’s configuration.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, then POST /statuses fails. Check the account’s instance limits before scheduling large carousels.
  • The v1 media endpoint is deprecated. The code uses POST /api/v1/media and accepts 200, 201 or 202. Mastodon 3.1.3+ documents POST /api/v2/media as the replacement; v1 still works and blocks until the attachment is processed. The follow-up GET /api/v1/media/:id poll remains correct for both.
  • Media MIME type. The multipart file part is built with Part::stream(...).file_name(...) and no Content-Type. Mastodon’s docs ask for a part with a MIME type. Reqwest may send application/octet-stream; if an instance rejects an otherwise valid image with File content type is invalid, set the part’s MIME type by content sniffing.
  • No token refresh. Mastodon access tokens normally do not expire. authenticate() stores refresh_token: Some("null") (the literal string) and expires_in of 100 years plus 25 days. refresh_token() returns an empty access token and expires_in: 0. A 401 therefore cannot be repaired automatically; reconnect the channel. A 401 during media polling is explicitly converted to a refresh error.
  • MASTODON_URL is read at publish time. For the standard provider, instance_url() always returns the current MASTODON_URL, not the instance used at connect time. Changing MASTODON_URL after connecting makes every existing channel post its stored token to the new host.
  • MASTODON_URL default. https://mastodon.social. If you leave it unset and register the app on another instance, the login will fail with an invalid_client page 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 retries 429/5xx up 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 = 10 is 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 canonical url (usually https://{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