# Media uploads

Upload an image, video or document with a presigned URL and attach it to a post with mediaItems.

import { Tab, Tabs } from 'fumadocs-ui/components/tabs';

Upload a file (up to 5 GB) to storage through a presigned URL and attach it to a post. Files already on public HTTPS skip the upload ([requirements](#how-it-behaves)).

## Before you start

You need an API key and an `accountId` ([quickstart](/quickstart)). 3 calls:

1. `POST /v1/media/presign`
2. A `PUT` of the file
3. `POST /v1/posts` with the returned `publicUrl` in `mediaItems`

## Get an upload URL

Call `POST /v1/media/presign` with `filename` and `contentType`. Pass `size` in bytes to check the 5 GB limit before you upload.

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
import { readFile } from 'node:fs/promises';
import Zernio from '@zernio/node';

const zernio = new Zernio();

const { data: presigned } = await zernio.media.getMediaPresignedUrl({
  body: {
    filename: 'photo.jpg',
    contentType: 'image/jpeg'
  }
});

const { uploadUrl, publicUrl } = presigned;
```
</Tab>
<Tab value="Python">
```python
import httpx
from zernio import Zernio

client = Zernio()

presigned = client.media.get_media_presigned_url(
    filename="photo.jpg",
    content_type="image/jpeg"
)

upload_url = presigned["uploadUrl"]
public_url = presigned["publicUrl"]
```
</Tab>
<Tab value="curl">
```bash
curl -X POST "https://zernio.com/api/v1/media/presign" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "filename": "photo.jpg",
    "contentType": "image/jpeg"
  }'
```
</Tab>
</Tabs>

Response (`200`):

```json
{
  "uploadUrl": "https://<bucket>.r2.cloudflarestorage.com/temp/1234567890_abc123_photo.jpg?X-Amz-Signature=...",
  "publicUrl": "https://media.zernio.com/temp/1234567890_abc123_photo.jpg",
  "key": "temp/1234567890_abc123_photo.jpg",
  "expiresIn": 3600
}
```

- **`uploadUrl`**: valid for `expiresIn` seconds (1 hour).
- **`publicUrl`**: the address the post uses.

## Upload the file

`PUT` the file to `uploadUrl` with the same `Content-Type`. It goes to storage, not the API, so no `Authorization` header.

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
const fileBuffer = await readFile('photo.jpg');

await fetch(uploadUrl, {
  method: 'PUT',
  headers: { 'Content-Type': 'image/jpeg' },
  body: fileBuffer
});
```
</Tab>
<Tab value="Python">
```python
with open("photo.jpg", "rb") as f:
    httpx.put(upload_url, content=f.read(), headers={"Content-Type": "image/jpeg"})
```
</Tab>
<Tab value="curl">
```bash
# UPLOAD_URL is the uploadUrl from Step 1
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg
```
</Tab>
</Tabs>

Storage answers an empty `200` with an `ETag` header, never JSON. The file is now at `publicUrl`.

## Attach it to a post

Call `POST /v1/posts` with `publicUrl` in `mediaItems`. `type` is `image`, `video`, `gif` or `document`, inferred from the file extension when omitted.

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
const { data: created } = await zernio.posts.createPost({
  body: {
    content: 'Golden hour at the pier.',
    mediaItems: [
      { url: publicUrl, type: 'image' }
    ],
    platforms: [
      { platform: 'linkedin', accountId: '66b2e19d8c3f5a7e9d0b1c2d' }
    ],
    scheduledFor: '2027-01-01T12:00:00',
    timezone: 'America/New_York'
  }
});

console.log(created.post._id);
```
</Tab>
<Tab value="Python">
```python
created = client.posts.create_post(
    content="Golden hour at the pier.",
    media_items=[
        {"url": public_url, "type": "image"}
    ],
    platforms=[
        {"platform": "linkedin", "accountId": "66b2e19d8c3f5a7e9d0b1c2d"}
    ],
    scheduled_for="2027-01-01T12:00:00",
    timezone="America/New_York"
)

print(created["post"]["_id"])
```
</Tab>
<Tab value="curl">
```bash
curl -X POST "https://zernio.com/api/v1/posts" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Golden hour at the pier.",
    "mediaItems": [
      { "url": "https://media.zernio.com/temp/1234567890_abc123_photo.jpg", "type": "image" }
    ],
    "platforms": [
      { "platform": "linkedin", "accountId": "66b2e19d8c3f5a7e9d0b1c2d" }
    ],
    "scheduledFor": "2027-01-01T12:00:00",
    "timezone": "America/New_York"
  }'
