ffmpegvideo-encodingsocial-media-automation

X (Twitter) Video Specs That Survive the Upload (and the API)

··11 min read
X (Twitter) Video Specs That Survive the Upload (and the API)

Twitter video specs for a programmatic post are stricter than the tables you find when you search for them, and the numbers are different too. Four rules decide whether X's media API takes your file:

  • Video has to be H.264 High Profile and audio has to be AAC LC, because X's media docs reject HEVC, VP9, AV1 and High-Efficiency AAC at upload instead of transcoding them for you.
  • The X video size limit depends on media_category: tweet_video and amplify_video allow 20 minutes and 8 GB from a default account and 125 minutes and 16 GB from a Premium or verified one, while the famous 140 seconds and 512 MB is the dm_video cap.
  • Playback resolution follows the posting account: a subscribed account plays back at 1080p, an unsubscribed one at 720p, whatever you hand over.
  • Chunked uploads need APPEND bodies held near 4.5 MB rather than the documented 5 MB, with segment_index starting at 0 and incrementing on every chunk.

Who needs an X encode preset, and why the spec tables don't produce one

Conventional wisdom says an X-ready file is 1280x720, 16:9, H.264 plus AAC, under 512 MB and under 140 seconds. Export that from any editor and a manual upload in the composer works fine, which is why every creator-marketing page agrees on it. But the thing that fails an automated post isn't resolution, it's the two fields nobody tabulates: the H.264 profile and the AAC profile.

X's own media best practices name High Profile and AAC LC specifically. A Main-profile H.264 file satisfies every spec table on page one and still comes back rejected. So does an hvc1 HEVC master out of a phone or an AI generator, and so does an HE-AAC track, which some encoders pick on their own at low bitrates. The 512 MB and 140-second figures come from the legacy v1.1 Media Best Practices page, and the v1.1 media/upload endpoints were sunset on June 9, 2025 after three deadline extensions.

Post video to X from code, from n8n, or from Make, and this is the error no spec table explains. Automation builders hit the second half of it immediately: the n8n X node won't chunk, so anything over 140 seconds needs the upload written by hand.

The job, in the order that works

Three of the four inputs to this job are facts about your account, not your file: whose credentials the pipeline uses, whether that account is subscribed, and the media_category you send at INIT. The fourth is your encoder, either an FFmpeg build with libx264 and the native aac encoder or an API key. Each step below produces something the next one checks, so every failure lands in the step that caused it.

  1. Probe the master: ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,profile,pix_fmt,width,height,r_frame_rate -of default=noprint_wrappers=1 master.mov. If codec_name reads hevc, or profile reads Main or Baseline, you need a re-encode and not a remux.
  2. Probe the audio the same way with -select_streams a:0 -show_entries stream=codec_name,profile,sample_rate. profile=HE-AAC is a rejection waiting to happen.
  3. Pick the encode target from the posting account: 1920x1080 at 30 fps for a subscribed account, 1280x720 at 30 fps if it's unsubscribed, since 720p is all anyone will see.
  4. Run the encode below. It writes High Profile, level 4.0, 4:2:0, a closed two-second GOP and an AAC LC track at 48 kHz.
  5. Re-probe the output and confirm profile=High, pix_fmt=yuv420p and audio profile=LC before you spend bandwidth on it.
  6. Upload chunked: POST /2/media/upload/initialize with total_bytes and media_category, then POST /2/media/upload/{id}/append per chunk, then POST /2/media/upload/{id}/finalize. Poll the status endpoint while processing_info comes back.

The encode that satisfies X on both tiers

One command covers both tiers: the constraints X enforces (profile, level, chroma, GOP, audio profile) don't change with Premium status. Only frame size and duration do.

ffmpeg -i master.mov \
  -c:v libx264 -profile:v high -level:v 4.0 \
  -pix_fmt yuv420p -vf "setsar=1" \
  -r 30 -fps_mode cfr \
  -g 60 -keyint_min 60 -sc_threshold 0 \
  -b:v 8M -maxrate 20M -bufsize 40M \
  -c:a aac -profile:a aac_low -b:a 192k -ar 48000 -ac 2 \
  -movflags +faststart \
  x-ready.mp4

