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

# Upload and manage media

> Get assets into the media library with direct-to-storage uploads — presign, upload, confirm, plus bulk imports, folders, and replacement workflows.

The [media library](/concepts/media-library) holds every image and video your workspace's posts can use. Uploads happen **directly to storage** using a three-step flow — the file bytes never pass through the API itself.

## How direct uploads work

<Steps>
  <Step title="Presign">
    Tell the API which files you want to upload (name, MIME type, size). It pre-creates a temporary media item for each and returns a short-lived, pre-signed upload URL.
  </Step>

  <Step title="Upload">
    `PUT` each file's bytes straight to the returned `upload_url`, replaying the signed headers. This goes to storage, not to the PublishBuddy API.
  </Step>

  <Step title="Confirm">
    Tell the API which uploads finished. It verifies each object landed in storage, moves it into the workspace's library, and marks it active.
  </Step>
</Steps>

<Info>
  Direct uploads replace the old `POST /media_contents` multipart endpoint, which has been removed from the public API. Uploading through the API is no longer supported — always use the presign → upload → confirm flow.
</Info>

## A single upload

### 1. Presign

```bash theme={null}
curl -X POST "https://api.publishbuddy.com/v1/workspaces/$WORKSPACE_ID/media_contents/presign" \
  -H "Authorization: Bearer $PUBLISHBUDDY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "files": [
      { "name": "hero.jpg", "mime": "image/jpeg", "size": 184293 }
    ]
  }'
```

Each entry needs a `name` and `size` (bytes); `mime` is optional but recommended. You can presign up to **10 files per request**.

Response — one `PresignedMediaUploadResource` per file:

```json theme={null}
{
  "data": [
    {
      "id": "9b1f2c3d-5a6e-7f8a-9b0c-1d2e3f4a5b6c",
      "name": "a1b2c3d4-1720000000.jpg",
      "full_name": "library-temporary/9b1f2c3d.../a1b2c3d4-1720000000.jpg",
      "link": "https://cdn.publishbuddy.com/...",
      "upload_url": "https://storage.publishbuddy.com/...?X-Amz-Signature=...",
      "upload_headers": { "x-amz-acl": "public-read" },
      "upload_method": "PUT",
      "expires_in": 1800
    }
  ]
}
```

The `id` is the temporary media item's UUID — you'll pass it back in the confirm step. The `upload_url` is valid for `expires_in` seconds (30 minutes).

### 2. Upload the bytes

`PUT` the file straight to `upload_url`, replaying every header from `upload_headers` exactly as returned:

```bash theme={null}
curl -X PUT "$UPLOAD_URL" \
  -H "x-amz-acl: public-read" \
  --data-binary @./hero.jpg
```

This request goes to storage, not to `api.publishbuddy.com`. A `200`/`204` means the bytes are in place.

### 3. Confirm

```bash theme={null}
curl -X POST "https://api.publishbuddy.com/v1/workspaces/$WORKSPACE_ID/media_contents/confirm" \
  -H "Authorization: Bearer $PUBLISHBUDDY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "uuids": ["9b1f2c3d-5a6e-7f8a-9b0c-1d2e3f4a5b6c"] }'
```

Response — the finalized media objects, ready to attach to posts:

```json theme={null}
{
  "data": [
    {
      "id": "9b1f2c3d-5a6e-7f8a-9b0c-1d2e3f4a5b6c",
      "name": "a1b2c3d4-1720000000.jpg",
      "full_name": "library/9b1f2c3d.../a1b2c3d4-1720000000.jpg",
      "label": null,
      "link": "https://cdn.publishbuddy.com/...",
      "size": 184293,
      "width": 1080,
      "height": 1080,
      "time": null,
      "folder_id": null
    }
  ],
  "message": "Media uploaded successfully."
}
```

<Info>
  `size`, `width`, `height`, and `time` are detected asynchronously and may come back `null` immediately after confirm. Fetch the media item again a moment later (`GET /media_contents/{mediaContent}`) to read the resolved values.
</Info>

Only UUIDs whose bytes actually landed in storage appear in `data` — a UUID you confirm without uploading is silently skipped rather than erroring.

## Multiple files in one request

Presign and confirm both accept up to **10 items** per request. Presign every file, `PUT` each one, then confirm them all together:

```bash theme={null}
# 1. Presign all three
curl -X POST "https://api.publishbuddy.com/v1/workspaces/$WORKSPACE_ID/media_contents/presign" \
  -H "Authorization: Bearer $PUBLISHBUDDY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "files": [
      { "name": "shot-01.jpg", "mime": "image/jpeg", "size": 201322 },
      { "name": "shot-02.jpg", "mime": "image/jpeg", "size": 198744 },
      { "name": "shot-03.jpg", "mime": "image/jpeg", "size": 210987 }
    ]
  }'

# 2. PUT each file to its own upload_url (see above)

# 3. Confirm all three UUIDs at once
curl -X POST "https://api.publishbuddy.com/v1/workspaces/$WORKSPACE_ID/media_contents/confirm" \
  -H "Authorization: Bearer $PUBLISHBUDDY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "uuids": ["<uuid-1>", "<uuid-2>", "<uuid-3>"] }'
```

To place freshly uploaded files in a folder, update each one's `folder_id` after confirming (see [Organising with folders](#organising-with-folders)).

## Bulk imports

For larger imports (hundreds or thousands of files), batch them in groups of 10 (the presign/confirm limit) and cap concurrency:

