# Media uploads

Upload images, videos and documents with a presigned URL, attach them to posts with mediaItems, and add alt text, thumbnails and captions.

Posts carry media as URLs in `mediaItems`. If your file is already on a public HTTPS URL, use that URL directly. If it lives on your server or the user's device, upload it to Pozy first: you ask for an upload URL, send the file there, and put the returned public URL in the post. One upload can be reused by every platform in a post and by later posts.

## Upload a file

### Ask for an upload URL

`POST /v1/media/presign` takes the file name and its MIME type. Add `size` in bytes to check the 5 GB limit before you upload.

```bash cURL
curl -X POST https://api.pozy.ai/v1/media/presign \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "filename": "demo.mp4", "contentType": "video/mp4", "size": 48213504 }'
```
```ts Node
const uploadRes = await fetch("https://api.pozy.ai/v1/media/presign", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ filename: "demo.mp4", contentType: "video/mp4", size: 48213504 }),
});
const upload = await uploadRes.json();
if (!uploadRes.ok) throw new Error(upload.error);
```
```python Python
upload = requests.post(
    "https://api.pozy.ai/v1/media/presign",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={"filename": "demo.mp4", "contentType": "video/mp4", "size": 48213504},
    timeout=30,
).json()
```

```json
{
  "uploadUrl": "https://…signed-upload-url…",
  "publicUrl": "https://pozy.ai/media/temp/1730289600_r7t2_demo.mp4",
  "key": "temp/1730289600_r7t2_demo.mp4",
  "expiresIn": 3600
}
```

`uploadUrl` is valid for `expiresIn` seconds (one hour). `publicUrl` is the address you put in the post.

### PUT the file

Send the raw bytes to `uploadUrl` with the same `Content-Type` you declared. Do not add an `Authorization` header: the signature in the URL is the permission.

```bash cURL
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: video/mp4" \
  --data-binary @demo.mp4
```
```ts Node
import { readFile } from "node:fs/promises";

await fetch(upload.uploadUrl, {
  method: "PUT",
  headers: { "Content-Type": "video/mp4" },
  body: await readFile("demo.mp4"),
});
```
```python Python
with open("demo.mp4", "rb") as f:
    requests.put(upload["uploadUrl"], data=f, headers={"Content-Type": "video/mp4"}, timeout=600).raise_for_status()
```

A successful upload answers `200` with an empty body.

### Attach it to a post

Put `publicUrl` in `mediaItems`:

```bash cURL
curl -X POST https://api.pozy.ai/v1/posts \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Two minutes, start to finish.",
    "mediaItems": [{ "type": "video", "url": "https://pozy.ai/media/temp/1730289600_r7t2_demo.mp4" }],
    "platforms": [
      { "platform": "youtube", "accountId": "6710c3e2a9f14b0d8e5c7a27", "platformSpecificData": { "title": "Pozy in two minutes" } },
      { "platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21" }
    ],
    "queuedFromProfile": "670f9b1d4c2e8a7f3b6d5e10"
  }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/posts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    content: "Two minutes, start to finish.",
    mediaItems: [{ type: "video", url: upload.publicUrl }],
    platforms: [
      { platform: "youtube", accountId: "6710c3e2a9f14b0d8e5c7a27", platformSpecificData: { title: "Pozy in two minutes" } },
      { platform: "linkedin", accountId: "6710c3e2a9f14b0d8e5c7a21" },
    ],
    queuedFromProfile: "670f9b1d4c2e8a7f3b6d5e10",
  }),
});
if (!res.ok) throw new Error((await res.json()).error);
```
```python Python
requests.post(
    "https://api.pozy.ai/v1/posts",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={
        "content": "Two minutes, start to finish.",
        "mediaItems": [{"type": "video", "url": upload["publicUrl"]}],
        "platforms": [
            {"platform": "youtube", "accountId": "6710c3e2a9f14b0d8e5c7a27", "platformSpecificData": {"title": "Pozy in two minutes"}},
            {"platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21"},
        ],
        "queuedFromProfile": "670f9b1d4c2e8a7f3b6d5e10",
    },
    timeout=30,
).raise_for_status()
```

## Uploads through the API: about 4 MB

