# Attach media to a post

Upload an image or video, wait until the asset is `ready`, then attach its id to your post. Use a public URL when the file is already hosted, or a signed upload when your app holds the bytes. Both paths create an asset in the product’s bucket.

Replace the example URL and `<asset id>`, `<post id>` and `<channel id>` with your file URL and the ids returned by Content.

## Before you start

- For MCP calls, use a token with product-scoped write access for the Content operations below.
- A post in `draft`, `pending_approval` or `approved` state. Attaching is an edit, and a `scheduled` or `published` post cannot be edited.
- Files that are `image/*` or `video/*`. Nothing else is accepted — this publishes to social networks.
- Each asset no larger than **512 MB**.
- A post carries at most **20** attachments, and a channel may accept fewer. Check `maxMedia` and `supportsVideo` on the channel in [Channels](/acquire/content/channels) before you attach.

## Option A: from a URL

Choose this path for a file available at a public `http(s)` URL.

1. Create the asset from its URL:

   ```text
   content_media_upload
     sourceUrl: "https://example.com/screenshot.png"
   ```

   Content downloads the file server-side. The source must provide `Content-Type` and a nonempty image or video no larger than 512 MB.

2. Read the asset back until its `status` is `ready`:

   ```text
   content_media_get
     id: <asset id>
   ```

   If the fetch fails, the asset has `status: "failed"` and an `error` explaining the failure. Check those fields before continuing; a failed fetch returns an asset instead of raising an error.

## Option B: by signed upload

Choose this path to upload bytes directly from your app.

1. Request an upload URL with the file’s media type:

   ```text
   content_media_create_upload_url
     contentType: "image/png"
   ```

   The response includes a pending asset, signed `uploadUrl`, `expiresAt` and `contentType`. Upload before expiry and use that exact content type: it is part of the signature.

2. `PUT` the bytes straight to that URL, with that exact `Content-Type` header.

3. Finalize the upload:

   ```text
   content_media_finalize
     id: <asset id>
   ```

   Finalization checks the stored object and marks the asset `ready`. Calling it before the upload exists fails.

## Attach it

```text
content_posts_attach_media
  id: <post id>
  mediaIds: [<asset id>]
```

Pass 1 to 20 asset ids in display order. To remove assets, call `content_posts_detach_media`; the remaining assets keep their order.

Every asset must be `ready` and belong to the post’s product. If either condition fails, the post stays unchanged. An asset already attached to the post is not added again.

Attaching and detaching both create a version. Complete these changes before submission: changing an approved post returns it to `pending_approval` and requires another approval. See [Posts](/acquire/content/posts).

## Check it worked

```text
content_publications_validate
  postId: <post id>
  channels: [{ channelId: <channel id> }]
```

Check for media issues in the validation result; `ok: true` means no issue blocks scheduling. The media issue codes are `media_missing` for an asset that does not exist, `media_not_ready` for one that is not stored yet, `media_not_supported` for one the network will not take, `too_many_media` for more attachments than the channel accepts, and `video_required` when the selected post type requires video, including YouTube uploads, but none is attached.

## The shape of an asset

| Field | What it is |
|---|---|
| `id` | Media asset id — this is what you attach to a post |
| `key` | Object key in the product bucket. Private; it is not a URL |
| `mime` | Stored media type, or `null` while pending |
| `sizeBytes` | Stored size, or `null` while pending |
| `status` | `pending`, `ready` or `failed` |
| `source` | `url` (fetched by Content) or `upload` (sent by your app) |
| `sourceUrl` | The URL the bytes were fetched from, if any |
| `filename` | The original filename, kept for display |
| `error` | Why the asset failed, or `null` |
| `createdAt`, `updatedAt` | ISO-8601 timestamps |

## Listing and storage

`content_media_list` returns assets newest first, with `total` counting every match regardless of paging. Filter by `status`; page with `limit` (1 to 100, default 50) and `offset`.

Storage metering uses the latest total bytes stored for the product. Each reading is a complete total, so do not add successive readings together.

## Tools

| Tool | What it does |
|---|---|
| `content_media_upload` | Ingest media from a public `http(s)` URL |
| `content_media_create_upload_url` | Get a signed PUT URL and a pending asset |
| `content_media_finalize` | Confirm a signed upload and make the asset ready |
| `content_media_get` | Read one asset |
| `content_media_list` | List assets, newest first |
| `content_posts_attach_media` | Attach ready assets to a post |
| `content_posts_detach_media` | Remove assets from a post |

## Next steps

- [Channels](/acquire/content/channels): `maxMedia`, `supportsVideo` and `supportsLink`, per network.
- [Publications](/acquire/content/publications): the rest of the validation report.
