<!-- https://cutedyno.com/docs/guides/media-uploads -->

# Media uploads

Get images and video into CuteDyno, and what each platform accepts.

CuteDyno does not accept file bytes through the API. You ask for a presigned URL, upload the file straight to storage, and then reference the resulting public URL in a post. That keeps large video uploads off the API path, where they would be slow and prone to timeouts.

## The short version

```typescript
import { readFile } from 'node:fs/promises';

const { publicUrl } = await cutedyno.media.upload({
  fileName: 'launch.mp4',
  fileType: 'video/mp4',
  data: await readFile('./launch.mp4'),
});

await cutedyno.posts.create({
  content: 'Six months of work, live today.',
  media: [{ type: 'video', url: publicUrl }],
  accountIds: ['3f2a…'],
  publishNow: true,
});
```

`media.upload` does the presign and the transfer in one call. Use it unless you need to manage the transfer yourself — a browser upload with a progress bar, or a resumable upload for a large file on a bad connection.

## Doing it manually

Two steps. First, presign:

```bash
curl https://api.cutedyno.com/v1/media/upload \
  -H "Authorization: Bearer $CUTEDYNO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "fileName": "launch.mp4", "fileType": "video/mp4" }'
```

```json
{
  "signedUrl": "https://storage.cutedyno.com/…?X-Amz-Signature=…",
  "publicUrl": "https://storage.cutedyno.com/workspace-…/videos/1754049600000-….mp4",
  "key": "workspace-…/videos/1754049600000-….mp4",
  "expiresIn": 3600,
  "maxBytes": 104857600
}
```

Then `PUT` the bytes, with the same `Content-Type` you presigned with:

```bash
curl -X PUT "$SIGNED_URL" \
  -H "Content-Type: video/mp4" \
  --data-binary @launch.mp4
```

A mismatched `Content-Type` fails the signature check, which shows up as a `403` from storage rather than an error from CuteDyno. If a `PUT` is rejected, check that header first.

`publicUrl` works as soon as the `PUT` returns. You do not need to wait or poll.

### Uploading from a browser

Presign on your server — never ship an API key to a browser — and hand the client just `signedUrl` and `publicUrl`:

```typescript
await fetch(signedUrl, {
  method: 'PUT',
  headers: { 'Content-Type': file.type },
  body: file,
});
```

The signed URL grants exactly one upload to one key, for one hour, so it is safe to give to a browser. Presign at the moment the user picks a file, not when the page loads, or the URL may expire while they are still filling in a caption.

## Accepted files

| Kind | Types |
| --- | --- |
| Images | JPEG, PNG, WebP, GIF |
| Video | MP4, QuickTime (`.mov`), WebM, AVI |

Maximum 100 MB per file. Anything else is rejected with `unsupported_media_type` before you waste bandwidth on it.

MP4 with H.264 video and AAC audio is the safest choice by a wide margin. Every platform accepts it; the others get transcoded, re-encoded, or occasionally refused depending on the codec inside the container.

## Attaching media to a post

The `media` array holds items of `{ type, url }`:

```typescript
await cutedyno.posts.create({
  content: 'Three shots from the shoot.',
  media: [
    { type: 'image', url: firstUrl },
    { type: 'image', url: secondUrl },
    { type: 'image', url: thirdUrl },
  ],
  accountIds,
});
```

The post's content type is inferred, not declared. A video in the array makes it a video post; otherwise images make it an image post; otherwise it is text. Do not mix a video and images in one post — the images are ignored, and the result is not what you meant. Send two posts.

Media must be reachable by the platforms, which fetch the URL themselves. URLs returned by `/v1/media/upload` always are. Your own URLs work too, as long as they are public HTTPS with no signed-URL expiry and no hotlink protection; a URL that 403s for a platform's fetcher produces a publish failure that looks mysterious from your side.

## Platform limits worth knowing

These are enforced by the platforms, at publish time, not by CuteDyno at upload time. A file that uploads fine can still fail to publish.

| Platform | Limits |
| --- | --- |
| Instagram | Up to 10 images in a carousel. Video becomes a Reel. Aspect ratio between 4:5 and 1.91:1 for feed images. |
| Facebook | Multiple images supported. Video up to 240 minutes in theory, far less in practice. |
| TikTok | Video only, plus photo posts. Maximum duration depends on the creator's account, not on your app — CuteDyno reads it per account and rejects longer videos before uploading. |
| LinkedIn | Multiple images supported. Video between 3 seconds and 30 minutes. |
| YouTube | Video only, and `platforms.youtube.title` is required. Unverified channels are capped at 15 minutes. |

Call [`POST /v1/posts/validate`](/docs/api/validate-post) before publishing to a multi-platform set. It catches structural problems — a video sent to a text-only target, a missing YouTube title — without spending an upload.

For the full per-platform picture, see the [platform guides](/docs/platforms).

## Reusing an upload

`publicUrl` is stable and permanent. Upload a brand asset once, store the URL, and reference it across as many posts as you like. There is no need to re-upload the same file for each post or each account.

Files are stored per profile. Uploading with `profileId` set puts the file in that customer's namespace, which is what you want on a multi-tenant platform so one customer's assets never appear in another's media library.

## Next

- [Platform guides](/docs/platforms) — media specs and options per platform
- [Connecting accounts](/docs/guides/connecting-accounts) — get accounts to post to
- [Errors](/docs/errors) — every code, including `unsupported_media_type`
