RelayAPI

Mastodon API

Schedule and automate Mastodon posts with RelayAPI — text statuses, images, videos, GIFs, content warnings, visibility controls, and reply threads.

Quick Reference

PropertyValue
Platform keymastodon
Auth methodOAuth 2.0 (per-instance)
Text limit500 characters (default, varies by instance)
Images per post4
Videos per post1
Image formatsJPEG, PNG, GIF, WebP
Image max size16 MB
Video formatsMP4, WebM, MOV
Video max size99 MB
Post typesText, Image, Video, GIF, Media Gallery
VisibilityPublic, Unlisted, Private, Direct
SchedulingYes
AnalyticsNo

SDK sourceTypeScript · Python · Go · Java · REST API

Before You Start

Each Mastodon account is tied to a specific instance (e.g., mastodon.social, fosstodon.org). The instance URL is stored during account connection. The 500-character limit is the default but individual instances can set their own limit. You can attach up to 4 images OR 1 video/GIF per post — mixing images and videos in one post is not allowed. Video and GIF uploads are processed asynchronously and may take a few seconds.

Quick Start

Post a status to Mastodon:

import Relay from '@relayapi/sdk';

const client = new Relay();
const post = await client.posts.create({
  content: 'Hello from RelayAPI! 🐘',
  targets: ['mastodon'],
  scheduled_at: 'now',
});

console.log(post.id); // post_abc123
from relay import Relay

client = Relay()
post = client.posts.create(
    content='Hello from RelayAPI! 🐘',
    targets=['mastodon'],
    scheduled_at='now',
)

print(post.id)  # post_abc123
client := relaygo.NewClient()

post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Content:     relaygo.F("Hello from RelayAPI! 🐘"),
    Targets:     relaygo.F([]string{"mastodon"}),
    ScheduledAt: relaygo.F("now"),
})

fmt.Println(post.ID) // post_abc123
RelayClient client = RelayOkHttpClient.fromEnv();

PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .content("Hello from RelayAPI! 🐘")
    .addTarget("mastodon")
    .scheduledAt("now")
    .build());

System.out.println(post.id()); // post_abc123
curl -X POST https://api.relayapi.dev/v1/posts \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Hello from RelayAPI! 🐘",
    "targets": ["mastodon"],
    "scheduled_at": "now"
  }'

Content Types

Text Status

Plain text status up to 500 characters (default instance limit).

const post = await client.posts.create({
  content: 'Hello from RelayAPI!',
  targets: ['mastodon'],
  scheduled_at: 'now',
});
post = client.posts.create(
    content='Hello from RelayAPI!',
    targets=['mastodon'],
    scheduled_at='now',
)
post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Content:     relaygo.F("Hello from RelayAPI!"),
    Targets:     relaygo.F([]string{"mastodon"}),
    ScheduledAt: relaygo.F("now"),
})
PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .content("Hello from RelayAPI!")
    .addTarget("mastodon")
    .scheduledAt("now")
    .build());
curl -X POST https://api.relayapi.dev/v1/posts \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Hello from RelayAPI!",
    "targets": ["mastodon"],
    "scheduled_at": "now"
  }'

Status with Images (Up to 4)

