Skip to content
Back to Resources
Guide

Mastodon Automation: Posting to Any Instance via API

Skopx Team
August 21, 2026
18 min read

To post to Mastodon programmatically, you register an application on the specific server that hosts your account, obtain an OAuth access token carrying the write:statuses and write:media scopes, then send a POST request to /api/v1/statuses with that token in an Authorization: Bearer header. The Mastodon API is scoped to a single instance rather than a single global service, so a token issued by mastodon.social is meaningless to fosstodon.org, hachyderm.io, or a server you run yourself.

That one architectural fact drives every design decision in a Mastodon automation. There is no central developer portal, no app review queue, no single base URL, and no shared rate limit pool. Instead there are thousands of independent servers, each running the same open source software, each with its own admin who can change character limits, media size caps, request quotas, and whether application registration is open at all. Building for Mastodon means building for a family of endpoints that share a schema but not a configuration.

This guide covers the practical mechanics: getting a token without a full OAuth dance, the scope model, the two-step media pipeline, idempotency keys, rate limit headers, and how to read instance configuration at runtime instead of hardcoding assumptions that break on the first non-default server you touch.

Why the Mastodon API works differently from every other social API

Most social platform integrations start the same way. You create a developer account, register an app against one company's portal, wait for approval, and receive credentials that work against one hostname forever. The Threads and X pipelines both follow that shape, as covered in our Threads API posting guide and the walkthrough on Twitter automatic posting.

Mastodon inverts it. The software is the product, and each server operator deploys their own copy. Consequences worth internalizing before you write code:

The base URL is a variable, not a constant. Every request path is https://{instance_host}/api/v1/.... Your configuration needs to store the host alongside the token, and the two must always travel together. Storing a bare token without its origin server is a bug waiting to surface.

Credentials do not federate. Federation moves posts, follows, and boosts between servers. It does not move authentication. If you manage three accounts on three servers, you register three applications and hold three tokens.

Capabilities vary per server. The default character limit is 500, but administrators routinely raise it. Some instances run 1,000, some 5,000, some higher. Media size caps, supported MIME types, and maximum attachments per post are all configurable. Hardcoding 500 characters means silently truncating on servers that would have accepted the full text, or getting 422 errors on servers configured lower.

Rate limits are per server and per token. The published defaults are a starting point, not a contract. Admins tune them. The response headers are the only authoritative source at runtime.

There is no app review. You are not asking permission from a platform company. You are asking permission from one account holder, on one server, through a standard OAuth consent screen. This makes Mastodon one of the fastest social integrations to get working end to end, often under fifteen minutes for a single account.

How do you get an access token for your instance?

There are two paths, and the right one depends on whether you are automating your own account or building something other people will connect.

Path one: the web UI, for your own account

If the automation posts as you, skip OAuth entirely. Log into your instance in a browser, open Preferences, then Development, then New application. Give it a name, leave the redirect URI at the default out-of-band value, check the scopes you need, and save. Open the application you just created and you will see a client key, a client secret, and, most usefully, Your access token.

That token is a long-lived bearer credential for your own account. Copy it into your secret store and you are done. No authorization code exchange, no callback server, no refresh loop. This is the correct approach for a single-account posting bot and it is the path most people should take first.

Path two: full OAuth, for multi-user tools

If other people will connect their own Mastodon accounts, you need the standard three-step flow, executed against whatever host the user typed in.

Step one: register the application. POST to /api/v1/apps on the user's instance. This endpoint is typically unauthenticated, which is what makes dynamic registration possible.

curl -X POST https://example.social/api/v1/apps \
  -F 'client_name=Newsroom Publisher' \
  -F 'redirect_uris=https://yourapp.com/oauth/mastodon/callback' \
  -F 'scopes=read:accounts write:statuses write:media' \
  -F 'website=https://yourapp.com'

