RelayAPI

Twitter/X API

Schedule and automate Twitter/X posts with RelayAPI — tweets, threads, polls, images, videos, GIFs, and reply settings.

Quick Reference

PropertyValue
Platform keytwitter
Auth methodOAuth 2.0
Character limit280 (free) / 25,000 (Premium)
Images per post4 (or 1 GIF)
Videos per post1
Image formatsJPEG, PNG, WebP, GIF
Image max size5 MB (images), 15 MB (GIFs)
Video formatsMP4, MOV
Video max size512 MB
Video max duration140 seconds
Post typesTweet, Thread, Reply, Poll
SchedulingYes
AnalyticsYes

SDK sourceTypeScript · Python · Go · Java · REST API

Before You Start

Twitter has a strict 280 character limit for free accounts (25,000 for Premium). URLs always count as 23 characters regardless of actual length, and emojis count as 2 characters. If you are cross-posting from platforms with higher limits (LinkedIn at 3,000, Facebook at 63,000), use target_options.twitter.content to provide a shorter version or your post will fail. Duplicate tweets are rejected by Twitter, even very similar content.

Quick Start

Post a tweet immediately:

import Relay from '@relayapi/sdk';

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

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

client = Relay()
post = client.posts.create(
    content='Hello from RelayAPI!',
    targets=['twitter'],
    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{"twitter"}),
    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("twitter")
    .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": ["twitter"],
    "scheduled_at": "now"
  }'

Content Types

Text Tweet

Simple text-only tweet. 280 character limit for free accounts, 25,000 for Premium.

const post = await client.posts.create({
  content: 'Just shipped a new feature! Check it out.',
  targets: ['twitter'],
  scheduled_at: 'now',
});
post = client.posts.create(
    content='Just shipped a new feature! Check it out.',
    targets=['twitter'],
    scheduled_at='now',
)
post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Content:     relaygo.F("Just shipped a new feature! Check it out."),
    Targets:     relaygo.F([]string{"twitter"}),
    ScheduledAt: relaygo.F("now"),
})
PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .content("Just shipped a new feature! Check it out.")
    .addTarget("twitter")
    .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": "Just shipped a new feature! Check it out.",
    "targets": ["twitter"],
    "scheduled_at": "now"
  }'

Tweet with Images

Up to 4 images per tweet. Cannot mix images and videos. Cannot mix images and GIFs.

const post = await client.posts.create({
  content: 'Check out these photos!',
  targets: ['twitter'],
  media: [
    { url: 'https://cdn.example.com/photo1.jpg', type: 'image' },
    { url: 'https://cdn.example.com/photo2.jpg', type: 'image' },
  ],
  scheduled_at: 'now',
});
post = client.posts.create(
    content='Check out these photos!',
    targets=['twitter'],
    media=[
        {'url': 'https://cdn.example.com/photo1.jpg', 'type': 'image'},
        {'url': 'https://cdn.example.com/photo2.jpg', 'type': 'image'},
    ],
    scheduled_at='now',
)
post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Content: relaygo.F("Check out these photos!"),
    Targets: relaygo.F([]string{"twitter"}),
    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)},
    }),
    ScheduledAt: relaygo.F("now"),
})
PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .content("Check out these photos!")
    .addTarget("twitter")
    .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())
    .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": "Check out these photos!",
    "targets": ["twitter"],
    "media": [
      {"url": "https://cdn.example.com/photo1.jpg", "type": "image"},
      {"url": "https://cdn.example.com/photo2.jpg", "type": "image"}
    ],
    "scheduled_at": "now"
  }'

Tweet with Video

Single video per tweet. MP4 or MOV, up to 512 MB, max 140 seconds.

const post = await client.posts.create({
  content: 'Watch this!',
  targets: ['twitter'],
  media: [
    { url: 'https://cdn.example.com/video.mp4', type: 'video' },
  ],
  scheduled_at: 'now',
});
post = client.posts.create(
    content='Watch this!',
    targets=['twitter'],
    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 this!"),
    Targets: relaygo.F([]string{"twitter"}),
    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 this!")
    .addTarget("twitter")
    .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 this!",
    "targets": ["twitter"],
    "media": [
      {"url": "https://cdn.example.com/video.mp4", "type": "video"}
    ],
    "scheduled_at": "now"
  }'

