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

# Upload reference photos and voice clips

> Upload face photos, body photos and a voice clip to Omnichar with POST /v1/uploads and a signed PUT, then pass each upload_key as a character reference.

A character is built from files you upload first. Each file takes two requests: `POST /v1/uploads` returns a short-lived upload link, and a `PUT` sends the bytes to it. The `upload_key` from the first answer is what you pass in `references` when you [create a character](https://docs.omnichar.org/api/characters.md).

## 1. Ask for an upload link

`POST /v1/uploads` · scope `chars:write`

```bash theme={null}
curl -s -X POST https://api.omnichar.org/v1/uploads \
  -H "Authorization: Bearer $OMNICHAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kind":"image","contentType":"image/jpeg","bytes":'$(wc -c < face-1.jpg)',"name":"face-1.jpg"}'
```

```json theme={null}
{
  "upload_key": "inputs/9b2e.../4c1d.../face-1.jpg",
  "uploadUrl": "https://...",
  "headers": { "content-type": "image/jpeg" },
  "expiresAt": "2026-11-09T10:12:00.000Z",
  "thumb": null,
  "r2Key": "inputs/9b2e.../4c1d.../face-1.jpg"
}
```

| Field | Required | Notes |
| - | - | - |
| `kind` | yes | `image` for face and body photos, `audio` for the voice clip. |
| `contentType` | yes | One of the types in the table below, matching `kind`. |
| `bytes` | yes | The file's exact size in bytes. The link only accepts a file of this size. |
| `name` | no | The original file name, up to 200 characters. It is cleaned and kept in the key so you can recognise the file later. |

`r2Key` holds the same value as `upload_key` and is deprecated; read `upload_key`. `thumb` is a second link the dashboard uses for previews; it is `null` unless you send `thumbBytes`, which you don't need.

## 2. Send the bytes

`PUT` the file to `uploadUrl` with exactly the headers from `headers`, before `expiresAt` (10 minutes):

```bash theme={null}
curl -s -X PUT "$UPLOAD_URL" -H "content-type: image/jpeg" --data-binary @face-1.jpg
```

The link is signed for one key, one content type and one size. A different `content-type`, a file of another size, or a late request is refused, and you ask for a new link. No API key goes on the `PUT`: the link carries its own permission, so treat it as a secret until it expires.

## 3. Use the upload key

Pass each `upload_key` in `references`, as a string or as `{ "upload_key": "..." }`:

```json theme={null}
"references": {
  "face": ["inputs/.../face-1.jpg", "inputs/.../face-2.jpg", "inputs/.../face-3.jpg", "inputs/.../face-4.jpg"],
  "body": ["inputs/.../full-length.jpg"],
  "voice": "inputs/.../voice.mp3"
}
```

Finish every `PUT` before you create the character. The API does not check that the bytes arrived: in production a reference that was never sent fails the build, and you get `char.failed`; sandbox does not notice at all.

## What you can upload

| Reference | `kind` | `contentType` | Count | Size |
| - | - | - | - | - |
| `face` | `image` | `image/jpeg`, `image/png`, `image/webp` | 4 to 12 | up to 1 GB each |
| `body` | `image` | `image/jpeg`, `image/png`, `image/webp` | 0 to 12 | up to 1 GB each |
| `voice` | `audio` | `audio/mpeg`, `audio/wav`, `audio/mp4`, `audio/aac`, `audio/flac`, `audio/ogg` | 0 or 1 | up to 50 MB |

A character takes at most 20 files across face, body and voice, so 12 face and 12 body photos together are refused. About 30 seconds of clear speech is enough for a voice. For which photos make a good character, see [Finetuning](https://docs.omnichar.org/cloud/finetuning.md).

## Who can use an upload

An API key acts as the person who created it, and an upload belongs to that person. Uploads made with one key can be used by any key that same person created, in either environment. A key created by a teammate cannot use them, and the character request answers `422` naming the reference, for example `references.face[0] is not one of your uploads`.

Rotating a key from Settings makes the new key act as whoever rotated it. If a teammate rotates your key, upload the files again with the new key.

## Storage

Uploads are stored files, with sandbox keys too, and count toward your team's storage allowance. When an upload would go over it, `POST /v1/uploads` answers `409` with the code `storage_limit_reached`.

## Upload a folder of photos

This uploads every `.jpg` in a folder and prints the keys as a JSON array, ready for `references.face`. It needs `jq`.

```bash theme={null}
for f in faces/*.jpg; do
  grant=$(curl -s -X POST https://api.omnichar.org/v1/uploads \
    -H "Authorization: Bearer $OMNICHAR_API_KEY" \
    -H "Content-Type: application/json" \
    -d "{\"kind\":\"image\",\"contentType\":\"image/jpeg\",\"bytes\":$(wc -c < "$f"),\"name\":\"$(basename "$f")\"}")
  curl -sf -X PUT "$(jq -r .uploadUrl <<< "$grant")" -H "content-type: image/jpeg" --data-binary @"$f" > /dev/null \
    && jq -r .upload_key <<< "$grant"
done | jq -R . | jq -s .
```

The same in Node 18 or later:

```js theme={null}
import { readFile } from 'node:fs/promises'
import { basename } from 'node:path'

const API = 'https://api.omnichar.org/v1'
const auth = { Authorization: `Bearer ${process.env.OMNICHAR_API_KEY}` }

export async function upload(path, contentType, kind = 'image') {
  const bytes = await readFile(path)
  const grant = await fetch(`${API}/uploads`, {
    method: 'POST',
    headers: { ...auth, 'Content-Type': 'application/json' },
    body: JSON.stringify({ kind, contentType, bytes: bytes.length, name: basename(path) }),
  }).then((res) => (res.ok ? res.json() : Promise.reject(new Error(`upload link: ${res.status}`))))
  const put = await fetch(grant.uploadUrl, { method: 'PUT', headers: grant.headers, body: bytes })
  if (!put.ok) throw new Error(`upload: ${put.status}`)
  return grant.upload_key
}
```

## Errors

| Status | Code | When |
| - | - | - |
| `403` | `forbidden` | The key lacks `chars:write`. |
| `409` | `storage_limit_reached` | The file would take your team over its storage allowance. |
| `422` | `unprocessable` | An unknown `kind`, a `contentType` that does not match it, or a size over the limit. |

See [Errors](https://docs.omnichar.org/api/errors.md) for the shape of an error body.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.