The response contains client_id and client_secret. Cache these per host. Re-registering on every login creates a pile of orphaned application records on the user's server and is considered rude.

Step two: send the user to authorize. Build the consent URL:

https://example.social/oauth/authorize
  ?client_id={client_id}
  &redirect_uri={redirect_uri}
  &response_type=code
  &scope=read:accounts%20write:statuses%20write:media
  &state={csrf_token}

The scope parameter here must be a subset of what you registered. Asking for more than the application declares produces an error rather than a broader grant.

Step three: exchange the code.

curl -X POST https://example.social/oauth/token \
  -F 'client_id={client_id}' \
  -F 'client_secret={client_secret}' \
  -F 'redirect_uri={redirect_uri}' \
  -F 'grant_type=authorization_code' \
  -F 'code={code}' \
  -F 'scope=read:accounts write:statuses write:media'

You receive an access_token. Classic Mastodon tokens do not expire on a timer and there is no refresh token in the traditional sense, though newer releases have moved toward token expiry, so treat a 401 as a signal to re-authorize rather than assuming permanence.

Immediately after the exchange, call GET /api/v1/accounts/verify_credentials and store the returned id, username, and acct. This confirms the token works and gives you a display identity for your UI. It also catches the classic mistake of pairing a token with the wrong host in your database.

Which OAuth scopes does posting actually require?

Mastodon supports both coarse scopes (read, write, follow, push) and granular sub-scopes. Request the narrowest set that covers your feature. Users read the consent screen, and a posting tool asking for blanket read access to direct messages looks careless.

ScopeGrantsNeeded for posting?
write:statusesCreate, delete, edit, boost, and favourite statusesYes, this is the core requirement
write:mediaUpload attachments and update alt textYes, if you post images, video, or audio
read:accountsRead profile data for the authorized accountRecommended, powers verify_credentials
read:statusesRead timelines and individual statusesOnly if you fetch posts back, for example to read reply counts
write:bookmarksAdd and remove bookmarksNo
followFollow, unfollow, block, muteNo, and asking for it on a publishing tool is a red flag to users
pushWeb Push subscriptionsOnly for real time notification delivery
profileNarrow read of profile metadata without full account accessA lighter alternative to read:accounts on newer servers

A minimal publishing integration therefore asks for read:accounts write:statuses write:media and nothing else. If you later add a feature that reads engagement data back, add read:statuses at that point and re-authorize, rather than over-requesting up front against a feature you have not shipped.

When a request fails with 403 and a body mentioning insufficient scope, the token is valid but was granted less than the endpoint requires. That is a re-authorization problem, not a retry problem, so do not put it in your retry loop.

How do you post a status with the /api/v1/statuses endpoint?

The core call is small. Everything interesting is in the optional parameters.

curl -X POST https://example.social/api/v1/statuses \
  -H "Authorization: Bearer ${MASTODON_TOKEN}" \
  -H "Idempotency-Key: post-2026-08-18-morning-brief" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "New write-up on running scheduled jobs against federated servers.",
    "visibility": "public",
    "language": "en",
    "media_ids": ["114829301..."],
    "spoiler_text": "",
    "sensitive": false
  }'

The parameters that matter most in automation:

status is the text body. Mentions use @user@host form. Hashtags are plain #text. URLs are auto-linked, and importantly, every URL counts as a fixed number of characters toward the limit regardless of its real length, typically 23. That means shortening links does nothing for your character budget on Mastodon and only costs you readability and click trust.

visibility takes public, unlisted, private, or direct. public appears in the local and federated timelines and in hashtag search. unlisted is publicly viewable but excluded from those discovery surfaces, which is a genuinely useful setting for high frequency automation that would otherwise flood a small server's local timeline. Consider defaulting bulk automated posts to unlisted unless the account exists specifically to be discovered.

in_reply_to_id builds threads. Post the first status, read the id from the response, pass it as in_reply_to_id on the next, and repeat. Threads on Mastodon are just replies to yourself, and you should carry the same visibility across the whole chain so a reply does not become more public than its parent.

