# Upload Images, Video and PDFs via API

import { TechArticle } from "../components/TechArticle";

<TechArticle slug="media" fm={frontmatter} />

Upload and manage media files for use in posts. Every media file is stored in your workspace's media library and can be referenced by multiple posts.

## Upload a file

Use `POST /media/upload` with a multipart form. Maximum file size is **100 MB** for a direct upload. To ingest larger files (up to **1 GB**), use [Upload from a URL](#upload-from-a-url), which downloads asynchronously.

```bash
curl -X POST https://api.omnisocials.com/v1/media/upload \
  -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \
  -F "file=@photo.jpg"
```

Response:

```json
{
  "data": {
    "id": "123",
    "url": "https://cdn.omnisocials.com/workspace-42/photo.jpg",
    "thumbnail_url": null,
    "type": "image",
    "name": "photo.jpg",
    "filename": "photo-1a2b3c.jpg",
    "folder_id": null,
    "size": "0.80 MB",
    "width": 1080,
    "height": 1350,
    "status": "ready",
    "created_at": "2026-04-10T14:22:00Z"
  },
  "compatibility": { "overall": "ok", "platforms": {} },
  "message": "File uploaded successfully. Use the 'id' in your post request."
}
```

The media record is nested under `data`. Note that `size` is a human-readable string (e.g. `"0.80 MB"`), not a byte count. `width` and `height` are the image's pixel size (`null` for videos and for files uploaded before July 2026); they also come back on `GET /media` and `GET /media/:id`. The `compatibility` block is a preflight check of the file against your connected platforms. Save `data.id`; you reference media on posts through the `media_ids` field.

`status` is `ready` for normal uploads. Large asynchronous URL ingests start as `processing`; poll `GET /media/:id` until `status` is `ready` before using the file in a post. A source that can't be fetched or validated becomes `failed`.

## Upload from a URL

Skip the download-and-reupload dance by pointing OmniSocials at a public URL. The server fetches the file (up to 1 GB) and stores it in your media library.

```bash
curl -X POST https://api.omnisocials.com/v1/media/upload-from-url \
  -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/photo.jpg" }'
```

The response shape matches `POST /media/upload`. Files above ~100 MB are ingested asynchronously and come back with `status: "processing"`.

## Upload a PDF as a carousel

Upload a PDF and OmniSocials rasterizes it into one image slide per page (up to 20 pages), keeps the original file, and returns a media ID for every slide. Pass **all** of the returned IDs to `media_ids` on a post to publish the deck as a carousel: on LinkedIn it posts as a native swipeable document made from your original PDF (text stays sharp, in-document links keep working, viewers download the real file, every page is included); on Instagram, TikTok, Threads and Pinterest as an image carousel. See [LinkedIn: Original PDF](/platforms/linkedin#original-pdf) for the exact rule and the `document_source` opt-out.

PDFs are accepted on every upload path: `POST /media/upload`, `upload-from-url`, `upload-from-base64`, the presigned `upload-by-token` flow, and `media_urls` on a post (where the PDF expands in place into one slide per page). The upload response carries the whole set:

```json
{
  "data": { "id": "201", "type": "image", "status": "ready", "pdf": { "id": "9d2c…", "name": "deck.pdf", "page": 1, "total_pages": 3, "rendered_pages": 3 } },
  "slides": [ { "id": "201" }, { "id": "202" }, { "id": "203" } ],
  "media_ids": ["201", "202", "203"],
  "pdf": { "total_pages": 3, "rendered_pages": 3, "truncated": false, "id": "9d2c…", "name": "deck.pdf", "url": "https://cdn.omnisocials.com/file/library-omnisocials/…/9d2c….pdf", "size_bytes": 184320 }
}
```

`data` mirrors the first slide for backward compatibility; use `media_ids` to attach the full carousel. Every slide's `Media` object carries `pdf` (the kept file's `id`, `name`, `url`, and this slide's `page`, `total_pages`, `rendered_pages`), so a slide in `GET /media` can always be traced to its file and page. Deleting the last slide of a PDF removes the kept file too.

### Keep the PDF as one item

Pass `pdf_mode: "document"` on any upload endpoint (a form field on the multipart and presigned paths) and the PDF stays **one** media item instead of one item per page. This is what the OmniSocials web library does. The item has `type: "document"`, its page images in `pdf.pages` (in order), page 1 as `thumbnail_url`, and the kept PDF as `url`. Its single id in `media_ids` expands into every page at post time, so one id attaches the whole deck:

```json
{
  "data": { "id": "301", "type": "document", "name": "deck.pdf", "pdf": { "id": "9d2c…", "total_pages": 3, "rendered_pages": 3, "pages": [ { "url": "…/p1.png" }, { "url": "…/p2.png" }, { "url": "…/p3.png" } ] } },
  "media_ids": ["301"],
  "pdf": { "total_pages": 3, "rendered_pages": 3, "truncated": false, "id": "9d2c…", "name": "deck.pdf", "url": "…/9d2c….pdf" }
}
```

Uploading the same PDF again returns the existing document item. `GET /media` lists document items with `type: "document"`. Deleting a document item removes its page images and, when no split slide still points at it, the kept PDF.

`POST /media/{id}/split` turns a document item into one image item per page (copies of the page images, each carrying `pdf.page`), for the case where a single page is needed as a normal image. The document item stays. A full, ordered set of those slides still publishes the original PDF on LinkedIn.

The default stays `pdf_mode: "slides"` so existing integrations that read one media id per page keep working.

## Inline URLs on a post

If you only need the media for a single post, skip the media library entirely. Pass URLs directly in `media_urls` on the post body. Note the size split: `media_urls` on a post caps at 100 MB per file, while `upload-from-url` handles up to 1 GB; for big videos, upload first and pass the returned media id.

```json
{
  "content": { "default": "Check this out" },
  "accounts": ["your-account-id"],
  "media_urls": ["https://example.com/photo.jpg"]
}
```

The API downloads, validates, and attaches the file automatically. The maximum number of items is platform-dependent (Instagram/Threads up to 10, TikTok up to 35, X/Bluesky/Mastodon up to 4, Pinterest up to 5, Google Business 1). Use the media library (via `upload` or `upload-from-url`) when the same file appears on multiple posts, to save on download bandwidth.

Any entry, in `media_urls` or `media_ids`, can be an object instead of a bare string to carry an accessibility description (alt text, max 1500 characters): `{ "url": "https://example.com/bike.jpg", "alt": "A red bicycle leaning against a brick wall" }` (or `{ "id": "123", "alt": "..." }` for library media). See [Attaching media](/creating-posts#attaching-media) for which platforms receive it.

## Presigned uploads

When the uploading party cannot send an `Authorization` header (a browser form, an AI sandbox, a third-party service), mint a one-time upload URL instead of sharing your API key:

```bash
curl -X POST https://api.omnisocials.com/v1/media/create-upload-url \
  -H "Authorization: Bearer $OMNISOCIALS_API_KEY"
```

The response contains an `upload_url` of the form `https://api.omnisocials.com/v1/media/upload-by-token?upload_token=...`. POST a multipart form with a `file` field to that URL, with no auth header; the token in the URL is the auth. Tokens are single-use and expire after 10 minutes. The upload response matches `POST /media/upload` (including the PDF `media_ids` expansion).

## Folders

Organize the library into folders. Reading uses `media:read`, writing uses `media:write`.

| Endpoint | Description |
|----------|-------------|
| `GET /folders` | List folders |
| `POST /folders` | Create a folder |
| `PATCH /folders/:id` | Rename a folder |
| `DELETE /folders/:id` | Delete a folder |

Move a file into a folder with `PATCH /media/:id` and a `folder_id`; filter the library by folder with `GET /media?folder_id=`.

## Searching the library

`GET /media` takes four optional query parameters:

| Parameter | Description |
|-----------|-------------|
| `limit` | Items per page, 1 to 100 (default 20) |
| `offset` | Skip this many items |
| `search` | Case-insensitive match on the media name and filename |
| `folder_id` | Only items in this folder |

Use `search` to find an existing asset by name instead of re-uploading it.

## Supported formats

| Media type | Formats |
|------------|---------|
| Images | JPEG, PNG, GIF, WebP, HEIC/HEIF (converted to JPEG on upload) |
| Videos | MP4, MOV, AVI |
| Documents | PDF (rasterized into page images; one item per page, or one document item with `pdf_mode: "document"`; see above) |

Platform-specific size and duration requirements are enforced when a post is scheduled or published, not at upload. A video can upload successfully and still fail to publish to Instagram if it exceeds Instagram's length limit. Aspect ratio is handled at post time too, in two steps: a feed image outside what a channel shows uncropped is cropped or kept per channel and reported in the post response (`media_fit`, see [Image fit per channel](/creating-posts#image-fit-per-channel)), and the hard limits that remain (Instagram feed images outside 9:16 to 1.91:1, Pinterest carousel slides at mixed ratios) return `400 validation_error`. See the [Platforms](/platforms) section for per-platform media specs.

## Check compatibility before posting

`POST /media/check` tells you, before you post, how a file does on your connected channels. Pass one of `url` (size and type are read with a HEAD request), `media_id` (a library file) or `size_bytes` plus `mime`. It returns `connected_platforms`, a `compatible` boolean and per-platform `warnings` (size and format, with reasons like `max 300 MB`). Every upload response carries the same `compatibility` block.

For a library image (`media_id`) the response also carries `file.width` / `file.height` and a `fit` array: one row per connected channel with an image fit rule, saying how that channel shows the image as a feed post.

```json
{
  "file": { "size_bytes": 812345, "mime": "image/jpeg", "size_known": true, "width": 1080, "height": 1920 },
  "connected_platforms": ["instagram", "pinterest", "linkedin"],
  "compatible": true,
  "warnings": [],
  "summary": null,
  "fit": [
    {
      "platform": "instagram",
      "display_name": "Instagram",
      "fits": false,
      "ratio": "9:16",
      "shown": "cut",
      "target": "4:5",
      "default_action": "crop",
      "message": "Instagram: This image is 9:16. Instagram shows feed images between 4:5 and 1.91:1, and Meta cannot boost a feed post outside that range. A post crops it to 4:5 unless media_fit says \"keep\"."
    },
    {
      "platform": "pinterest",
      "display_name": "Pinterest",
      "fits": false,
      "ratio": "9:16",
      "shown": "cut",
      "target": "2:3",
      "default_action": "keep",
      "message": "Pinterest: This image is 9:16. Pinterest may cut a Pin taller than 2:3 in the home feed. The Pin page shows it whole. Pinterest cuts it to fit."
    }
  ]
}
```

`fits: false` means the ratio is outside the window the channel shows uncropped; `default_action` is what a post create does when `media_fit` is not set (`crop` on Instagram, `keep` on Pinterest, `null` when the image fits). Only images with a known size get rows, so videos, `url` checks and files uploaded before July 2026 answer an empty `fit`. The check is advisory: the file still posts to every channel that accepts it.

## Managing media

| Endpoint | Description |
|----------|-------------|
| `GET /media` | List everything in the library |
| `GET /media/:id` | Fetch a single media record |
| `PATCH /media/:id` | Rename a media file or move it to a folder |
| `DELETE /media/:id` | Delete from the library |
| `POST /media/check` | Preflight-check a file's compatibility with your connected platforms, including how each channel shows an image as a feed post (`fit`, see above) |
| `POST /media/upload-from-base64` | Upload a base64-encoded file |
| `POST /media/:id/split` | Turn a PDF document item into one image item per page |
| `POST /media/create-upload-url` | Mint a one-time presigned upload URL (see above) |

Reading media (`GET /media`, `GET /media/:id`) requires the `media:read` scope; all writes require `media:write`.

Deleting a media file that is still attached to a scheduled or in-progress post returns `409 media_in_use`. Deleting one that is only on already-published posts succeeds and does not remove it from those live posts.
