Media Uploads
Upload images and videos with presigned URLs and attach them to posts via mediaUrls.
Media makes posts travel further, so uploads deserve a fast lane. Instead of proxying bytes through the API, Onepostly hands you a short-lived signed URL and your file goes straight to the CDN.
Upload Flow
- Call
POST /v1/media/presignto reserve an upload slot - PUT the raw file bytes to the
uploadUrlyou get back - Reference the
publicUrlin themediaUrlsof your post
Step 1: Get a Presigned URL
Then reserve an upload slot.
import { Configuration, MediaApi } from "@onepostly/sdk";
const mediaApi = new MediaApi(
new Configuration({ apiKey: process.env.ONEPOSTLY_API_KEY })
);
const response = await mediaApi.getMediaPresignedUrl({
presignMediaBody: {
"filename": "photo.jpg",
"contentType": "image/jpeg"
},
});
console.log(response);{
"uploadUrl": "https://myaccount.r2.cloudflarestorage.com/temp/1690000000_a1b2c3d4_photo.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600",
"publicUrl": "https://cdn.onepostly.com/temp/1690000000_a1b2c3d4_photo.jpg",
"key": "temp/1690000000_a1b2c3d4_photo.jpg",
"expiresIn": 3600
}The uploadUrl stops working after one hour (expiresIn counts seconds). Reserved files sit in a staging area with a 7-day lifetime; the first publish that points at a publicUrl moves the file into permanent storage automatically.
Scheduling a post pins its media until publish (+1 day buffer). A post going out 10 days after the upload is safe, and only uploads no post references expire.
Step 2: Upload the File
PUT the bytes to that URL straight from your machine. No API key is needed on this request because the signature in the URL already authorizes it.
// The signature is baked into uploadUrl, so this call is unauthenticated
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": "image/jpeg" },
body: fileBuffer,
});Step 3: Use in a Post
Point mediaUrls at the publicUrl from step 1. From here it behaves like any hosted file.
import { Configuration, PostsApi } from "@onepostly/sdk";
const postsApi = new PostsApi(
new Configuration({ apiKey: process.env.ONEPOSTLY_API_KEY })
);
const response = await postsApi.createPost({
createPostBody: {
"text": "Ship day",
"mediaUrls": [
"https://cdn.onepostly.com/media/product.jpg"
],
"mediaKind": "image",
"destinations": [
{
"accountId": "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5"
}
]
},
});
console.log(response);{
"post": {
"id": "b3e1a7c2-8f4d-4e6a-9c1b-2d3e4f5a6b7c",
"text": "Ship day",
"mediaUrls": [
"https://cdn.onepostly.com/media/product.jpg"
],
"mediaKind": "image",
"status": "queued",
"scheduledFor": null,
"timezone": null,
"destinations": [
{
"id": "d4e5f6a7-8b9c-4d0e-8f1a-2b3c4d5e6f7a",
"accountId": "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5",
"platform": "x",
"status": "draft",
"externalPostId": "17895695668004550",
"externalUrl": "https://x.com/you/status/17895695668004550",
"errorCode": null,
"errorMessage": null,
"publishedAt": "2026-08-25T10:00:00.000Z",
"metrics": null
}
],
"createdAt": "2026-08-25T10:00:00.000Z",
"updatedAt": "2026-08-25T10:00:00.000Z"
}
}Every post request must include mediaKind. Posts without media use text, while media posts use the matching kind and provide mediaUrls.
Hosting elsewhere works too, since any public HTTPS URL is accepted in mediaUrls with no upload step. Downstream platform caps still apply at publish time. The per-platform numbers live on each platform page.
Supported Formats
| Type | Formats | Max Size |
|---|---|---|
| Images | JPG, PNG, GIF, WebP | 30 MB |
| Videos | MP4, MOV, AVI, WebM | 1 GB |
Oversized files are rejected at publish with MEDIA_TOO_LARGE. Reserving a slot without PUTing anything and then publishing its publicUrl fails with MEDIA_NOT_UPLOADED.
Platform-Specific Media Rules
Limits diverge once the file leaves our CDN. This table summarizes what each platform accepts.
| Platform | Max Images | Max Videos | Notes |
|---|---|---|---|
| X | 4 | 1 | No mixing images and videos |
| 10 (carousel) | 1 (Reel) | Stories: single image/video | |
| 10 | 1 | Stories: single image/video | |
| 20 images | 1 | Documents supported (PDF, PPT, PPTX, DOC, DOCX) | |
| TikTok | 35 photos | 1 | No mixing photos and videos |
| YouTube | - | 1 (required) | Video-only publishing |
| 1 | 1 | One image or one video per pin | |
| Bluesky | 4 | 1 | No link card when media is attached |
| Threads | 20 images | 1 | Replies carry per-post media via threadItems |
Exact per-platform behavior (counts, durations, edge cases) is documented on the platform pages and in Platform Settings.
List
import { Configuration, MediaApi } from "@onepostly/sdk";
const mediaApi = new MediaApi(
new Configuration({ apiKey: process.env.ONEPOSTLY_API_KEY })
);
const response = await mediaApi.listMedia({
limit: 20,
page: 2,
});
console.log(response);{
"media": [
{
"id": "e8f9a0b1-c2d3-4e5f-8a6b-c7d8e9f0a1b2",
"url": "https://cdn.onepostly.com/media/WORKSPACE_ID/MEDIA_ID.jpg",
"contentType": "image/jpeg",
"sizeBytes": 12345
}
],
"total": 42,
"page": 1,
"hasMore": true
}| Query | Default | Max |
|---|---|---|
limit | 20 | 100 |
page | 1 |
The response carries media, total, page, and hasMore.
Delete
import { Configuration, MediaApi } from "@onepostly/sdk";
const mediaApi = new MediaApi(
new Configuration({ apiKey: process.env.ONEPOSTLY_API_KEY })
);
const response = await mediaApi.deleteMedia({
id: "1699f415-7fb6-43f4-9d2a-c447491f32a8",
});
console.log(response);Responds 204. The CDN object and its record are gone for good. Already-published posts are unaffected because every platform archived its own copy at publish time.
Errors
| Code | HTTP | Cause | Fix |
|---|---|---|---|
VALIDATION_ERROR | 400 | Missing filename/contentType | Send both fields as JSON |
UNSUPPORTED_MEDIA_TYPE | 400 | Type not in the allow-list | Convert to a supported type |
MEDIA_NOT_UPLOADED | 400 | Presigned URL reserved but file never PUT | PUT the file to uploadUrl first |
MEDIA_TOO_LARGE | 400 | Over 30 MB (image) or 1 GB (video) | Compress the file or self-host the URL |
NOT_FOUND | 404 | Unknown media id on delete | Check the id |
FORBIDDEN | 403 | Read-only key used for presign/delete | Use a read_write key |
UNAUTHORIZED | 401 | Missing or invalid key | Check the header value |