# Media

7 endpoints.

## Create a media upload URL

```http
POST https://orbitwrite.com/api/v1/media/create-upload-url
```

Creates a media record and returns a short-lived signed URL to upload the file to.

Bytes go straight from your client to storage — they never pass through this API.

**1. Request an upload URL**

```js
const res = await fetch("https://<host>/api/v1/media/create-upload-url", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: "Bearer orbit_sk_…",
  },
  body: JSON.stringify({
    name: "photo.jpg",
    mime_type: "image/jpeg",
    size_bytes: 123456,
  }),
});
const { media_id, upload_url } = await res.json();
```

**2. PUT the file to the signed URL**

```js
await fetch(upload_url, {
  method: "PUT",
  headers: { "Content-Type": "image/jpeg" },
  body: file,
});
```

The `Content-Type` and byte length are part of the signature: send exactly the type and size you declared, or storage rejects the PUT.

**3. Use `media_id`** wherever a post accepts an attachment.

There's no bulk endpoint — request one URL per file.

**Authentication:** bearer token or session cookie.

### Request body (`application/json`)

- `name` (string, required) — Original file name — kept for display and its extension.
- `mime_type` (string, required, one of `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `video/mp4`, `application/pdf`)
- `size_bytes` (integer, required) — Exact byte length of the file. Part of the signature — the PUT must send this many bytes with this Content-Type or storage rejects it.
- `organization_id` (string, optional) — Required for session callers without an active organization. Ignored for API keys, which are already scoped to one workspace.
- `alt_text` (string, optional)
- `title` (string, optional) — PDF documents only — LinkedIn requires a document title.
- `width` (integer, optional)
- `height` (integer, optional)
- `duration_sec` (integer, optional)
- `page_count` (integer, optional)

### Responses

#### 200

Media record created and upload URL signed

- `media_id` (string, required)
- `upload_url` (string, required) — Signed PUT URL. Send the raw bytes with the same Content-Type you declared. Expires — upload promptly.
- `name` (string, required)
- `expires_at` (string, required) — ISO 8601.
- `media` (Media, required)
  - `id` (string, required)
  - `name` (string, required)
  - `mime_type` (string, required)
  - `size_bytes` (integer, required) — Storage consumed by this asset, and what the usage meter sums. For a Stream video that is every rendition plus the HLS segments — several times the size of the single file `url` serves, which is `rendition_bytes`.
  - `rendition_bytes` (integer | null, required) — Byte length of the file at `url`. Video only, and null until the transcode has finished and it could be measured — an unmeasured video is not the same as an empty one. This is the number a platform's video budget applies to; `size_bytes` is not.
  - `status` (string, required, one of `pending`, `processing`, `uploaded`) — `pending` until the upload has been confirmed to have landed. Confirmation happens lazily when the record is read, so a freshly uploaded file reports `uploaded` from the first GET after the upload completes. `processing` applies to video only: the bytes have arrived but Bunny Stream is still transcoding, so no playable URL exists yet — treat it as not-yet-usable rather than as a failure.
  - `type` (string, required, one of `image`, `video`, `document`, `other`)
  - `url` (string, required) — Public CDN URL. Only serves bytes once the upload has completed.
  - `alt_text` (string | null, required)
  - `title` (string | null, required)
  - `width` (integer | null, required)
  - `height` (integer | null, required)
  - `duration_sec` (integer | null, required)
  - `page_count` (integer | null, required)
  - `created_at` (string, required)
  - `uploaded_at` (string | null, required)

#### 400

Invalid request

- `error` (string, required)
- `issues` (any[], optional)

#### 401

Not signed in

- `error` (string, required)
- `issues` (any[], optional)

#### 403

Not a member of the organization

- `error` (string, required)
- `issues` (any[], optional)

## Create a video upload ticket

```http
POST https://orbitwrite.com/api/v1/media/create-stream-upload
```

Reserves a video and returns a short-lived ticket to upload it with, using the [TUS](https://tus.io) resumable protocol.

Bytes go straight from your client to Bunny Stream — they never pass through this API — and the credential that authorises the upload never reaches your client either: what you receive is a hash covering one video and one expiry.

Video takes this route rather than `create-upload-url` for two reasons. Uploads are **resumable**, which matters when a large file meets a mobile connection; and the video is **transcoded** to a conformant MP4, which is what makes it publishable to platforms with strict codec requirements.

**1. Request a ticket**

```js
const res = await fetch("https://<host>/api/v1/media/create-stream-upload", {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: "Bearer orbit_sk_…" },
  body: JSON.stringify({ name: "clip.mp4", mime_type: "video/mp4", size_bytes: 12345678 }),
});
const ticket = await res.json();
```

**2. Upload with any TUS client**

```js
new tus.Upload(file, {
  endpoint: ticket.endpoint,
  headers: {
    AuthorizationSignature: ticket.signature,
    AuthorizationExpire: String(ticket.expires_at_seconds),
    VideoId: ticket.video_id,
    LibraryId: ticket.library_id,
  },
  metadata: { filetype: file.type },
}).start();
```

A deployment without Bunny Stream configured answers **501** here; video can still be uploaded through `create-upload-url`, it just is not transcoded or resumable.

**3. Attach it — waiting is optional.** The upload finishing is not the end: the video is then transcoded, during which `media.status` reads `processing` and no playable URL exists yet. You may attach a `processing` video to a post immediately — scheduling and publish-now both accept it, and the publish engine holds the post until the video is ready (the create response's `videoProcessing` flag says this is happening). Poll `GET /media/{id}` until `uploaded` only if you want the final URL yourself; only a video still `pending` (bytes never sent) is refused.

**Authentication:** bearer token or session cookie.

### Request body (`application/json`)

- `name` (string, required)
- `mime_type` (string, required, one of `video/mp4`, `video/quicktime`, `video/webm`)
- `size_bytes` (integer, required)
- `organization_id` (string, optional) — Required for session callers without an active organization. Ignored for API keys, which are already scoped to one workspace.
- `alt_text` (string, optional)
- `title` (string, optional)
- `width` (integer, optional)
- `height` (integer, optional)
- `duration_sec` (integer, optional)

### Responses

#### 200

Video reserved and upload ticket signed

- `media_id` (string, required)
- `endpoint` (string, required) — TUS endpoint to upload to.
- `video_id` (string, required)
- `library_id` (string, required)
- `signature` (string, required) — Send as the `AuthorizationSignature` header. A hash covering this video and this expiry — it authorises nothing else, and it is not the API key.
- `expires_at_seconds` (integer, required) — Send verbatim as the `AuthorizationExpire` header. It is part of the signed material, so changing it invalidates the signature.
- `media` (Media, required)
  - `id` (string, required)
  - `name` (string, required)
  - `mime_type` (string, required)
  - `size_bytes` (integer, required) — Storage consumed by this asset, and what the usage meter sums. For a Stream video that is every rendition plus the HLS segments — several times the size of the single file `url` serves, which is `rendition_bytes`.
  - `rendition_bytes` (integer | null, required) — Byte length of the file at `url`. Video only, and null until the transcode has finished and it could be measured — an unmeasured video is not the same as an empty one. This is the number a platform's video budget applies to; `size_bytes` is not.
  - `status` (string, required, one of `pending`, `processing`, `uploaded`) — `pending` until the upload has been confirmed to have landed. Confirmation happens lazily when the record is read, so a freshly uploaded file reports `uploaded` from the first GET after the upload completes. `processing` applies to video only: the bytes have arrived but Bunny Stream is still transcoding, so no playable URL exists yet — treat it as not-yet-usable rather than as a failure.
  - `type` (string, required, one of `image`, `video`, `document`, `other`)
  - `url` (string, required) — Public CDN URL. Only serves bytes once the upload has completed.
  - `alt_text` (string | null, required)
  - `title` (string | null, required)
  - `width` (integer | null, required)
  - `height` (integer | null, required)
  - `duration_sec` (integer | null, required)
  - `page_count` (integer | null, required)
  - `created_at` (string, required)
  - `uploaded_at` (string | null, required)

#### 400

Invalid request

- `error` (string, required)
- `issues` (any[], optional)

#### 401

Not signed in

- `error` (string, required)
- `issues` (any[], optional)

#### 403

Not a member of the organization

- `error` (string, required)
- `issues` (any[], optional)

#### 501

The deployment is not configured for this capability

- `error` (string, required)
- `issues` (any[], optional)

## List media

```http
GET https://orbitwrite.com/api/v1/media
```

The workspace's media, newest first. Any `pending` record in the page is checked against storage as it's read, so uploads that have landed report `uploaded` without an explicit confirmation call.

`postId` / `groupId` narrow the list to the library records a post references — its own attachments plus anything its auto-engagement automations will post. Externally-hosted media (a GIPHY pick) has no record here and so never appears. Use `groupId` for a thread or a multi-account send, where one composer session wrote several post rows; `postId` addresses one of those rows.

**Authentication:** bearer token or session cookie.

### Query parameters

- `organizationId` (string, optional) — Session callers only — an API key already determines the workspace.
- `offset` (integer | null, optional)
- `limit` (integer, optional)
- `type` (string | string[], optional) — Filter by kind. Repeat for OR (e.g. `?type=image&type=video`).
- `postId` (string, optional) — Only media referenced by this post row. Mutually exclusive with `groupId`. 404s if the post isn't in this workspace, rather than reading as an empty library.
- `groupId` (string, optional) — Only media referenced by any post in this composer group — what `POST /posts` hands back as `groupIds`. Mutually exclusive with `postId`. 404s if the group isn't in this workspace, rather than reading as an empty library; a group that exists but whose posts have all been discarded is an empty page, not a 404.

### Responses

#### 200

A page of media

- `data` (Media[], required)
  - `id` (string, required)
  - `name` (string, required)
  - `mime_type` (string, required)
  - `size_bytes` (integer, required) — Storage consumed by this asset, and what the usage meter sums. For a Stream video that is every rendition plus the HLS segments — several times the size of the single file `url` serves, which is `rendition_bytes`.
  - `rendition_bytes` (integer | null, required) — Byte length of the file at `url`. Video only, and null until the transcode has finished and it could be measured — an unmeasured video is not the same as an empty one. This is the number a platform's video budget applies to; `size_bytes` is not.
  - `status` (string, required, one of `pending`, `processing`, `uploaded`) — `pending` until the upload has been confirmed to have landed. Confirmation happens lazily when the record is read, so a freshly uploaded file reports `uploaded` from the first GET after the upload completes. `processing` applies to video only: the bytes have arrived but Bunny Stream is still transcoding, so no playable URL exists yet — treat it as not-yet-usable rather than as a failure.
  - `type` (string, required, one of `image`, `video`, `document`, `other`)
  - `url` (string, required) — Public CDN URL. Only serves bytes once the upload has completed.
  - `alt_text` (string | null, required)
  - `title` (string | null, required)
  - `width` (integer | null, required)
  - `height` (integer | null, required)
  - `duration_sec` (integer | null, required)
  - `page_count` (integer | null, required)
  - `created_at` (string, required)
  - `uploaded_at` (string | null, required)
- `meta` (object, required)
  - `total` (integer, required)
  - `offset` (integer, required)
  - `limit` (integer, required)
  - `next` (string | null, required) — Query string for the next page, or null on the last page.

#### 400

Invalid request

- `error` (string, required)
- `issues` (any[], optional)

#### 401

Not signed in

- `error` (string, required)
- `issues` (any[], optional)

#### 403

Not a member of the organization

- `error` (string, required)
- `issues` (any[], optional)

#### 404

The resource does not exist

- `error` (string, required)
- `issues` (any[], optional)

## Media upload usage for the current period

```http
GET https://orbitwrite.com/api/v1/media/usage
```

Uploads and total bytes for the workspace since the start of the current UTC calendar month. Reported only — no plan currently caps media uploads.

**Authentication:** bearer token or session cookie.

### Query parameters

- `organizationId` (string, optional) — Session callers only — an API key already determines the workspace.

### Responses

#### 200

Usage for the current period

- `uploads` (integer, required)
- `bytes` (integer, required)
- `period_start` (string, required)
- `period_end` (string, required)

#### 400

Invalid request

- `error` (string, required)
- `issues` (any[], optional)

#### 401

Not signed in

- `error` (string, required)
- `issues` (any[], optional)

#### 403

Not a member of the organization

- `error` (string, required)
- `issues` (any[], optional)

## Confirm uploads have landed

```http
POST https://orbitwrite.com/api/v1/media/confirm
```

Reconcile media records against storage and return them as they now stand. Bunny doesn't call back when a presigned PUT completes, so a record stays `pending` until something checks — normally a lazy check on the next read. Call this after uploading to make that check explicit, before attaching the ids to a post.

Idempotent: already-confirmed records are returned untouched. A record whose bytes never arrived stays `pending`; a video whose upload finished but is still transcoding reports `processing`, which is attachable — the publish engine waits for the encode.

Every id must name a record in the workspace; an unknown id is a 404 rather than a silent omission, so a caller can't read a short list as a clean bill of health.

**Authentication:** bearer token or session cookie.

### Query parameters

- `organizationId` (string, optional) — Session callers only — an API key already determines the workspace.

### Request body (`application/json`)

- `media_ids` (string[], required) — Ids returned by `/media/create-upload-url` or `/media/create-stream-upload`, after the bytes have been sent.

### Responses

#### 200

The records as they now stand

- `media` (Media[], required)
  - `id` (string, required)
  - `name` (string, required)
  - `mime_type` (string, required)
  - `size_bytes` (integer, required) — Storage consumed by this asset, and what the usage meter sums. For a Stream video that is every rendition plus the HLS segments — several times the size of the single file `url` serves, which is `rendition_bytes`.
  - `rendition_bytes` (integer | null, required) — Byte length of the file at `url`. Video only, and null until the transcode has finished and it could be measured — an unmeasured video is not the same as an empty one. This is the number a platform's video budget applies to; `size_bytes` is not.
  - `status` (string, required, one of `pending`, `processing`, `uploaded`) — `pending` until the upload has been confirmed to have landed. Confirmation happens lazily when the record is read, so a freshly uploaded file reports `uploaded` from the first GET after the upload completes. `processing` applies to video only: the bytes have arrived but Bunny Stream is still transcoding, so no playable URL exists yet — treat it as not-yet-usable rather than as a failure.
  - `type` (string, required, one of `image`, `video`, `document`, `other`)
  - `url` (string, required) — Public CDN URL. Only serves bytes once the upload has completed.
  - `alt_text` (string | null, required)
  - `title` (string | null, required)
  - `width` (integer | null, required)
  - `height` (integer | null, required)
  - `duration_sec` (integer | null, required)
  - `page_count` (integer | null, required)
  - `created_at` (string, required)
  - `uploaded_at` (string | null, required)

#### 400

Invalid request

- `error` (string, required)
- `issues` (any[], optional)

#### 401

Not signed in

- `error` (string, required)
- `issues` (any[], optional)

#### 403

Not a member of the organization

- `error` (string, required)
- `issues` (any[], optional)

#### 404

The resource does not exist

- `error` (string, required)
- `issues` (any[], optional)

#### 429

Rate limit exceeded

- `error` (string, required)
- `issues` (any[], optional)

## Get media by ID

```http
GET https://orbitwrite.com/api/v1/media/{id}
```

**Authentication:** bearer token or session cookie.

### Path parameters

- `id` (string, required)

### Query parameters

- `organizationId` (string, optional) — Session callers only — an API key already determines the workspace.

### Responses

#### 200

The media record

- `id` (string, required)
- `name` (string, required)
- `mime_type` (string, required)
- `size_bytes` (integer, required) — Storage consumed by this asset, and what the usage meter sums. For a Stream video that is every rendition plus the HLS segments — several times the size of the single file `url` serves, which is `rendition_bytes`.
- `rendition_bytes` (integer | null, required) — Byte length of the file at `url`. Video only, and null until the transcode has finished and it could be measured — an unmeasured video is not the same as an empty one. This is the number a platform's video budget applies to; `size_bytes` is not.
- `status` (string, required, one of `pending`, `processing`, `uploaded`) — `pending` until the upload has been confirmed to have landed. Confirmation happens lazily when the record is read, so a freshly uploaded file reports `uploaded` from the first GET after the upload completes. `processing` applies to video only: the bytes have arrived but Bunny Stream is still transcoding, so no playable URL exists yet — treat it as not-yet-usable rather than as a failure.
- `type` (string, required, one of `image`, `video`, `document`, `other`)
- `url` (string, required) — Public CDN URL. Only serves bytes once the upload has completed.
- `alt_text` (string | null, required)
- `title` (string | null, required)
- `width` (integer | null, required)
- `height` (integer | null, required)
- `duration_sec` (integer | null, required)
- `page_count` (integer | null, required)
- `created_at` (string, required)
- `uploaded_at` (string | null, required)

#### 400

Invalid request

- `error` (string, required)
- `issues` (any[], optional)

#### 401

Not signed in

- `error` (string, required)
- `issues` (any[], optional)

#### 403

Not a member of the organization

- `error` (string, required)
- `issues` (any[], optional)

#### 404

The resource does not exist

- `error` (string, required)
- `issues` (any[], optional)

## Delete media by ID

```http
DELETE https://orbitwrite.com/api/v1/media/{id}
```

Removes the record and the stored object. Posts that already reference the file keep working: attaching by `media_id` copies the URL and metadata into the post, so deletion doesn't reach back into scheduled content — but the URL stops serving once the CDN's cached copy expires, so delete media that scheduled posts still need to fetch at publish time with care. Fails (leaving the record intact) if the stored object can't be removed, so the delete stays retryable rather than orphaning the file.

**Authentication:** bearer token or session cookie.

### Path parameters

- `id` (string, required)

### Query parameters

- `organizationId` (string, optional) — Session callers only — an API key already determines the workspace.

### Responses

#### 200

Media deleted

- `id` (string, required)
- `deleted` (boolean, required, one of `true`)

#### 400

Invalid request

- `error` (string, required)
- `issues` (any[], optional)

#### 401

Not signed in

- `error` (string, required)
- `issues` (any[], optional)

#### 403

Not a member of the organization

- `error` (string, required)
- `issues` (any[], optional)

#### 404

The resource does not exist

- `error` (string, required)
- `issues` (any[], optional)

#### 500

The record cannot name its stored object (an invalid stream path); the record is kept so the delete stays retryable after the data is repaired

- `error` (string, required)
- `issues` (any[], optional)

#### 503

Video storage is not configured on this deployment, so a stream-hosted object cannot be removed; the record is kept and the delete can be retried once it is

- `error` (string, required)
- `issues` (any[], optional)