spoiler_text puts the post behind a content warning, with the spoiler string shown as the clickable summary. Mastodon culture uses content warnings far more heavily than other networks, and a bot that respects that convention gets muted far less often.

sensitive blurs media until clicked. Set it independently of spoiler_text.

scheduled_at accepts an ISO 8601 timestamp and makes the server hold the post for you. The timestamp must be a few minutes in the future, and instead of a Status object you get back a ScheduledStatus with its own id, retrievable and deletable through /api/v1/scheduled_statuses. Server-side scheduling has real limits on how many you can queue, so treat it as a convenience for a handful of posts rather than the backbone of a publishing calendar. Most production systems keep the schedule in their own database and fire immediate posts at the right moment, an approach we compare against platform-native queues in our overview of social media scheduling tools.

poll[options][] and poll[expires_in] create a poll. Polls and media attachments are mutually exclusive, so a request carrying both is rejected.

A successful call returns 200 with the full Status object, including id, url, and created_at. Log the url, because it is the canonical permalink and the only thing you can hand a human to verify the post exists.

How do you attach images, video, and alt text?

Media is a two-step pipeline, and the step people miss is that uploads are asynchronous.

Step one: upload the file to /api/v2/media as multipart form data.

curl -X POST https://example.social/api/v2/media \
  -H "Authorization: Bearer ${MASTODON_TOKEN}" \
  -F 'file=@chart.png' \
  -F 'description=Bar chart showing weekly publishing volume by network.' \
  -F 'focus=0.0,0.4'

The v2 endpoint returns 202 Accepted for anything requiring processing, meaning video, audio, and large images. A 202 response body contains the attachment id but a null url, because the file is still being transcoded. It returns 200 immediately for small images that need no processing.

Step two: wait for readiness. Poll GET /api/v1/media/{id}. While processing continues you get 206 Partial Content. When the file is ready you get 200 with a populated url. Poll with backoff, starting around one second and widening, with a hard ceiling. Attaching an id that is still processing to a status will fail validation.

Step three: attach. Pass the ids in media_ids on your status call. The default maximum is four attachments per post, though this is configurable per instance.

Three details worth building in from the start:

Alt text is not optional in practice. The description field is the accessibility description, and Mastodon has a strong community norm around it. Posts without alt text draw complaints, and several instances have moderation policies about it. Generate a real description, not a filename. You can also set or correct it after upload with PUT /api/v1/media/{id}, but only while the attachment is still unattached to a status.

The focus parameter controls cropping. It takes x,y coordinates from -1.0 to 1.0, with 0.0,0.0 as the center. This tells clients which part of the image to keep visible when generating a cropped thumbnail. For a chart or a screenshot with content near the top, 0.0,0.5 prevents the important region from being cut off in timeline previews.

Size and type caps are per instance. Common defaults are roughly 10 MB for images and 40 MB for video, but an admin can raise or lower either. Read the real values from the instance configuration endpoint rather than guessing, and fail with a clear message when a file is over the limit instead of letting the server return an opaque 422.

What are the rate limits, and how do you avoid duplicate posts?

Mastodon ships with default rate limits that admins can adjust. Treat the table below as the shape of the constraint, and the response headers as the truth.

OperationCommon defaultWhat it means for automation
General authenticated API requests300 per 5 minutes per tokenAmple for normal posting, easy to exhaust with aggressive polling
Status creation300 per 3 hoursRoughly 100 posts an hour, far above sane posting cadence
Media uploads30 per 30 minutesThe real bottleneck for image-heavy batches
Status deletion30 per 30 minutesMatters for cleanup jobs and test harnesses
Unauthenticated requests300 per 5 minutes per IPRelevant if you read public timelines without a token

Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. The reset value is an ISO 8601 timestamp, not a number of seconds, which trips up code copied from other APIs. When you receive a 429, sleep until the reset timestamp rather than applying a fixed backoff, because a blind exponential retry against a three hour window wastes hours.