Tweet with GIF

1 GIF per tweet (consumes all 4 image slots). Max 15 MB, 1280 x 1080 px. Auto-plays in timeline.

const post = await client.posts.create({
  content: 'Check this out!',
  targets: ['twitter'],
  media: [
    { url: 'https://cdn.example.com/animation.gif', type: 'gif' },
  ],
  scheduled_at: 'now',
});
post = client.posts.create(
    content='Check this out!',
    targets=['twitter'],
    media=[
        {'url': 'https://cdn.example.com/animation.gif', 'type': 'gif'},
    ],
    scheduled_at='now',
)
post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Content: relaygo.F("Check this out!"),
    Targets: relaygo.F([]string{"twitter"}),
    Media: relaygo.F([]relaygo.PostNewParamsMedia{
        {URL: relaygo.F("https://cdn.example.com/animation.gif"), Type: relaygo.F(relaygo.PostNewParamsMediaTypeGif)},
    }),
    ScheduledAt: relaygo.F("now"),
})
PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .content("Check this out!")
    .addTarget("twitter")
    .addMedia(PostCreateParams.Media.builder()
        .url("https://cdn.example.com/animation.gif")
        .type(PostCreateParams.Media.Type.GIF)
        .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": "Check this out!",
    "targets": ["twitter"],
    "media": [
      {"url": "https://cdn.example.com/animation.gif", "type": "gif"}
    ],
    "scheduled_at": "now"
  }'

Thread (Multi-Tweet)

Create Twitter threads with multiple connected tweets using target_options.twitter.thread. Each item becomes a reply to the previous tweet and can have its own content and media.

const post = await client.posts.create({
  targets: ['twitter'],
  scheduled_at: 'now',
  target_options: {
    twitter: {
      thread: [
        { content: '1/ Starting a thread about API design...' },
        { content: '2/ First, always use proper HTTP methods.' },
        {
          content: '3/ Second, version your APIs from day one.',
          media: [{ url: 'https://cdn.example.com/diagram.png', type: 'image' }],
        },
        { content: '4/ Finally, document everything! /end' },
      ],
    },
  },
});
post = client.posts.create(
    targets=['twitter'],
    scheduled_at='now',
    target_options={
        'twitter': {
            'thread': [
                {'content': '1/ Starting a thread about API design...'},
                {'content': '2/ First, always use proper HTTP methods.'},
                {
                    'content': '3/ Second, version your APIs from day one.',
                    'media': [{'url': 'https://cdn.example.com/diagram.png', 'type': 'image'}],
                },
                {'content': '4/ Finally, document everything! /end'},
            ],
        },
    },
)
post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Targets:     relaygo.F([]string{"twitter"}),
    ScheduledAt: relaygo.F("now"),
    TargetOptions: relaygo.F(map[string]interface{}{
        "twitter": map[string]interface{}{
            "thread": []map[string]interface{}{
                {"content": "1/ Starting a thread about API design..."},
                {"content": "2/ First, always use proper HTTP methods."},
                {"content": "3/ Second, version your APIs from day one.",
                    "media": []map[string]interface{}{
                        {"url": "https://cdn.example.com/diagram.png", "type": "image"},
                    }},
                {"content": "4/ Finally, document everything! /end"},
            },
        },
    }),
})
PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .addTarget("twitter")
    .scheduledAt("now")
    .targetOptions(PostCreateParams.TargetOptions.builder()
        .putAdditionalProperty("twitter", JsonValue.from(Map.of(
            "thread", List.of(
                Map.of("content", "1/ Starting a thread about API design..."),
                Map.of("content", "2/ First, always use proper HTTP methods."),
                Map.of("content", "3/ Second, version your APIs from day one.",
                    "media", List.of(Map.of("url", "https://cdn.example.com/diagram.png", "type", "image"))),
                Map.of("content", "4/ Finally, document everything! /end")
            )
        )))
        .build())
    .build());