```
</Tab>
</Tabs>

Response (`201`):

```json
{
  "message": "Post scheduled successfully",
  "post": {
    "_id": "65f1c0a9e2b5af0012ab34cd",
    "status": "scheduled",
    "scheduledFor": "2027-01-01T17:00:00Z",
    "mediaItems": [
      { "url": "https://media.zernio.com/temp/1234567890_abc123_photo.jpg", "type": "image" }
    ],
    "platforms": [
      { "platform": "linkedin", "status": "pending" }
    ]
  }
}
```

## Send different media to one platform

`mediaItems` attaches to every platform in the request. Set `customMedia` on one entry to replace it for that platform.

```json
{
  "content": "Same text, different media per platform",
  "mediaItems": [{ "url": "https://media.zernio.com/temp/1234567890_abc123_photo.jpg", "type": "image" }],
  "platforms": [
    { "platform": "linkedin", "accountId": "66b2e19d8c3f5a7e9d0b1c2d" },
    {
      "platform": "instagram",
      "accountId": "66b2e19d8c3f5a7e9d0b1c2e",
      "customMedia": [{ "url": "https://media.zernio.com/temp/1234567890_abc123_square.jpg", "type": "image" }]
    }
  ]
}
```

## How it behaves

- **Uploads expire after 7 days.** A post created or updated with the `publicUrl` copies the file to permanent storage, so posts scheduled or queued months ahead keep their media.
- **`mediaItems[].url`** must be public HTTPS and return the file itself. Google Drive, Dropbox, OneDrive and iCloud links return an HTML page platforms cannot fetch. **Test a URL** in an incognito window: a webpage means the post will fail.
- **`contentType`** must be a MIME type the [presign endpoint](/media/get-media-presigned-url) accepts (table below).
- **Audio** has no `mediaItems[].type`, so no posts. Send its `publicUrl` as `attachmentUrl` with `attachmentType: "audio"` on [send inbox message](/messages/send-inbox-message).
- **Size.** Presign caps every type at 5 GB, none smaller. Platforms are stricter (a LinkedIn document: PDF, 100 MB or less); count, size, duration and aspect ratio limits are under "Media requirements" on each [platform page](/platforms).
- **Compression.** Images and videos above a platform's limit are compressed; videos over 200 MB may not be. Bluesky images are recompressed under its 1 MB blob limit.
- **Custom thumbnail** in `mediaItems[].thumbnail` (JPG or PNG, 10 MB max): Facebook video posts, Facebook Reels, YouTube videos and LinkedIn video posts.

| Type | Formats |
|------|---------|
| Images | JPEG, PNG, GIF, WebP |
| Videos | MP4, MPEG, MOV, AVI, WebM, M4V |
| Documents | PDF (LinkedIn only) |
| Audio | MP3, M4A, AAC, OGG, WAV, WebM |

## If it fails

| Error | Cause | Fix |
|---|---|---|
| `400` on `POST /v1/media/presign` | Missing `filename`, unaccepted `contentType` (such as `application/octet-stream`), or `size` above 5 GB. | Send the file's real MIME type (`image/jpeg`, `video/mp4`, `application/pdf`). |
| `400` on `POST /v1/posts` | `type` contradicts the file extension. | Fix or omit `type`. |
| `400` creating a scheduled, queued or `publishNow` post | Upload older than 7 days. A draft saves, but scheduling or publishing it returns `400`. | Re-upload; create the post within 7 days. |
| `PUT` refused by storage | `uploadUrl` older than 1 hour. | Request a new one. |

Example `400` from presign:

```json
{
  "error": "Invalid contentType",
  "type": "invalid_request_error",
  "code": "invalid_field_value",
  "param": "contentType"
}
```

## Related

- [Presign endpoint](/media/get-media-presigned-url): every parameter and accepted MIME type.
- [Create post](/posts/create-post): `mediaItems`, `customMedia`, `altText` and `thumbnail`.
- [Platforms](/platforms): per-platform media requirements.
- [Multi-tenant publishing](/multi-tenant/publishing): uploading once for many customers.