const post = await client.posts.create({
  content: 'Photo gallery from today\'s hike!',
  targets: ['mastodon'],
  media: [
    { url: 'https://cdn.example.com/photo1.jpg', type: 'image' },
    { url: 'https://cdn.example.com/photo2.jpg', type: 'image' },
    { url: 'https://cdn.example.com/photo3.jpg', type: 'image' },
    { url: 'https://cdn.example.com/photo4.jpg', type: 'image' },
  ],
  scheduled_at: 'now',
});
post = client.posts.create(
    content='Photo gallery from today\'s hike!',
    targets=['mastodon'],
    media=[
        {'url': 'https://cdn.example.com/photo1.jpg', 'type': 'image'},
        {'url': 'https://cdn.example.com/photo2.jpg', 'type': 'image'},
        {'url': 'https://cdn.example.com/photo3.jpg', 'type': 'image'},
        {'url': 'https://cdn.example.com/photo4.jpg', 'type': 'image'},
    ],
    scheduled_at='now',
)
post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Content: relaygo.F("Photo gallery from today's hike!"),
    Targets: relaygo.F([]string{"mastodon"}),
    Media: relaygo.F([]relaygo.PostNewParamsMedia{
        {URL: relaygo.F("https://cdn.example.com/photo1.jpg"), Type: relaygo.F(relaygo.PostNewParamsMediaTypeImage)},
        {URL: relaygo.F("https://cdn.example.com/photo2.jpg"), Type: relaygo.F(relaygo.PostNewParamsMediaTypeImage)},
        {URL: relaygo.F("https://cdn.example.com/photo3.jpg"), Type: relaygo.F(relaygo.PostNewParamsMediaTypeImage)},
        {URL: relaygo.F("https://cdn.example.com/photo4.jpg"), Type: relaygo.F(relaygo.PostNewParamsMediaTypeImage)},
    }),
    ScheduledAt: relaygo.F("now"),
})
PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .content("Photo gallery from today's hike!")
    .addTarget("mastodon")
    .addMedia(PostCreateParams.Media.builder()
        .url("https://cdn.example.com/photo1.jpg")
        .type(PostCreateParams.Media.Type.IMAGE)
        .build())
    .addMedia(PostCreateParams.Media.builder()
        .url("https://cdn.example.com/photo2.jpg")
        .type(PostCreateParams.Media.Type.IMAGE)
        .build())
    .addMedia(PostCreateParams.Media.builder()
        .url("https://cdn.example.com/photo3.jpg")
        .type(PostCreateParams.Media.Type.IMAGE)
        .build())
    .addMedia(PostCreateParams.Media.builder()
        .url("https://cdn.example.com/photo4.jpg")
        .type(PostCreateParams.Media.Type.IMAGE)
        .build())
    .scheduledAt("now")
    .build());
curl -X POST https://api.relayapi.dev/v1/posts \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Photo gallery from today'\''s hike!",
    "targets": ["mastodon"],
    "media": [
      {"url": "https://cdn.example.com/photo1.jpg", "type": "image"},
      {"url": "https://cdn.example.com/photo2.jpg", "type": "image"},
      {"url": "https://cdn.example.com/photo3.jpg", "type": "image"},
      {"url": "https://cdn.example.com/photo4.jpg", "type": "image"}
    ],
    "scheduled_at": "now"
  }'

Status with Video

const post = await client.posts.create({
  content: 'Watch our latest update!',
  targets: ['mastodon'],
  media: [
    { url: 'https://cdn.example.com/video.mp4', type: 'video' },
  ],
  scheduled_at: 'now',
});
post = client.posts.create(
    content='Watch our latest update!',
    targets=['mastodon'],
    media=[
        {'url': 'https://cdn.example.com/video.mp4', 'type': 'video'},
    ],
    scheduled_at='now',
)
post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Content: relaygo.F("Watch our latest update!"),
    Targets: relaygo.F([]string{"mastodon"}),
    Media: relaygo.F([]relaygo.PostNewParamsMedia{
        {URL: relaygo.F("https://cdn.example.com/video.mp4"), Type: relaygo.F(relaygo.PostNewParamsMediaTypeVideo)},
    }),
    ScheduledAt: relaygo.F("now"),
})
PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .content("Watch our latest update!")
    .addTarget("mastodon")
    .addMedia(PostCreateParams.Media.builder()
        .url("https://cdn.example.com/video.mp4")
        .type(PostCreateParams.Media.Type.VIDEO)
        .build())
    .scheduledAt("now")
    .build());
curl -X POST https://api.relayapi.dev/v1/posts \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Watch our latest update!",
    "targets": ["mastodon"],
    "media": [
      {"url": "https://cdn.example.com/video.mp4", "type": "video"}
    ],
    "scheduled_at": "now"
  }'

Content Warning (Spoiler Text)

Add a content warning that hides the status behind a toggle.

const post = await client.posts.create({
  content: 'Detailed discussion of the season finale plot twists.',
  targets: ['mastodon'],
  scheduled_at: 'now',
  target_options: {
    mastodon: {
      spoiler_text: 'Season 3 finale spoilers',
    },
  },
});
post = client.posts.create(
    content='Detailed discussion of the season finale plot twists.',
    targets=['mastodon'],
    scheduled_at='now',
    target_options={
        'mastodon': {
            'spoiler_text': 'Season 3 finale spoilers',
        },
    },
)
post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Content:     relaygo.F("Detailed discussion of the season finale plot twists."),
    Targets:     relaygo.F([]string{"mastodon"}),
    ScheduledAt: relaygo.F("now"),
    TargetOptions: relaygo.F(map[string]interface{}{
        "mastodon": map[string]interface{}{
            "spoiler_text": "Season 3 finale spoilers",
        },
    }),
})
PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .content("Detailed discussion of the season finale plot twists.")
    .addTarget("mastodon")
    .scheduledAt("now")
    .targetOptions(PostCreateParams.TargetOptions.builder()
        .putAdditionalProperty("mastodon", JsonValue.from(Map.of(
            "spoiler_text", "Season 3 finale spoilers"
        )))
        .build())
    .build());