<CodeGroup>
  ```javascript Node.js — presign → upload → confirm theme={null}
  import pLimit from 'p-limit';
  import fs from 'node:fs';

  const base = `https://api.publishbuddy.com/v1/workspaces/${workspaceId}/media_contents`;
  const auth = { Authorization: `Bearer ${token}` };
  const limit = pLimit(8);

  // Process files in batches of 10 (the presign/confirm cap).
  for (let i = 0; i < files.length; i += 10) {
    const batch = files.slice(i, i + 10);

    // 1. Presign the batch.
    const presignRes = await fetch(`${base}/presign`, {
      method: 'POST',
      headers: { ...auth, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        files: batch.map((f) => ({ name: f.name, mime: f.mime, size: f.size })),
      }),
    });
    const { data: slots } = await presignRes.json();

    // 2. Upload each file's bytes directly to storage.
    await Promise.all(
      slots.map((slot, idx) =>
        limit(async () => {
          const res = await fetch(slot.upload_url, {
            method: slot.upload_method, // "PUT"
            headers: slot.upload_headers,
            body: fs.createReadStream(batch[idx].path),
            duplex: 'half',
          });
          if (!res.ok) throw new Error(`Upload failed: ${batch[idx].name}`);
        })
      )
    );

    // 3. Confirm the whole batch.
    await fetch(`${base}/confirm`, {
      method: 'POST',
      headers: { ...auth, 'Content-Type': 'application/json' },
      body: JSON.stringify({ uuids: slots.map((s) => s.id) }),
    });
  }
  ```

  ```python Python — presign → upload → confirm theme={null}
  import os, requests

  base = f'https://api.publishbuddy.com/v1/workspaces/{workspace_id}/media_contents'
  auth = {'Authorization': f'Bearer {os.environ["PUBLISHBUDDY_TOKEN"]}'}

  def chunk(seq, n):
      for i in range(0, len(seq), n):
          yield seq[i:i + n]

  for batch in chunk(files, 10):  # presign/confirm cap is 10
      # 1. Presign.
      slots = requests.post(
          f'{base}/presign',
          headers=auth,
          json={'files': [{'name': f['name'], 'mime': f['mime'], 'size': f['size']} for f in batch]},
      ).json()['data']

      # 2. Upload each file's bytes directly to storage.
      for slot, f in zip(slots, batch):
          with open(f['path'], 'rb') as fh:
              res = requests.put(slot['upload_url'], headers=slot['upload_headers'], data=fh)
              res.raise_for_status()

      # 3. Confirm the batch.
      requests.post(
          f'{base}/confirm',
          headers=auth,
          json={'uuids': [slot['id'] for slot in slots]},
      ).raise_for_status()
  ```
</CodeGroup>

The `media_contents` endpoint group uses the `throttle:api_1000_per_min` rate limit, so a modest concurrency (8 or so) per token stays comfortably under it. Note the presign/confirm calls count against the API rate limit, but the `PUT` uploads go to storage and don't.

## Supported formats and limits

| Type  | Common formats       | Notes                                                                        |
| ----- | -------------------- | ---------------------------------------------------------------------------- |
| Image | JPEG, PNG, WebP, GIF | Subject to per-network size and aspect-ratio rules at publish time.          |
| Video | MP4, MOV             | H.264 baseline strongly recommended for maximum cross-network compatibility. |

The maximum per-file size is **2 GB** (declare the real byte count in the presign `size` field). Per-network and per-plan limits may be lower and are enforced at publish time. For very large files (long-form video), upload them once and reuse the same `media_content_id` across multiple posts.

## Organising with folders

Folders are workspace-internal — they help your team find assets and don't change anything that gets sent to the social network.

```bash theme={null}
curl -X POST "https://api.publishbuddy.com/v1/workspaces/$WORKSPACE_ID/media_library/folders" \
  -H "Authorization: Bearer $PUBLISHBUDDY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Spring 2026 campaign",
    "parent_id": null
  }'
```

Move a file into a folder by updating its `folder_id`:

```bash theme={null}
curl -X PUT "https://api.publishbuddy.com/v1/media_contents/$MEDIA_ID" \
  -H "Authorization: Bearer $PUBLISHBUDDY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "folder_id": "'"$FOLDER_ID"'" }'
```

## Filtering existing media

```bash theme={null}
curl "https://api.publishbuddy.com/v1/workspaces/$WORKSPACE_ID/media_contents?folder_id=$FOLDER_ID" \
  -H "Authorization: Bearer $PUBLISHBUDDY_TOKEN"
```

Combine `folder_id` with `page` and `per_page` (see [Pagination](/api-essentials/pagination)) to walk large libraries.

## Replacing a file

The binary attached to a media object is immutable. To "replace" an asset:

1. Upload the new file (presign → upload → confirm gives you a new `media_content_id`).
2. Update any scheduled (`status: waiting`) posts that reference the old ID — set their `media_content_ids` to include the new ID instead.
3. Delete the old media object (optional — already-published posts hold their own reference).

Trying to mutate the file via `PUT` is intentionally not supported; this keeps the audit trail clean ("the post that went out on Dec 25 used exactly *this* asset").

## Deleting

```bash theme={null}
curl -X DELETE "https://api.publishbuddy.com/v1/media_contents/$MEDIA_ID" \
  -H "Authorization: Bearer $PUBLISHBUDDY_TOKEN"
```

Deletion is a soft delete — the file is hidden from listings and can no longer be attached to **new** posts, but any existing post that used it continues to work, including future scheduled publishes.
