> ## Documentation Index
> Fetch the complete documentation index at: https://docs.postbreeze.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Media uploads

> Upload images and videos to attach to your scheduled posts.

Posts with media perform better on every platform. Postbreeze uses
**presigned URLs** so your file goes directly from your code to
storage — the API server never touches the bytes.

Uploaded media is **account-global** — once a file is uploaded, the
same `publicUrl` works across every workspace and every social
account your key can reach. Upload once, reuse anywhere.

## Step 1: Get a presigned URL

`POST /media/presign` with just `filename` + `contentType`. No
workspace, no file size required.

<CodeGroup>
  ```ts SDK theme={null}
  const { uploadUrl, publicUrl, headers } = await postbreeze.media.presign({
    filename: "hero.jpg",
    contentType: "image/jpeg",
  });
  ```

  ```bash cURL theme={null}
  curl -X POST https://api.postbreeze.ai/api/v1/media/presign \
    -H "Authorization: Bearer $POSTBREEZE_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{ "filename": "hero.jpg", "contentType": "image/jpeg" }'
  ```
</CodeGroup>

Response:

```json theme={null}
{
  "uploadUrl": "https://<r2-host>/users/usr_…/med_abc/hero.jpg?X-Amz-…",
  "publicUrl": "https://media.postbreeze.ai/users/usr_…/med_abc/hero.jpg",
  "expiresAt": "2026-06-15T09:15:00.000Z",
  "headers": { "Content-Type": "image/jpeg" }
}
```

The `uploadUrl` is valid for **15 minutes**. Issue the PUT promptly,
or re-presign.

## Step 2: Upload the file

A direct `PUT` to `uploadUrl`. The signature is in the URL — no
`Authorization` header needed on this request.

<CodeGroup>
  ```ts Node.js theme={null}
  import { readFile } from "node:fs/promises";

  const fileBytes = await readFile("./hero.jpg");

  await fetch(uploadUrl, {
    method: "PUT",
    body: fileBytes,
    headers, // { "Content-Type": "image/jpeg" }
  });
  ```

  ```ts Browser theme={null}
  const file = inputEl.files[0];

  await fetch(uploadUrl, {
    method: "PUT",
    body: file,
    headers, // { "Content-Type": "image/jpeg" }
  });
  ```

  ```bash cURL theme={null}
  curl -X PUT "$UPLOAD_URL" \
    -H "Content-Type: image/jpeg" \
    --data-binary "@./hero.jpg"
  ```
</CodeGroup>

A `200 OK` from R2 means the bytes are stored. Your `publicUrl` is
now live.

## Step 3: Use in a post

Pass `publicUrl` into the post's `mediaItems` array:

```ts theme={null}
await postbreeze.posts.create({
  content: "Launching today 🚀",
  platforms: [{ accountId: "soc_…" }],
  mediaItems: [{ url: publicUrl, type: "image" }],
});
```

`type` can be `"image"`, `"video"`, `"gif"`, or `"document"`
(uppercase also works).

## Same media, many platforms

When `mediaItems` is at the **post level**, it's used by every
platform listed in `platforms[]`:

```ts theme={null}
await postbreeze.posts.create({
  content: "Same image, three platforms.",
  platforms: [
    { accountId: "soc_ig_123" },
    { accountId: "soc_x_456" },
    { accountId: "soc_tiktok_789" },
  ],
  mediaItems: [{ url: publicUrl, type: "image" }],
});
```

One upload, one `publicUrl`, every platform — no per-platform
duplication.

## Different media per platform

Sometimes you need a square crop for Instagram and a landscape one
for YouTube. Override per-target with `mediaIds`:

```ts theme={null}
await postbreeze.posts.create({
  caption: "Cross-platform release",
  targets: [
    {
      socialAccountId: "soc_yt_…",
      mediaIds: [landscapeMediaId], // landscape video
    },
    {
      socialAccountId: "soc_tiktok_…",
      mediaIds: [portraitMediaId], // portrait video
    },
  ],
});
```

## Attach a file you already have on a CDN

If your image already lives on a public HTTPS URL, you can skip the
presign + PUT entirely and paste the URL straight into `mediaItems`:

```ts theme={null}
await postbreeze.posts.create({
  content: "Launching today 🚀",
  platforms: [{ accountId: "soc_…" }],
  mediaItems: [
    { url: "https://cdn.example.com/hero.jpg", type: "image" },
  ],
});
```

Postbreeze will fetch the URL server-side (SSRF-guarded) and store
the bytes — same outcome as a direct upload.

## Supported formats

| Type      | Formats                         | Max size |
| --------- | ------------------------------- | -------- |
| Images    | JPG, PNG, GIF, WebP, HEIC, HEIF | 5 GB     |
| Videos    | MP4, MOV, AVI, WebM             | 5 GB     |
| Documents | PDF (LinkedIn only)             | 100 MB   |

<Note>
  The size limits above are the **storage ceiling**. Each platform
  enforces its own (much tighter) caps at publish time — see
  [per-platform limits](#per-platform-limits) below. The server
  accepts files up to the storage ceiling so you can keep originals
  alongside re-encoded variants; the publisher rejects files that
  exceed the destination platform's limit.
</Note>

## Per-platform limits

Each platform enforces its own media rules at publish time:

| Platform  | Images              | Videos | Documents        | Notes                                |
| --------- | ------------------- | ------ | ---------------- | ------------------------------------ |
| Instagram | up to 10 (carousel) | 1      | —                | 9:16 for Reels, 4:5–1.91:1 for Feed  |
| Facebook  | up to 10 (carousel) | 1      | —                | —                                    |
| X         | up to 4             | 1      | —                | Can't mix images + video in one post |
| LinkedIn  | up to 20            | 1      | 1 PDF (carousel) | The only platform that accepts PDFs  |
| TikTok    | up to 35 (carousel) | 1      | —                | —                                    |
| Threads   | up to 20 (mixed)    | 1      | —                | —                                    |
| Pinterest | up to 5 (carousel)  | 0      | —                | —                                    |
| YouTube   | 0                   | 1      | —                | Video only                           |

See each [platform guide](/platforms/overview) for full per-platform
rules.

### LinkedIn PDFs

LinkedIn's "document carousel" surface accepts a single PDF. Upload
it the same way as any other file, then attach with `type: "document"`:

```ts theme={null}
const { uploadUrl, publicUrl, headers } = await postbreeze.media.presign({
  filename: "deck.pdf",
  contentType: "application/pdf",
});

await fetch(uploadUrl, { method: "PUT", body: pdfBytes, headers });

await postbreeze.posts.create({
  caption: "Our Q3 deck — page-by-page.",
  targets: [
    {
      socialAccountId: "soc_linkedin_…",
      mediaItems: [{ url: publicUrl, type: "document" }],
    },
  ],
});
```

If you put a PDF in the post-level `mediaItems` for a cross-post that
includes non-LinkedIn targets, Postbreeze rejects the post with a
`400` — PDFs only land on LinkedIn. Use per-target `mediaItems` (or
`mediaIds`) to send a different asset to the other platforms.