curl -X POST https://api.relayapi.dev/v1/posts \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Detailed discussion of the season finale plot twists.",
    "targets": ["mastodon"],
    "scheduled_at": "now",
    "target_options": {
      "mastodon": {
        "spoiler_text": "Season 3 finale spoilers"
      }
    }
  }'

Sensitive Media

Mark media as sensitive so it is hidden behind a warning.

const post = await client.posts.create({
  content: 'Street art from the city.',
  targets: ['mastodon'],
  media: [
    { url: 'https://cdn.example.com/art.jpg', type: 'image' },
  ],
  scheduled_at: 'now',
  target_options: {
    mastodon: {
      sensitive: true,
    },
  },
});
post = client.posts.create(
    content='Street art from the city.',
    targets=['mastodon'],
    media=[
        {'url': 'https://cdn.example.com/art.jpg', 'type': 'image'},
    ],
    scheduled_at='now',
    target_options={
        'mastodon': {
            'sensitive': True,
        },
    },
)
post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Content: relaygo.F("Street art from the city."),
    Targets: relaygo.F([]string{"mastodon"}),
    Media: relaygo.F([]relaygo.PostNewParamsMedia{
        {URL: relaygo.F("https://cdn.example.com/art.jpg"), Type: relaygo.F(relaygo.PostNewParamsMediaTypeImage)},
    }),
    ScheduledAt: relaygo.F("now"),
    TargetOptions: relaygo.F(map[string]interface{}{
        "mastodon": map[string]interface{}{
            "sensitive": true,
        },
    }),
})
PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .content("Street art from the city.")
    .addTarget("mastodon")
    .addMedia(PostCreateParams.Media.builder()
        .url("https://cdn.example.com/art.jpg")
        .type(PostCreateParams.Media.Type.IMAGE)
        .build())
    .scheduledAt("now")
    .targetOptions(PostCreateParams.TargetOptions.builder()
        .putAdditionalProperty("mastodon", JsonValue.from(Map.of(
            "sensitive", true
        )))
        .build())
    .build());
curl -X POST https://api.relayapi.dev/v1/posts \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Street art from the city.",
    "targets": ["mastodon"],
    "media": [
      {"url": "https://cdn.example.com/art.jpg", "type": "image"}
    ],
    "scheduled_at": "now",
    "target_options": {
      "mastodon": {
        "sensitive": true
      }
    }
  }'

Visibility Controls

Control who can see the post.

const post = await client.posts.create({
  content: 'Only my followers can see this.',
  targets: ['mastodon'],
  scheduled_at: 'now',
  target_options: {
    mastodon: {
      visibility: 'private',
    },
  },
});
post = client.posts.create(
    content='Only my followers can see this.',
    targets=['mastodon'],
    scheduled_at='now',
    target_options={
        'mastodon': {
            'visibility': 'private',
        },
    },
)
post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Content:     relaygo.F("Only my followers can see this."),
    Targets:     relaygo.F([]string{"mastodon"}),
    ScheduledAt: relaygo.F("now"),
    TargetOptions: relaygo.F(map[string]interface{}{
        "mastodon": map[string]interface{}{
            "visibility": "private",
        },
    }),
})
PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .content("Only my followers can see this.")
    .addTarget("mastodon")
    .scheduledAt("now")
    .targetOptions(PostCreateParams.TargetOptions.builder()
        .putAdditionalProperty("mastodon", JsonValue.from(Map.of(
            "visibility", "private"
        )))
        .build())
    .build());
curl -X POST https://api.relayapi.dev/v1/posts \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Only my followers can see this.",
    "targets": ["mastodon"],
    "scheduled_at": "now",
    "target_options": {
      "mastodon": {
        "visibility": "private"
      }
    }
  }'

Reply to a Status

