# Media

Upload images and videos, import them from a link or ask a person for them, then put them on a post.

A file goes up first, as media. A post then names it by id in `mediaIds`. Adding media needs the scope `media:write` (`create_media` in MCP).

Rizamo takes `image/jpeg`, `image/png`, `image/webp`, `video/mp4` and `video/quicktime`, up to 4 GB each.

## Upload a file [#upload-a-file]

<Steps>
<Step>

### Say what you will send [#say-what-you-will-send]

```bash
curl https://api.rizamo.com/v1/media \
  -H "Authorization: Bearer $RIZAMO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "contentType": "image/jpeg", "size": 482113, "alt": "A loaf of bread on the counter" }'
```

`size` is the file's exact size in bytes (`wc -c < photo.jpg`). The answer is the media, still `uploading`, and where to send the file:

```json
{
  "media": { "id": "med_...", "type": "image", "status": "uploading", "contentType": "image/jpeg", "size": 482113 },
  "upload": {
    "parts": [{ "url": "https://api.rizamo.com/api/media/upload/...", "method": "PUT", "headers": { "content-type": "image/jpeg" }, "size": 482113 }],
    "complete": null,
    "expiresAt": "2026-11-04T16:00:00.000Z"
  }
}
```

</Step>
<Step>

### Send the file [#send-the-file]

```bash
curl -X PUT "https://api.rizamo.com/api/media/upload/..." \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg
```

The answer is the media, `ready`, with its `width` and `height` (and `duration`, for a video). It can go on a post now.

</Step>
</Steps>

- Upload links are signed, so they take no API key, and work for 6 hours (`expiresAt`). An expired one answers `403`: add the media again for new links.
- Send each part with the `headers` it lists. A `Content-Type` other than the one you declared answers `415`, and a request without `Content-Length` answers `411` (curl and `fetch` send it). A file larger than the `size` you declared answers `413`.
- Sending files to these links does not count toward the [limits](/limits).

### Large files [#large-files]

A file up to 64 MB is one part. A larger one comes in parts of 64 MB, the last one with what is left:

- The first part takes the file's first `size` bytes, the second the next, and so on. Each part must be exactly its `size`, or it answers `400`.
- Parts can go up in any order, or at the same time.
- Once every part is up, `POST` to `complete.url`: the answer is the media, `ready`. With a part missing it answers `409` and names it.

```ts title="upload.ts"
import { openAsBlob } from "node:fs";

const file = await openAsBlob("video.mp4");

const res = await fetch("https://api.rizamo.com/v1/media", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RIZAMO_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({ contentType: "video/mp4", size: file.size }),
});
if (!res.ok) throw new Error(await res.text());
const { media, upload } = await res.json();

let start = 0;
for (const part of upload.parts) {
  const sent = await fetch(part.url, { method: part.method, headers: part.headers, body: file.slice(start, start + part.size) });
  if (!sent.ok) throw new Error(await sent.text());
  start += part.size;
}
if (upload.complete) {
  const done = await fetch(upload.complete.url, { method: upload.complete.method });
  if (!done.ok) throw new Error(await done.text());
}

console.log(media.id);
```

## Import from a link [#import-from-a-link]

```bash
curl https://api.rizamo.com/v1/media \
  -H "Authorization: Bearer $RIZAMO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "url": "https://example.com/photos/bread.jpg", "alt": "A loaf of bread on the counter" }'
```

Rizamo downloads the file during the call, and the answer's media is `ready` (`upload` is `null`). The address must:

- be a public `http` or `https` address, with at most 3 redirects;
- answer with the file's `Content-Type`, one of the types above, and its `Content-Length`;
- send the whole file within 30 seconds.

Otherwise the call answers `400` and says why. For large videos, or a slow host, upload the file instead.

## States [#states]

| `status` | Meaning |
| --- | --- |
| `uploading` | Waiting for its file |
| `ready` | Can go on a post |
| `failed` | Rizamo could not read the file, such as a damaged video: add it again |

