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

  1. Call POST /v1/media/presign to reserve an upload slot
  2. PUT the raw file bytes to the uploadUrl you get back
  3. Reference the publicUrl in the mediaUrls of 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

TypeFormatsMax Size
ImagesJPG, PNG, GIF, WebP30 MB
VideosMP4, MOV, AVI, WebM1 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.

PlatformMax ImagesMax VideosNotes
X41No mixing images and videos
Instagram10 (carousel)1 (Reel)Stories: single image/video
Facebook101Stories: single image/video
LinkedIn20 images1Documents supported (PDF, PPT, PPTX, DOC, DOCX)
TikTok35 photos1No mixing photos and videos
YouTube-1 (required)Video-only publishing
Pinterest11One image or one video per pin
Bluesky41No link card when media is attached
Threads20 images1Replies 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
}
QueryDefaultMax
limit20100
page1

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

CodeHTTPCauseFix
VALIDATION_ERROR400Missing filename/contentTypeSend both fields as JSON
UNSUPPORTED_MEDIA_TYPE400Type not in the allow-listConvert to a supported type
MEDIA_NOT_UPLOADED400Presigned URL reserved but file never PUTPUT the file to uploadUrl first
MEDIA_TOO_LARGE400Over 30 MB (image) or 1 GB (video)Compress the file or self-host the URL
NOT_FOUND404Unknown media id on deleteCheck the id
FORBIDDEN403Read-only key used for presign/deleteUse a read_write key
UNAUTHORIZED401Missing or invalid keyCheck the header value