const post = await client.posts.create({
  content: '@user Great point! Here\'s my take...',
  targets: ['mastodon'],
  scheduled_at: 'now',
  target_options: {
    mastodon: {
      in_reply_to_id: '110123456789012345',
    },
  },
});
post = client.posts.create(
    content='@user Great point! Here\'s my take...',
    targets=['mastodon'],
    scheduled_at='now',
    target_options={
        'mastodon': {
            'in_reply_to_id': '110123456789012345',
        },
    },
)
post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Content:     relaygo.F("@user Great point! Here's my take..."),
    Targets:     relaygo.F([]string{"mastodon"}),
    ScheduledAt: relaygo.F("now"),
    TargetOptions: relaygo.F(map[string]interface{}{
        "mastodon": map[string]interface{}{
            "in_reply_to_id": "110123456789012345",
        },
    }),
})
PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .content("@user Great point! Here's my take...")
    .addTarget("mastodon")
    .scheduledAt("now")
    .targetOptions(PostCreateParams.TargetOptions.builder()
        .putAdditionalProperty("mastodon", JsonValue.from(Map.of(
            "in_reply_to_id", "110123456789012345"
        )))
        .build())
    .build());
curl -X POST https://api.relayapi.dev/v1/posts \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "@user Great point! Here'\''s my take...",
    "targets": ["mastodon"],
    "scheduled_at": "now",
    "target_options": {
      "mastodon": {
        "in_reply_to_id": "110123456789012345"
      }
    }
  }'

Media Requirements

Images

PropertyRequirement
Max per post4
FormatsJPEG, PNG, GIF, WebP
Max file size16 MB

Videos

PropertyRequirement
Max per post1
FormatsMP4, WebM, MOV
Max file size99 MB
ProcessingAsynchronous (may take a few seconds)

target_options Fields

All fields go inside target_options.mastodon on your post request.

FieldTypeDefaultDescription
contentstringOverride post content for Mastodon specifically
mediaobject[]Override media for Mastodon specifically
visibilitystring"public"Post visibility: "public", "unlisted", "private" (followers only), or "direct" (mentioned users only)
spoiler_textstringContent warning text displayed above the post
sensitivebooleanfalseMark media as sensitive (hidden behind a warning)
in_reply_to_idstringMastodon status ID to reply to
instance_urlstringOverride the instance URL (defaults to the one stored during account connection)

Visibility Reference

VisibilityDescription
publicVisible to everyone, appears on public timelines
unlistedVisible to everyone, but hidden from public timelines
privateVisible to followers only
directVisible only to mentioned users (like a DM)

Common Errors

ErrorCauseFix
Content too longText exceeds the instance's character limitShorten the content. Use target_options.mastodon.content for a shorter version.
Media upload failedMedia URL inaccessible or unsupported formatUse a direct HTTPS URL with a supported format (JPEG, PNG, GIF, WebP, MP4, WebM, MOV).
Media processing timeoutVideo or GIF took too long to processTry a smaller or shorter video file.
Too many mediaMore than 4 images or more than 1 videoReduce to 4 images or 1 video per post.

Known Quirks

  • Instance-specific limits — the 500-character default can vary. Some instances allow 5,000+ characters.
  • 4 images OR 1 video — you cannot mix images and videos in the same post.
  • Video processing is asynchronous — uploads return immediately but the media may take a few seconds to be ready.
  • Alt text support — images support alt text descriptions for accessibility (passed via media alt field).
  • Federated delivery — posts are distributed to other Fediverse instances, which may take a moment.
  • Content warnings are a strong community norm on Mastodon — consider using spoiler_text for sensitive topics.
  • Direct messages are statuses"direct" visibility creates a DM visible only to mentioned users.

Automations

Mastodon automation uses per-instance URLs (stored on socialAccounts.metadata.instance_url). DMs are just statuses with visibility: direct addressed to @user.

Triggers

TypeFires on
mastodon_mention@-mention (also used for direct DMs)
mastodon_replyReply to your status
mastodon_boostSomeone boosts your status
mastodon_followNew follower
mastodon_favouriteSomeone favourites your status

Send nodes

NodeEndpointRequired fields
mastodon_replyPOST /api/v1/statusestext, in_reply_to_id, optional visibility (default public)
mastodon_favouritePOST /api/v1/statuses/{id}/favouritestatus_id
mastodon_boostPOST /api/v1/statuses/{id}/reblogstatus_id
mastodon_send_dmPOST /api/v1/statuses with visibility: directtext

Found something wrong? Help us improve this page.

On this page

Submit an Issue
Requires a GitHub account.View repo