Uploading Media
The API offers two ways to upload media to your Content Library:
- From a URL:
uploadMediaFromUrl, when your file is already hosted at a public URL. - From a local file:
startMediaUpload+completeMediaUpload, when the file lives on disk.
Images, videos, and GIFs are supported. Direct uploads are capped at 500 MiB per file.
Uploading Media from a URL
Adds a file to your Content Library from a public URL.
mutation uploadMediaFromUrl(
$url: String!
$name: String
$mediaLabelIds: [ID]
) {
uploadMediaFromUrl(
url: $url
name: $name
mediaLabelIds: $mediaLabelIds
) {
success
media {
id
name
url
mimeType
size
}
errors {
field
code
message
}
}
}
{
"url": "https://example.com/images/hero.jpg",
"name": "campaign-hero.jpg",
"mediaLabelIds": ["1182", "1235"]
}
{
"data": {
"uploadMediaFromUrl": {
"success": true,
"media": {
"id": "1001",
"name": "campaign-hero.jpg",
"url": "https://cdn.example.com/1a2b3c4d/",
"mimeType": "image/jpeg",
"size": 482912
},
"errors": []
}
}
}
url.Direct Upload
When your file is on disk, upload it in three steps.
Start the upload
Call startMediaUpload with the exact file size in bytes and the file's mime type. It returns an uploadUrl and an uploadToken.
mutation startMediaUpload($size: BigInt!, $mimeType: String!) {
startMediaUpload(size: $size, mimeType: $mimeType) {
success
uploadToken
uploadUrl
expiresAt
errors {
field
code
message
}
}
}
{
"size": 2048576,
"mimeType": "image/jpeg"
}
{
"data": {
"startMediaUpload": {
"success": true,
"uploadToken": "b3f1c2a4-9d5e-4c7b-8a1f-2e6d0c9a7b12",
"uploadUrl": "https://uploads.example.com/...&signature=...",
"expiresAt": "2025-02-01T15:30:00Z",
"errors": []
}
}
}
PUT in the next step must send a matching Content-Length.image/png, image/jpeg, image/webp, image/gif, image/heic, video/mp4, or video/quicktime. The upload URL is tied to this exact value, so the PUT in the next step must send it as the Content-Type header, verbatim.Upload the file
Send the raw file bytes to uploadUrl with an HTTP PUT. Three headers are required:
Content-Lengthmust match thesizeyou passed tostartMediaUpload. The URL is tied to the declared size, and a mismatch is rejected.Content-Typemust match themimeTypeyou passed tostartMediaUpload, verbatim. This is what makes the file's type detectable once it lands in your Content Library.If-None-Match: *must be sent exactly as shown. The URL is signed with it, and requests without it are rejected.
curl --request PUT \
--upload-file ./campaign-hero.jpg \
--header 'Content-Length: 2048576' \
--header 'Content-Type: image/jpeg' \
--header 'If-None-Match: *' \
"https://uploads.example.com/...&signature=..."
import requests
with open('./campaign-hero.jpg', 'rb') as f:
response = requests.put(
'https://uploads.example.com/...&signature=...',
data=f,
headers={
'Content-Type': 'image/jpeg',
'If-None-Match': '*',
},
)
response.raise_for_status()
import { readFile } from 'node:fs/promises';
const file = await readFile('./campaign-hero.jpg');
const response = await fetch('https://uploads.example.com/...&signature=...', {
method: 'PUT',
headers: {
'Content-Type': 'image/jpeg',
'If-None-Match': '*',
},
body: file,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
curl with --upload-file) set Content-Length automatically from the file. Only set it by hand if your client doesn't.uploadUrl is valid until expiresAt, roughly an hour after it's issued. Upload the file and call completeMediaUpload before then, or start over.uploadUrl accepts one successful upload. If the transfer fails partway through, just retry the same PUT. A 412 Precondition Failed response means a file was already uploaded with this URL. No need to retry, move on to completeMediaUpload.Complete the upload
Once you finished uploading the file, you must call completeMediaUpload with the uploadToken and a display name. This adds the uploaded file to your Content Library and returns the created media.
mutation completeMediaUpload(
$uploadToken: String!
$name: String!
$mediaLabelIds: [ID]
) {
completeMediaUpload(
uploadToken: $uploadToken
name: $name
mediaLabelIds: $mediaLabelIds
) {
success
media {
id
name
url
mimeType
size
}
errors {
field
code
message
}
}
}
{
"uploadToken": "b3f1c2a4-9d5e-4c7b-8a1f-2e6d0c9a7b12",
"name": "campaign-hero.jpg",
"mediaLabelIds": ["12"]
}
{
"data": {
"completeMediaUpload": {
"success": true,
"media": {
"id": "102",
"name": "campaign-hero.jpg",
"url": "https://cdn.example.com/5e6f7a8b/",
"mimeType": "image/jpeg",
"size": 2048576
},
"errors": []
}
}
}
startMediaUpload.name if you reference the file by name when scheduling.completeMediaUpload transfers the uploaded file into your Content Library, so the request takes longer for bigger files. Large videos may not finish in time, in which case the request fails with an upload timeout error or an HTTP 504. When that happens, wait a few seconds and call completeMediaUpload again with the same uploadToken. Retry until it returns success: true or a different error. Retrying is safe: if an earlier attempt already added the file to your library, the same media is returned instead of a duplicate.completeMediaUpload with the same token again returns the same media rather than creating a new one. Call startMediaUpload again for the next file. If a completion attempt fails, you can retry it while the upload is still valid (until expiresAt).Scheduling a Post with the Uploaded Media
Both upload paths return the created media, including its id. Pass that id in the media list of any scheduling mutation to attach the file to a post:
mutation scheduleTweet($input: ScheduleTweetInput!) {
scheduleTweet(input: $input) {
success
errors {
field
code
message
}
}
}
{
"input": {
"username": "myhandle",
"postAt": "2025-12-01T15:30:00Z",
"thread": [
{
"text": "Our new campaign is live 🚀",
"order": 0,
"media": [{ "id": "1001" }]
}
]
}
}
{
"data": {
"scheduleTweet": {
"success": true,
"errors": []
}
}
}
You can also reference the file by the name you gave it when uploading, which is handy when you don't keep track of IDs:
{
"media": [{ "name": "campaign-hero.jpg" }]
}
Names are matched case-insensitively and the first match wins, so prefer id when several files share a name.
Uploaded files stay in your Content Library, so the same id can be attached to as many posts as you like. Uploading is only needed once per file.
{ "url": "https://example.com/images/hero.jpg" } directly in media.See Attaching Media for the full rules on media, including how many files each platform accepts.
Rate Limits
Media uploads count against a separate, stricter budget on top of the general API rate limit:
Free Accounts
10 uploads per hour
Paid Accounts
100 uploads per hour
An upload counts against this budget when it begins, that is, on each uploadMediaFromUrl or startMediaUpload call.
When you exceed these limits, the mutation returns success: false with a Media upload rate limit exceeded error.
The two budgets are tracked separately, so exhausting your upload budget never blocks your regular API requests. They aren't mutually exclusive, though: like every API call, an upload mutation also counts against the general rate limit.
Error Handling
The mutations return success: false with details in errors when there's a failure.
Always check success and inspect errors before using the returned media.