-profile:v high and -level:v 4.0 are the pair that matters. A developer debugging exactly this found that profile high, level 4.0 and yuv420p turned a rejected H.264 file into an accepted one, and X re-encodes the stream afterward anyway while staying strict about what it takes in. Level 4.0 is also the tightest legal level for 1080p30: at 60 fps you need 4.2.

-g 60 with -keyint_min 60 and -sc_threshold 0 gives a fixed two-second closed GOP, which is what the format requirements call for. The scheduling tool Swat.io keeps the most complete public list of those requirements: fixed frame rate, progressive closed GOP of 2 to 5 seconds, 4:2:0 chroma, 1:1 pixel aspect, 60 fps maximum, aspect ratio between 1:3 and 3:1, at least 5,000 kbps video and 128 kbps audio, and AAC LC at 48 kHz or lower. The -b:v 8M plus -maxrate 20M pair clears the 5,000 kbps floor and stays under the 25 Mbps ceiling.

-profile:a aac_low isn't decoration. The native FFmpeg aac encoder defaults to LC, but a pipeline that touches libfdk_aac or an upstream export preset can hand you HE-AAC without saying so, and that file fails upload while passing every spec table. Also, -fps_mode cfr replaced -vsync cfr, and the two aren't interchangeable in every graph: the drop-in swap has edges.

The same encode as one API call

Running that command yourself means an FFmpeg build you keep current, a box that survives a 125-minute Premium upload, and a queue that doesn't time out mid-encode. To skip all three, send the same flags to FFmpeg Micro: upload the master to a presigned URL, confirm it, then post the job.

curl -X POST https://api.ffmpeg-micro.com/v1/transcodes \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "inputs": [{ "url": "gs://<YOUR_BUCKET>/1762000000-master.mov" }],
    "outputFormat": "mp4",
    "options": [
      { "option": "-c:v",       "argument": "libx264" },
      { "option": "-profile:v", "argument": "high" },
      { "option": "-level:v",   "argument": "4.0" },
      { "option": "-pix_fmt",   "argument": "yuv420p" },
      { "option": "-r",         "argument": "30" },
      { "option": "-g",         "argument": "60" },
      { "option": "-b:v",       "argument": "8M" },
      { "option": "-maxrate",   "argument": "20M" },
      { "option": "-bufsize",   "argument": "40M" },
      { "option": "-c:a",       "argument": "aac" },
      { "option": "-profile:a", "argument": "aac_low" },
      { "option": "-b:a",       "argument": "192k" },
      { "option": "-ar",        "argument": "48000" },
      { "option": "-ac",        "argument": "2" },
      { "option": "-movflags",  "argument": "+faststart" }
    ]
  }'

That returns a job id with status: "pending". Poll GET /v1/transcodes/{id} until it reads completed, then GET /v1/transcodes/{id}/download for a signed URL. Billing is per second of input with no rounding up to the minute: a three-minute master costs 18 tokens, the free tier's 200 tokens cover about 33 minutes of input, and failed jobs are never billed. Status checks and downloads are free. The same call works from n8n, Make, and Zapier, which is where most of this traffic originates.

Where the chunked upload's byte math goes wrong

The chunked flow is unforgiving about arithmetic: total_bytes at INIT has to equal the sum of the bytes you actually APPEND. Every common failure violates that one equation.

Hold each chunk near 4.5 MB rather than the documented 5 MB. Developers reported on X's own forum that multipart form encoding overhead pushes a full 5 MB payload past an undocumented request-body limit, so the usable payload is smaller than the stated ceiling.

segment_index is an ordered index that starts at 0 and increments on every APPEND. It's documented as 0 to 999 inclusive, which developers have flagged as stale since the 16 GB increase, and the arithmetic says why: 1,000 chunks at 4.5 MB is about 4.4 GB, well short of the 8 GB default cap and nowhere near 16 GB on Premium.