The media upload ceiling is the one that bites. A batch of forty illustrated posts is forty status calls, well within limits, but forty or more uploads, which is over the thirty per thirty minutes default. Spread media-heavy batches across time or upload ahead of publish time and hold the attachment ids.

Idempotency keys

POST /api/v1/statuses accepts an Idempotency-Key header. Send a stable, unique string per logical post. If the same key arrives again within the server's dedupe window, Mastodon returns the original status instead of creating a duplicate.

This is the single most valuable header in the whole API for automation. Network timeouts on write requests are genuinely ambiguous: the post may or may not have been created. Without idempotency, your retry doubles the post. With it, your retry is safe. Derive the key from something deterministic about the content, such as a hash of the post body plus its scheduled slot, so a retry after a process restart still produces the same key.

Not every third-party server implementation honors the header, so keep a local record of published ids as a second line of defense. Check that record before posting, and write to it immediately after a 200.

How do you handle multiple instances and multiple accounts?

Model the account, not the platform. Each Mastodon connection is a triple: host, access token, account id. Anything that assumes a single global endpoint will break the moment a second account joins.

A few patterns that hold up:

Normalize the host on input. Users type @name@example.social, example.social, https://example.social/, and https://example.social/@name. Parse all of these down to a bare hostname before you build URLs.

Probe before you trust. Call GET /api/v2/instance on the host before registering an application. A valid response confirms the server exists, speaks the Mastodon API, and gives you its version and configuration in one round trip. It also gracefully catches typos and non-Mastodon fediverse software that shares some endpoints but not all.

Store per-host client credentials. Application registration is per server. A map of host to client_id and client_secret avoids re-registering for every user on a popular instance.

Isolate failures. A post to one server failing must not abort the run for the others. Fan out per account, collect per-account results, and report failures individually with the host attached to the error message. An error that says "422 validation failed" without naming the server is useless when six accounts are in flight.

Respect per-server culture. Some instances have explicit rules about bot accounts, required content warnings for certain topics, or restrictions on automated cross-posting. Set the bot flag on the account profile where the account is genuinely automated. Read the server rules, available through GET /api/v1/instance/rules, when you onboard.

Read instance configuration instead of hardcoding limits

GET /api/v2/instance returns a configuration block that removes most of the guesswork:

{
  "configuration": {
    "statuses": {
      "max_characters": 500,
      "max_media_attachments": 4,
      "characters_reserved_per_url": 23
    },
    "media_attachments": {
      "supported_mime_types": ["image/jpeg", "image/png", "image/gif", "video/mp4"],
      "image_size_limit": 10485760,
      "video_size_limit": 41943040
    },
    "polls": {
      "max_options": 4,
      "max_characters_per_option": 50
    }
  }
}

Fetch this once per host, cache it for a day, and drive your validation from it. Then your character counter is correct on a 5,000 character instance, your uploader rejects oversized files before wasting bandwidth, and your attachment limit matches the server rather than the documentation default.

The character counting rule deserves attention because it is genuinely different from other networks. Every URL counts as characters_reserved_per_url regardless of actual length, and the domain portion of a mention does not count toward the total, only @username does. A naive text.length check will overcount posts with links and mentions and truncate content that would have fit. Bluesky handles this differently again, with byte-based counting and explicit facets, which we cover in the Bluesky posting API guide.

Where Mastodon fits alongside the rest of your posting

Almost nobody publishes only to Mastodon. The realistic setup is a piece of content going out to several networks with the wording adjusted per destination, and Mastodon is one of the easier destinations once the token is in place. The hard part is not any single API, it is running six or ten of them with different auth models, different character budgets, and different media rules, then keeping every token alive. That operational surface is the subject of our guides on automated social media posting and cross-posting tools.