curl -X POST https://api.relayapi.dev/v1/posts \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "targets": ["twitter"],
    "scheduled_at": "now",
    "target_options": {
      "twitter": {
        "thread": [
          {"content": "1/ Starting a thread about API design..."},
          {"content": "2/ First, always use proper HTTP methods."},
          {"content": "3/ Second, version your APIs from day one.",
           "media": [{"url": "https://cdn.example.com/diagram.png", "type": "image"}]},
          {"content": "4/ Finally, document everything! /end"}
        ]
      }
    }
  }'

Each thread item has its own 280-character limit (free accounts). Thread replies are posted sequentially since each one needs the parent tweet ID.

Reply to Tweet

Reply to an existing tweet using reply_to. Provide the tweet ID of the tweet you want to reply to.

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

Reply Settings

Control who can reply to your tweet using reply_settings.

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

reply_to cannot be combined with reply_settings. For threads, reply settings apply to the first tweet only.

Poll

Create a tweet with a poll using target_options.twitter.poll. Polls support 2-4 options (each up to 25 characters) and a duration from 5 minutes to 7 days.

const post = await client.posts.create({
  content: 'What should we build next?',
  targets: ['twitter'],
  scheduled_at: 'now',
  target_options: {
    twitter: {
      poll: {
        options: ['Dark mode', 'New analytics', 'More integrations'],
        duration_minutes: 1440,
      },
    },
  },
});
post = client.posts.create(
    content='What should we build next?',
    targets=['twitter'],
    scheduled_at='now',
    target_options={
        'twitter': {
            'poll': {
                'options': ['Dark mode', 'New analytics', 'More integrations'],
                'duration_minutes': 1440,
            },
        },
    },
)
post, err := client.Posts.New(context.TODO(), relaygo.PostNewParams{
    Content:     relaygo.F("What should we build next?"),
    Targets:     relaygo.F([]string{"twitter"}),
    ScheduledAt: relaygo.F("now"),
    TargetOptions: relaygo.F(map[string]interface{}{
        "twitter": map[string]interface{}{
            "poll": map[string]interface{}{
                "options":          []string{"Dark mode", "New analytics", "More integrations"},
                "duration_minutes": 1440,
            },
        },
    }),
})
PostCreateResponse post = client.posts().create(PostCreateParams.builder()
    .content("What should we build next?")
    .addTarget("twitter")
    .scheduledAt("now")
    .targetOptions(PostCreateParams.TargetOptions.builder()
        .putAdditionalProperty("twitter", JsonValue.from(Map.of(
            "poll", Map.of(
                "options", List.of("Dark mode", "New analytics", "More integrations"),
                "duration_minutes", 1440
            )
        )))
        .build())
    .build());
curl -X POST https://api.relayapi.dev/v1/posts \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "What should we build next?",
    "targets": ["twitter"],
    "scheduled_at": "now",
    "target_options": {
      "twitter": {
        "poll": {
          "options": ["Dark mode", "New analytics", "More integrations"],
          "duration_minutes": 1440
        }
      }
    }
  }'

Polls cannot be combined with media attachments or threads. Each poll option must be 1-25 characters. Duration must be between 5 and 10,080 minutes (7 days).

Media Requirements

Images

PropertyRequirement
Max per tweet4
FormatsJPEG, PNG, WebP
Max file size5 MB
Min dimensions4 x 4 px
Max dimensions8192 x 8192 px
Recommended1200 x 675 px (16:9)

Aspect Ratios

TypeRatioDimensions
Landscape16:91200 x 675 px
Square1:11200 x 1200 px
Portrait4:51080 x 1350 px

GIFs

PropertyRequirement
Max per tweet1 (consumes all 4 image slots)
Max file size15 MB
Max dimensions1280 x 1080 px
BehaviorAuto-plays in timeline

Videos