Segments do not add up to provided total file size is the FINALIZE error for that mismatch, and it has two usual causes. Either segment_index never incremented, so later chunks overwrote earlier ones, or total_bytes was computed on a base64 string while the bytes sent were binary. Base64 inflates by roughly 33%. If your pipeline reads the file through a JSON hop, check that one first.

Confirm the file before X does

One probe on the output tells you whether the encode did what you asked.

ffprobe -v error -show_entries stream=codec_name,profile,level,pix_fmt \
  -of default=noprint_wrappers=1 x-ready.mp4

You want codec_name=h264 with profile=High and level=40 on the video stream, pix_fmt=yuv420p, and codec_name=aac with profile=LC on the audio stream. Anything else and the upload fails no matter how the chunking goes. A successful FINALIZE returns processing_info with state: pending or in_progress for video; keep polling until it reads succeeded, since a media id still processing can't be attached to a post.

Run this against the account you're posting from

Most broken X video pipelines fail on an assumption about the account rather than a mistake in the command. Walk these five before you debug anything else.

  1. Check whose credentials the pipeline uses: unsubscribed means 720p playback and the lower duration ceiling, pristine 1080p master or not.
  2. Check the media_category your INIT call sends, since dm_video still carries the 140-second and 512 MB limits.
  3. Check the master's video profile, not just its codec. Container tags matter in the same quiet way a mislabeled color tag does.
  4. Check the audio profile reads LC at 48,000 Hz or lower.
  5. Check that your chunk loop increments segment_index and that total_bytes came from the binary file on disk.

FAQ

Is the Twitter video size limit 512 MB or 8 GB?

512 MB and 8 GB are both real limits, for different things. 512 MB and 140 seconds is the dm_video cap in X's media API documentation. tweet_video and amplify_video allow 20 minutes and 8 GB from a default account, 125 minutes and 16 GB from a Premium or verified one. The 512 MB figure is on nearly every spec page because it was the v1.1 limit, retired in June 2025.

Will X transcode my HEVC file if I upload it anyway?

X rejects HEVC, VP9 and AV1 at upload rather than converting them. X's media best practices call for H.264 High Profile with AAC LC audio, so an hvc1 master from an iPhone or an AI video model has to be re-encoded first. The same goes for High-Efficiency AAC audio, which isn't supported.

Do I need chunked upload for a short 20-second clip?

Chunked upload is the path for video in the v2 media API regardless of clip length, and it's what the n8n X node doesn't implement. A 20-second clip at the bitrates above lands around 20 MB, which is five APPEND calls at 4.5 MB each with segment_index running 0 through 4.

If the account isn't subscribed, is encoding 1080p wasted compute?

Encoding 1080p for an unsubscribed account mostly is wasted, since unsubscribed viewers get 720p playback no matter what you upload. Encoding 1280x720 at 30 fps instead cuts output size roughly in half and removes a downscale from X's side. Keep the 1080p master for everywhere else and branch the X encode on who's posting it, the same way you'd branch a 4K master down to 1080x1920 for TikTok.

Why does the same file upload fine in the composer but fail from the API?

The composer and the API apply different strictness. Hand uploads go through a path that tolerates more variation in the source file, while the media endpoints validate profile, audio codec and byte counts up front. A Main-profile or HE-AAC file that posts fine from the web composer is exactly the kind of file the API turns away.

Branching the encode on the posting account, then running the chunk loop correctly, is the unglamorous half of social media automation that no spec table covers. Send the encode half to the API and the upload loop is all you maintain: Sign up free for a key, no servers to run and no FFmpeg to install, callable from your own code, from n8n, Make and Zapier, or from an AI agent, with a free tier for your first X encode.

About Javid Jamae

Founder & CEO at FFmpeg Micro

Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.

Software EngineeringVideo ProcessingFFmpegCloud ArchitectureAPI DesignAutomation

Ready to process videos at scale?

Start using FFmpeg Micro's simple API today. No infrastructure required.

Get Started Free