Only `ready` media can go on a post. `GET /v1/media/{id}` (`get_media`) shows it, with `url`, a link to see the file that works for a few hours: get the media again for a fresh one.

## Who reaches it, and how long it stays [#who-reaches-it-and-how-long-it-stays]

- Media is yours (the person, key or agent that added it) until a post uses it. It then belongs to that post's profile. Send `profileId` to put it in a profile at once.
- A key or person limited to some profiles reaches their own media and their profiles' media.
- Media no post uses is deleted 7 days after it was added.

## Ask a person for files [#ask-a-person-for-files]

When your agent or integration does not have the files, Rizamo makes a page where a person adds them, with no account (`create_upload` in MCP):

```bash
curl https://api.rizamo.com/v1/uploads \
  -H "Authorization: Bearer $RIZAMO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "note": "Photos of the new menu", "maxFiles": 5, "profileId": "prof_..." }'
```

- Give the person `url`: only this answer has it. The page works for 24 hours.
- `maxFiles` is 1 to 20, 10 when left out. `note` is shown on the page, with the name of the person who asked (for a key or an agent, the person who made or connected it) and the profile, and a line saying a Rizamo customer sent it.
- Follow it with `GET /v1/uploads/{id}` (`get_upload`): `status` is `open`, `done` once the person sends their files, or `expired`. Its `media` are ready to put on a post.

## What Rizamo changes for each network [#what-rizamo-changes-for-each-network]

Rizamo checks each file against every target's network when the post is made, so most file problems are refused then, not when it goes out.

- **Refused when attached:** a video too short or too long for the format, a wide video for a format that takes only vertical video (Facebook reels and stories and YouTube Shorts), or files of a type or number the format does not take.
- **Fixed with a copy:** a type the network does not take (Instagram takes only JPEG images), a file too large or too wide, an Instagram feed image of a shape Instagram refuses (`options.instagram.fit`: `fill` or `crop`), a video's codec, frame rate or header. The copy is kept beside your file, which stays as it is.
- Each target's `changes`, in `POST /v1/posts/validate` and in the post, say what each copy changes: `format`, `size`, `width`, `fill`, `crop`, `fps` or `faststart`.
- A video copy can take a few minutes. Rizamo starts it 2 hours before a scheduled post's time. A post published now waits for its copy, and its [publishing window](/publishing#when-it-goes-out) starts once the copy is ready.

Each network's own rules: [Instagram](/channels/instagram), [Facebook](/channels/facebook), [TikTok](/channels/tiktok), [Threads](/channels/threads), [YouTube](/channels/youtube).

## From upload to post [#from-upload-to-post]

<Steps>
<Step>

### Add the image [#add-the-image]

Upload it as above, or import it from a link. Keep its `media.id`.

</Step>
<Step>

### Check the post [#check-the-post]

```bash
curl https://api.rizamo.com/v1/posts/validate \
  -H "Authorization: Bearer $RIZAMO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "profileId": "prof_...",
    "targets": [{ "accountId": "acc_..." }],
    "text": "Fresh bread at 9.",
    "mediaIds": ["med_..."],
    "status": "scheduled",
    "scheduledAt": "2027-06-01T09:00:00Z"
  }'
```

`ok` is `true` when creating it would work. `problems` lists everything that would stop it, and `targets[].changes` what Rizamo does to the image for each network.

</Step>
<Step>

### Schedule it [#schedule-it]

```bash
curl https://api.rizamo.com/v1/posts \
  -H "Authorization: Bearer $RIZAMO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "profileId": "prof_...",
    "targets": [{ "accountId": "acc_..." }],
    "text": "Fresh bread at 9.",
    "mediaIds": ["med_..."],
    "status": "scheduled",
    "scheduledAt": "2027-06-01T09:00:00Z"
  }'
```

`scheduledAt` must be in the future. Once the profile has posting times ([Posting queues](/queues)), send `"queue": true` instead to take the next free one; without them it answers `409` `queue_empty`. Keep the same `Idempotency-Key` if you send it again: the post is not created twice.

</Step>
</Steps>