A request to the API itself can carry about 4 MB. Endpoints that take the file in the request body are limited by that: [`POST /v1/media/upload-direct`](https://pozy.ai/docs/api/uploadMediaDirect), porting and identity documents, SMS opt-in screenshots, RCS logos and banners, the WhatsApp profile photo and CSV bulk uploads. A larger body is refused with `413` before Pozy reads it, without the usual JSON error.

Use `upload-direct` for files up to 4 MB. For anything larger, use `POST /v1/media/presign` and send the file straight to storage as shown above: presigned uploads take files up to 5 GB.

## Keep uploads alive

Uploaded files sit in temporary storage for **7 days**. When a post that uses the file is created or updated, Pozy copies it to permanent storage, so a post scheduled months ahead keeps its media.

The catch is the gap between upload and post. If your app uploads when the person picks a file but creates the post a week later, the file is gone. A draft still saves, but scheduling or publishing it fails with `400`. Either create the post soon after the upload, or upload when the post is created.

## Accepted file types

| Kind | Formats | `contentType` values |
| --- | --- | --- |
| Images | JPEG, PNG, WebP, GIF | `image/jpeg`, `image/png`, `image/webp`, `image/gif` |
| Videos | MP4, MPEG, MOV, AVI, WebM, M4V | `video/mp4`, `video/mpeg`, `video/quicktime`, `video/avi`, `video/x-msvideo`, `video/webm`, `video/x-m4v` |
| Documents | PDF (LinkedIn document posts) | `application/pdf` |
| Audio | MP3, M4A, AAC, OGG, WAV, WebM | `audio/mpeg`, `audio/mp4`, `audio/x-m4a`, `audio/aac`, `audio/ogg`, `audio/wav`, `audio/webm` |
| Captions | SRT, WebVTT | `application/x-subrip`, `text/vtt` |

Every type can be up to 5 GB at upload. Platforms are much stricter, and each has its own rules for count, size, duration and aspect ratio: see the media section of each [platform page](https://pozy.ai/docs/platforms). Audio cannot be posted on its own. Use it as an attachment in [inbox messages](https://pozy.ai/docs/inbox/messages).

A presign request with a type outside this list, such as `application/octet-stream`, returns `400` with `code: "invalid_field_value"` and `param: "contentType"`.

## Media item fields

| Field | What it does |
| --- | --- |
| `type` | `image`, `video`, `gif` or `document`. Pozy infers it from the file extension when you leave it out, and rejects a `type` that contradicts the extension. |
| `url` | Public HTTPS URL that returns the file itself. |
| `altText` | Accessibility text for images. Used where the platform supports it, for example Instagram (cut at 1,000 characters) and Bluesky. |
| `thumbnail` | Custom cover for a video, JPG or PNG up to 10 MB. Used for Facebook videos and Reels, YouTube and LinkedIn videos. |
| `instagramThumbnail` | Cover image for an Instagram Reel. |
| `subtitles` | Up to 20 caption files for a video, each `{ "url": "...", "language": "en" }`. |
| `title`, `filename`, `size`, `mimeType` | Optional details stored with the item. |

Order matters: items are published in the order you list them, which sets the order of a carousel.

## Captions on videos

Attach SRT or WebVTT files, one per language, and Pozy converts them for each platform that accepts captions:

```json
[
  {
    "type": "video",
    "url": "https://pozy.ai/media/temp/1730289600_r7t2_demo.mp4",
    "subtitles": [
      { "url": "https://pozy.ai/media/temp/1730289600_c1a9_demo.en.vtt", "language": "en" },
      { "url": "https://pozy.ai/media/temp/1730289600_c1b3_demo.es.srt", "language": "es" }
    ]
  }
]
```

Caption files must be public and under 1 MB. A platform that cannot take a track skips it and adds a warning to the response. A file Pozy cannot parse returns `400`.

## Different media per platform

`mediaItems` applies to every entry. Use `customMedia` on one `platforms[]` entry to replace it there, for example a square crop for Instagram and a wide one elsewhere:

```json
{
  "mediaItems": [{ "type": "image", "url": "https://pozy.ai/media/temp/1730289600_k3f9_wide.jpg" }],
  "platforms": [
    { "platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21" },
    {
      "platform": "instagram",
      "accountId": "6710c3e2a9f14b0d8e5c7a22",
      "customMedia": [{ "type": "image", "url": "https://pozy.ai/media/temp/1730289600_m2p7_square.jpg" }]
    }
  ]
}
```

## Prepare a file for a platform

When you are not sure a file meets a platform's rules, `POST /v1/media/prepare` runs the same conversion Pozy applies before publishing and gives you the result. Send the file URL and a `target`:

```json
{ "url": "https://pozy.ai/media/temp/1730289600_r7t2_demo.mp4", "target": "instagram" }
```

`target` is one of `instagram`, `instagram_story`, `facebook`, `facebook_reel`, `facebook_story`, `threads`, `tiktok`, `linkedin`, `twitter`, `bluesky`, `pinterest`, `reddit`, `snapchat`, `telegram`, `googlebusiness` or `youtube_thumbnail`. The response says whether anything `changed`, and gives the new `url` with `originalSize` and `outputSize`. Images are converted to a format the platform accepts and compressed under its limit; videos are re-encoded or resized when needed.

The call is synchronous, so a long video can take a few minutes. A workspace can prepare up to 60 files per hour; past that you get `429`.

## URLs that fail

The most common media failure is a URL that does not return the file. Links to Google Drive, Dropbox, OneDrive or iCloud pages return an HTML page, and platforms cannot publish a web page. Open the URL in a private browser window: if you see a page instead of the image or video, upload the file to Pozy instead. `POST /v1/tools/validate/media` checks a URL for you and reports its type, size and how it compares with each platform's limit ([Validation](https://pozy.ai/docs/publishing/validation)).

| Error | Cause | Fix |
| --- | --- | --- |
| `400` on presign | Missing `filename`, a `contentType` outside the list, or `size` over 5 GB. | Send the real MIME type. |
| Upload refused by storage | `uploadUrl` is more than an hour old, or the `Content-Type` differs from the presign call. | Ask for a new URL and send the same type. |
| `400` when scheduling | The upload is more than 7 days old and was never attached to a post. | Upload again. |
| Entry `failed` with `user_content` | The file breaks a platform rule (duration, size, ratio). | Check the platform page, or run `prepare` first. |

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`POST /v1/media/presign`](https://pozy.ai/docs/api/getMediaPresignedUrl) | Get upload URL |
| [`POST /v1/media/prepare`](https://pozy.ai/docs/api/prepareMedia) | Prepare media for a platform |

| Endpoint | What it does |
| --- | --- |
| [`POST /v1/tools/validate/media`](https://pozy.ai/docs/api/validateMedia) | Validate media URL |