If you would rather not maintain that plumbing, Skopx Social Autopilot publishes to LinkedIn, Facebook Pages, Reddit, Instagram, X, Threads, Bluesky, Mastodon, Telegram, Discord, an email newsletter through your own Resend account, and the Skopx community feed. Content is generated per batch and adapted to each network's character limit, so the Mastodon version respects that instance's real ceiling rather than a lowest common denominator. Skopx connects to nearly 1,000 business tools, runs on Solo at $5 per month or Team at $16 per seat per month, and has SOC 2 controls in place.

For teams that want a custom pipeline instead of a fixed one, chat-built workflow automations can call the Mastodon endpoints directly as part of a longer sequence, for example posting only after a document is approved, or after a build passes. Two adjacent patterns worth reading if broadcast automation is the goal: Telegram channel automation and Discord webhook announcements, both of which have far simpler auth than Mastodon and often make a better first automation target.

Frequently Asked Questions

Do I need approval from Mastodon to use the API?

No. There is no central Mastodon company approving API access, and no app review process. You register an application against the specific server hosting the account, and the only approval involved is the account holder clicking through the OAuth consent screen. For your own account you can skip even that by generating a token directly in Preferences, then Development. The practical constraint is not approval, it is that each server's admin sets their own rules about bot accounts and automated posting, so read the instance rules before running a high volume account there.

Does one access token work across multiple instances?

No, and this is the most common mistake when moving from a centralized platform. A token is issued by one server and is only valid against that server's API. If you manage accounts on three instances, you register three applications and hold three tokens, each stored with its host. Federation carries your posts to other servers after publication, but it does not carry your credentials, so there is no way to authenticate once and publish everywhere.

Why does my media upload return 202 instead of 200?

A 202 Accepted means the file was received and is being processed asynchronously, which is normal for video, audio, and larger images. The response includes an attachment id but a null url. Poll GET /api/v1/media/{id}, which returns 206 while processing continues and 200 with a populated url once the file is ready. Only then attach the id to a status. Attaching an id that is still processing produces a validation error, so build the polling loop with backoff rather than a fixed sleep.

How do I stop retries from creating duplicate posts?

Send an Idempotency-Key header on every POST /api/v1/statuses call, using a value derived deterministically from the post itself, such as a hash of the body plus the intended publish slot. If the same key reaches the server again inside its dedupe window, the original status is returned rather than a second one being created. Pair that with a local record of published status ids, checked before each send and written immediately after a 200, so a process restart between the request and the response cannot produce a double post.

What is the real character limit for a Mastodon post?

It depends on the server. The default is 500, but administrators frequently raise it and some instances run several thousand. Read configuration.statuses.max_characters from GET /api/v2/instance for the host you are posting to and validate against that. Also account for the counting rules: each URL counts as a fixed number of characters regardless of length, usually 23, and for mentions only the @username portion counts, not the domain. A plain string length check will be wrong for any post containing links or mentions.

Should automated posts be public or unlisted?

Use unlisted for routine, high frequency automation and reserve public for content you actively want discovered. Unlisted posts are still fully viewable by anyone with the link and still reach your followers' home timelines, but they stay out of the local and federated timelines and out of hashtag search. That distinction matters on Mastodon, where a small server's local timeline is a shared space and a bot posting several times an hour into it is the fastest route to being blocked instance-wide.

How do I build a thread?

Post the first status normally and read the id from the response. Pass that id as in_reply_to_id on the second status, then chain each subsequent post to the id of the one before it. Keep the same visibility on every post in the chain, since a reply set more publicly than its parent creates a confusing and sometimes unwanted disclosure. Add a short position marker such as 1/5 in the text if the thread is long, because not every client renders reply chains as a unified thread.

Share this article

Skopx Team

The Skopx engineering and product team

Related Articles

Stay Updated

Get the latest insights on AI-powered code intelligence delivered to your inbox.