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

# Uploading media

> Stream large files, or inline small ones as base64.

There are two ways to attach media to a post. Pick by size.

|              | Streaming upload                                                     | Inline base64                             |
| ------------ | -------------------------------------------------------------------- | ----------------------------------------- |
| **How**      | `PUT /socials/media/{projectId}/stream-upload`, then pass the handle | `mediaItems` on the create-post body      |
| **Good for** | Video, anything over a few MB                                        | Small images                              |
| **Limit**    | 10 MB images, 100 MB video                                           | Bounded by request memory — keep it small |

<Tip>
  Default to the streaming upload. Base64 inflates a payload by about a third and
  the whole thing has to be held in memory at once, so it stops working well
  before the documented size limits.
</Tip>

## Streaming upload

Send the raw bytes as the request body with the file's own `Content-Type`. Not
multipart, not base64 — the bytes themselves.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X PUT \
    "https://api.sapt.ai/socials/media/$PROJECT_ID/stream-upload?filename=reel.mp4" \
    -H "Authorization: ApiKey $SAPT_API_KEY" \
    -H "Content-Type: video/mp4" \
    --data-binary @reel.mp4
  ```

  ```typescript TypeScript theme={null}
  import { createReadStream, statSync } from 'node:fs'

  const res = await fetch(
    `https://api.sapt.ai/socials/media/${projectId}/stream-upload?filename=reel.mp4`,
    {
      method: 'PUT',
      headers: {
        Authorization: `ApiKey ${process.env.SAPT_API_KEY}`,
        'Content-Type': 'video/mp4',
        'Content-Length': String(statSync('reel.mp4').size),
      },
      body: createReadStream('reel.mp4'),
      duplex: 'half',
    }
  )

  const { data } = await res.json()
  const handle = data.handle // → uploadedHandles on create-post
  ```

  ```python Python theme={null}
  with open("reel.mp4", "rb") as f:
      res = requests.put(
          f"https://api.sapt.ai/socials/media/{project_id}/stream-upload",
          params={"filename": "reel.mp4"},
          headers={
              "Authorization": f"ApiKey {api_key}",
              "Content-Type": "video/mp4",
          },
          data=f,
      )
  handle = res.json()["data"]["handle"]
  ```
</CodeGroup>

```json Response theme={null}
{
  "success": true,
  "data": {
    "handle": { "stagingId": "stg_01JQ…", "ext": "mp4" },
    "preview": {
      "r2Url": "https://assets.sapt.ai/staging/stg_01JQ….mp4",
      "contentType": "video/mp4",
      "sizeBytes": 18446721
    }
  }
}
```

Pass the whole `handle` object — both fields — in `uploadedHandles` when you
create the post:

```json theme={null}
{
  "socialAccountId": "9f1c2f7a-…",
  "platform": "instagram",
  "mediaType": "REELS",
  "caption": "Behind the scenes.",
  "uploadedHandles": [{ "stagingId": "stg_01JQ…", "ext": "mp4" }],
  "scheduledFor": "2026-09-10T15:00:00Z",
  "timezone": "America/New_York"
}
```

`preview.r2Url` is a real URL you can show in a composer before the post exists.
It is a staging location, not the published permalink.

## Inline base64

For small images, skip the upload round trip:

```json theme={null}
{
  "socialAccountId": "9f1c2f7a-…",
  "platform": "instagram",
  "mediaType": "IMAGE",
  "caption": "Morning light.",
  "mediaItems": [
    {
      "base64": "iVBORw0KGgoAAAANSUhEUg…",
      "filename": "morning.jpg",
      "mimeType": "image/jpeg"
    }
  ],
  "publishNow": true
}
```

Send the base64 payload only — no `data:image/jpeg;base64,` prefix.

## Carousels

Order is the order you supply. Sources are concatenated as
`uploadedHandles` then `mediaItems`, so don't split one carousel across both
unless you want that ordering.

```json theme={null}
{
  "mediaType": "CAROUSEL",
  "uploadedHandles": [
    { "stagingId": "stg_a…", "ext": "jpg" },
    { "stagingId": "stg_b…", "ext": "jpg" },
    { "stagingId": "stg_c…", "ext": "jpg" }
  ]
}
```

## Video covers

Pass `coverImage` to choose a video's thumbnail. Same base64 shape as
`mediaItems`:

```json theme={null}
{
  "mediaType": "REELS",
  "uploadedHandles": [{ "stagingId": "stg_01JQ…", "ext": "mp4" }],
  "coverImage": {
    "base64": "iVBORw0KGgo…",
    "filename": "cover.jpg",
    "mimeType": "image/jpeg"
  }
}
```

## Validation

Media is checked against the target platform and `mediaType` **before** the post
is accepted — dimensions, duration, file size, and count. A rejection comes back
as `400` naming the offending item:

```json theme={null}
{
  "error": {
    "code": "BAD_REQUEST",
    "message": "Media 2: video must be at most 60 seconds for STORIES"
  }
}
```

## Removing media from a post

While a post is still editable, drop one item without touching the rest:

```bash theme={null}
curl -X DELETE \
  "https://api.sapt.ai/socials/posts/$PROJECT_ID/$POST_ID/media/$MEDIA_ID" \
  -H "Authorization: ApiKey $SAPT_API_KEY"
```

`mediaId` comes from the `media[]` array on the post. This deletes the stored
blob as well as the row.