PropertyRequirement
Max per tweet1
FormatsMP4, MOV
Max file size512 MB
Max duration140 seconds (2 min 20 sec)
Min duration0.5 seconds
Min dimensions32 x 32 px
Max dimensions1920 x 1200 px
Frame rate40 fps max
Bitrate25 Mbps max
PropertyRecommended
Resolution1280 x 720 px (720p)
Aspect ratio16:9 (landscape) or 1:1 (square)
Frame rate30 fps
CodecH.264
AudioAAC, 128 kbps

target_options Fields

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

FieldTypeDescription
contentstringOverride post content for Twitter specifically
mediaobject[]Override media for Twitter specifically
threadobject[]Array of {content, media?} for multi-tweet threads. Each item becomes a reply to the previous tweet.
reply_tostringTweet ID to reply to. The published tweet appears as a reply in that tweet's thread.
reply_settingsstringWho can reply: "following", "mentioned_users", "subscribers", "verified". Cannot be combined with reply_to.
pollobjectPoll with options (array of 2-4 strings, each 1-25 chars) and duration_minutes (5-10,080). Cannot be combined with media or threads.

Character Counting

Twitter uses weighted character counting:

  • URLs always count as 23 characters regardless of actual length (t.co shortening)
  • Emojis count as 2 characters each
  • All other characters count as 1

A tweet with a 200-character URL still only uses 23 of your 280-character budget. But a tweet with 260 characters of text plus one URL would be 283 characters (260 + 23), exceeding the limit.

Common Errors

ErrorCauseFix
Character limit exceededContent exceeds 280 chars (free account)Use target_options.twitter.content to provide a shorter version. Remember: URLs = 23 chars, emojis = 2 chars.
Duplicate contentSame or very similar text posted recentlyModify the text, even slightly. Twitter rejects near-duplicate tweets.
Rate limit hitToo many posts in a short windowSpace posts at least 4 minutes apart. Limit is ~300 tweets per 3-hour window.
Media processing failedUnsupported format or corrupt fileVerify media format and file integrity.
Missing tweet.write scopeOAuth token lacks required permissionsReconnect the account with all required scopes.
Token expiredOAuth access was revoked or expiredReconnect the account via the dashboard or Connect API.
INVALID_POLLPoll validation failedEnsure 2-4 options (each 1-25 chars), duration 5-10,080 minutes, and no media or thread attached.

Known Quirks

  • Duplicate tweets are rejected — even very similar content gets blocked. Modify text meaningfully between posts.
  • URLs always count as 23 characters regardless of actual length due to t.co shortening.
  • Cannot mix images and videos in the same tweet.
  • Cannot mix images and GIFs in the same tweet.
  • GIF consumes all 4 image slots — you cannot attach a GIF alongside other images.
  • Free vs Premium character limits — free accounts get 280 chars, Premium gets 25,000. You must know the account type.
  • Rate limit — approximately 300 tweets per 3-hour window for creation.
  • Thread replies must be posted sequentially since each reply needs the parent tweet ID.
  • Emojis count as 2 characters — a tweet with 140 emojis uses all 280 characters.
  • Polls cannot have media or threads — polls are mutually exclusive with media attachments and thread items.

Automations

X (Twitter) is a Tier 1 automation platform with tier-gated webhooks (Pay-per-Use: 3 DM conversation subs + 1 webhook).

Triggers

TypeFires on
twitter_dmInbound DM
twitter_mention@-mention
twitter_replyReply to your tweet
twitter_followNew follower
twitter_likeSomeone likes your tweet
twitter_retweetSomeone retweets
twitter_quoteSomeone quotes your tweet

Send nodes

Base: https://api.x.com/2.

NodeEndpointRequired fields
twitter_send_dmPOST /dm_conversations/with/{user}/messagestext
twitter_send_dm_mediaPOST /dm_conversations/with/{user}/messagesmedia_id (upload first via media endpoint), optional text
twitter_reply_to_tweetPOST /tweets with reply.in_reply_to_tweet_idtext, tweet_id
twitter_like_tweetPOST /users/{id}/likestweet_id
twitter_retweetPOST /users/{id}/retweetstweet_id

Found something wrong? Help us improve this page.

On this page

Submit an Issue
Requires a GitHub account.View repo