API reference

Media

7 endpoints, served at https://orbitwrite.com/api/v1. Each one lists the credentials it accepts, its parameters and every response it can give.

Create a media upload URL#

POST/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

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

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

An API key or OAuth access token as a bearer token, or a signed-in session.

Body application/json

namestringRequired

Original file name — kept for display and its extension.

Between 1 and 255 characters.

mime_typestringRequired
  • "image/jpeg"
  • "image/png"
  • "image/gif"
  • "image/webp"
  • "video/mp4"
  • "application/pdf"
size_bytesintegerRequired

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.

Between 0 and 1000000000.

organization_idstring

Required for session callers without an active organization. Ignored for API keys, which are already scoped to one workspace.

At least 1 character.

alt_textstring

At most 2000 characters.

titlestring

PDF documents only — LinkedIn requires a document title.

At most 300 characters.

widthinteger

At least 0.

heightinteger

At least 0.

duration_secinteger

At least 0.

page_countinteger

At least 0.

Responses

200CreateUploadUrlResult

Media record created and upload URL signed

Response attributes
media_idstringRequired
upload_urlstringRequired

Signed PUT URL. Send the raw bytes with the same Content-Type you declared. Expires — upload promptly.

namestringRequired
expires_atstringRequired

ISO 8601.

mediaMediaRequired
Child attributes
idstringRequired
namestringRequired
mime_typestringRequired
size_bytesintegerRequired

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_bytesinteger | nullRequired

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.

statusstringRequired

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.

  • "pending"
  • "processing"
  • "uploaded"
typestringRequired
  • "image"
  • "video"
  • "document"
  • "other"
urlstringRequired

Public CDN URL. Only serves bytes once the upload has completed.

alt_textstring | nullRequired
titlestring | nullRequired
widthinteger | nullRequired
heightinteger | nullRequired
duration_secinteger | nullRequired
page_countinteger | nullRequired
created_atstringRequired
uploaded_atstring | nullRequired
400Error

Invalid request

Response attributes
errorstringRequired
issuesany[]
401Error

Not signed in

Response attributes
errorstringRequired
issuesany[]
403Error

Not a member of the organization

Response attributes
errorstringRequired
issuesany[]
POST /api/v1/media/create-upload-url
curl -X POST "https://orbitwrite.com/api/v1/media/create-upload-url" \
  -H "Authorization: Bearer $ORBITWRITE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "photo.jpg",
  "mime_type": "image/jpeg",
  "size_bytes": 123456
}'
Response
{
  "media_id": "string",
  "upload_url": "string",
  "name": "string",
  "expires_at": "string",
  "media": {
    "id": "string",
    "name": "string",
    "mime_type": "string",
    "size_bytes": 0,
    "rendition_bytes": 0,
    "status": "pending",
    "type": "image",
    "url": "string",
    "alt_text": "string",
    "title": "string",
    "width": 0,
    "height": 0,
    "duration_sec": 0,
    "page_count": 0,
    "created_at": "string",
    "uploaded_at": "string"
  }
}

Create a video upload ticket#

POST/api/v1/media/create-stream-upload

Reserves a video and returns a short-lived ticket to upload it with, using the TUS 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

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

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

An API key or OAuth access token as a bearer token, or a signed-in session.

Body application/json

namestringRequired

Between 1 and 255 characters.

mime_typestringRequired
  • "video/mp4"
  • "video/quicktime"
  • "video/webm"
size_bytesintegerRequired

Between 0 and 1000000000.

organization_idstring

Required for session callers without an active organization. Ignored for API keys, which are already scoped to one workspace.

At least 1 character.

alt_textstring

At most 2000 characters.

titlestring

At most 300 characters.

widthinteger

At least 0.

heightinteger

At least 0.

duration_secinteger

At least 0.

Responses

200CreateStreamUploadResult

Video reserved and upload ticket signed

Response attributes
media_idstringRequired
endpointstringRequired

TUS endpoint to upload to.

video_idstringRequired
library_idstringRequired
signaturestringRequired

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_secondsintegerRequired

Send verbatim as the AuthorizationExpire header. It is part of the signed material, so changing it invalidates the signature.

mediaMediaRequired
Child attributes
idstringRequired
namestringRequired
mime_typestringRequired
size_bytesintegerRequired

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_bytesinteger | nullRequired

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.

statusstringRequired

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.

  • "pending"
  • "processing"
  • "uploaded"
typestringRequired
  • "image"
  • "video"
  • "document"
  • "other"
urlstringRequired

Public CDN URL. Only serves bytes once the upload has completed.

alt_textstring | nullRequired
titlestring | nullRequired
widthinteger | nullRequired
heightinteger | nullRequired
duration_secinteger | nullRequired
page_countinteger | nullRequired
created_atstringRequired
uploaded_atstring | nullRequired
400Error

Invalid request

Response attributes
errorstringRequired
issuesany[]
401Error

Not signed in

Response attributes
errorstringRequired
issuesany[]
403Error

Not a member of the organization

Response attributes
errorstringRequired
issuesany[]
501Error

The deployment is not configured for this capability

Response attributes
errorstringRequired
issuesany[]
POST /api/v1/media/create-stream-upload
curl -X POST "https://orbitwrite.com/api/v1/media/create-stream-upload" \
  -H "Authorization: Bearer $ORBITWRITE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "clip.mp4",
  "mime_type": "video/mp4",
  "size_bytes": 0
}'
Response
{
  "media_id": "string",
  "endpoint": "https://video.bunnycdn.com/tusupload",
  "video_id": "string",
  "library_id": "string",
  "signature": "string",
  "expires_at_seconds": 0,
  "media": {
    "id": "string",
    "name": "string",
    "mime_type": "string",
    "size_bytes": 0,
    "rendition_bytes": 0,
    "status": "pending",
    "type": "image",
    "url": "string",
    "alt_text": "string",
    "title": "string",
    "width": 0,
    "height": 0,
    "duration_sec": 0,
    "page_count": 0,
    "created_at": "string",
    "uploaded_at": "string"
  }
}

List media#

GET/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

An API key or OAuth access token as a bearer token, or a signed-in session.

Query parameters

organizationIdstring

Session callers only — an API key already determines the workspace.

At least 1 character.

offsetinteger | null

At least 0. Defaults to 0.

limitinteger

Between 1 and 100. Defaults to 10.

typestring | string[]

Filter by kind. Repeat for OR (e.g. ?type=image&type=video).

postIdstring

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.

At least 1 character.

groupIdstring

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.

At least 1 character.

Responses

200MediaList

A page of media

Response attributes
dataMedia[]Required
Child attributes
idstringRequired
namestringRequired
mime_typestringRequired
size_bytesintegerRequired

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_bytesinteger | nullRequired

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.

statusstringRequired

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.

  • "pending"
  • "processing"
  • "uploaded"
typestringRequired
  • "image"
  • "video"
  • "document"
  • "other"
urlstringRequired

Public CDN URL. Only serves bytes once the upload has completed.

alt_textstring | nullRequired
titlestring | nullRequired
widthinteger | nullRequired
heightinteger | nullRequired
duration_secinteger | nullRequired
page_countinteger | nullRequired
created_atstringRequired
uploaded_atstring | nullRequired
metaobjectRequired
Child attributes
totalintegerRequired
offsetintegerRequired
limitintegerRequired
nextstring | nullRequired

Query string for the next page, or null on the last page.

400Error

Invalid request

Response attributes
errorstringRequired
issuesany[]
401Error

Not signed in

Response attributes
errorstringRequired
issuesany[]
403Error

Not a member of the organization

Response attributes
errorstringRequired
issuesany[]
404Error

The resource does not exist

Response attributes
errorstringRequired
issuesany[]
GET /api/v1/media
curl -X GET "https://orbitwrite.com/api/v1/media" \
  -H "Authorization: Bearer $ORBITWRITE_API_KEY"
Response
{
  "data": [
    {
      "id": "string",
      "name": "string",
      "mime_type": "string",
      "size_bytes": 0,
      "rendition_bytes": 0,
      "status": "pending",
      "type": "image",
      "url": "string",
      "alt_text": "string",
      "title": "string",
      "width": 0,
      "height": 0,
      "duration_sec": 0,
      "page_count": 0,
      "created_at": "string",
      "uploaded_at": "string"
    }
  ],
  "meta": {
    "total": 0,
    "offset": 0,
    "limit": 0,
    "next": "string"
  }
}

Media upload usage for the current period#

GET/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

An API key or OAuth access token as a bearer token, or a signed-in session.

Query parameters

organizationIdstring

Session callers only — an API key already determines the workspace.

At least 1 character.

Responses

200MediaUsage

Usage for the current period

Response attributes
uploadsintegerRequired
bytesintegerRequired
period_startstringRequired
period_endstringRequired
400Error

Invalid request

Response attributes
errorstringRequired
issuesany[]
401Error

Not signed in

Response attributes
errorstringRequired
issuesany[]
403Error

Not a member of the organization

Response attributes
errorstringRequired
issuesany[]
GET /api/v1/media/usage
curl -X GET "https://orbitwrite.com/api/v1/media/usage" \
  -H "Authorization: Bearer $ORBITWRITE_API_KEY"
Response
{
  "uploads": 0,
  "bytes": 0,
  "period_start": "string",
  "period_end": "string"
}

Confirm uploads have landed#

POST/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

An API key or OAuth access token as a bearer token, or a signed-in session.

Query parameters

organizationIdstring

Session callers only — an API key already determines the workspace.

At least 1 character.

Body application/json

media_idsstring[]Required

Ids returned by /media/create-upload-url or /media/create-stream-upload, after the bytes have been sent.

Between 1 and 200 items.

Responses

200ConfirmMediaResult

The records as they now stand

Response attributes
mediaMedia[]Required
Child attributes
idstringRequired
namestringRequired
mime_typestringRequired
size_bytesintegerRequired

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_bytesinteger | nullRequired

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.

statusstringRequired

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.

  • "pending"
  • "processing"
  • "uploaded"
typestringRequired
  • "image"
  • "video"
  • "document"
  • "other"
urlstringRequired

Public CDN URL. Only serves bytes once the upload has completed.

alt_textstring | nullRequired
titlestring | nullRequired
widthinteger | nullRequired
heightinteger | nullRequired
duration_secinteger | nullRequired
page_countinteger | nullRequired
created_atstringRequired
uploaded_atstring | nullRequired
400Error

Invalid request

Response attributes
errorstringRequired
issuesany[]
401Error

Not signed in

Response attributes
errorstringRequired
issuesany[]
403Error

Not a member of the organization

Response attributes
errorstringRequired
issuesany[]
404Error

The resource does not exist

Response attributes
errorstringRequired
issuesany[]
429Error

Rate limit exceeded

Response attributes
errorstringRequired
issuesany[]
POST /api/v1/media/confirm
curl -X POST "https://orbitwrite.com/api/v1/media/confirm" \
  -H "Authorization: Bearer $ORBITWRITE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "media_ids": [
    "string"
  ]
}'
Response
{
  "media": [
    {
      "id": "string",
      "name": "string",
      "mime_type": "string",
      "size_bytes": 0,
      "rendition_bytes": 0,
      "status": "pending",
      "type": "image",
      "url": "string",
      "alt_text": "string",
      "title": "string",
      "width": 0,
      "height": 0,
      "duration_sec": 0,
      "page_count": 0,
      "created_at": "string",
      "uploaded_at": "string"
    }
  ]
}

Get media by ID#

GET/api/v1/media/{id}

Authentication

An API key or OAuth access token as a bearer token, or a signed-in session.

Path parameters

idstringRequired

Query parameters

organizationIdstring

Session callers only — an API key already determines the workspace.

At least 1 character.

Responses

200Media

The media record

Response attributes
idstringRequired
namestringRequired
mime_typestringRequired
size_bytesintegerRequired

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_bytesinteger | nullRequired

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.

statusstringRequired

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.

  • "pending"
  • "processing"
  • "uploaded"
typestringRequired
  • "image"
  • "video"
  • "document"
  • "other"
urlstringRequired

Public CDN URL. Only serves bytes once the upload has completed.

alt_textstring | nullRequired
titlestring | nullRequired
widthinteger | nullRequired
heightinteger | nullRequired
duration_secinteger | nullRequired
page_countinteger | nullRequired
created_atstringRequired
uploaded_atstring | nullRequired
400Error

Invalid request

Response attributes
errorstringRequired
issuesany[]
401Error

Not signed in

Response attributes
errorstringRequired
issuesany[]
403Error

Not a member of the organization

Response attributes
errorstringRequired
issuesany[]
404Error

The resource does not exist

Response attributes
errorstringRequired
issuesany[]
GET /api/v1/media/{id}
curl -X GET "https://orbitwrite.com/api/v1/media/{id}" \
  -H "Authorization: Bearer $ORBITWRITE_API_KEY"
Response
{
  "id": "string",
  "name": "string",
  "mime_type": "string",
  "size_bytes": 0,
  "rendition_bytes": 0,
  "status": "pending",
  "type": "image",
  "url": "string",
  "alt_text": "string",
  "title": "string",
  "width": 0,
  "height": 0,
  "duration_sec": 0,
  "page_count": 0,
  "created_at": "string",
  "uploaded_at": "string"
}

Delete media by ID#

DELETE/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

An API key or OAuth access token as a bearer token, or a signed-in session.

Path parameters

idstringRequired

Query parameters

organizationIdstring

Session callers only — an API key already determines the workspace.

At least 1 character.

Responses

200DeleteMediaResult

Media deleted

Response attributes
idstringRequired
deletedbooleanRequired
400Error

Invalid request

Response attributes
errorstringRequired
issuesany[]
401Error

Not signed in

Response attributes
errorstringRequired
issuesany[]
403Error

Not a member of the organization

Response attributes
errorstringRequired
issuesany[]
404Error

The resource does not exist

Response attributes
errorstringRequired
issuesany[]
500Error

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

Response attributes
errorstringRequired
issuesany[]
503Error

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

Response attributes
errorstringRequired
issuesany[]
DELETE /api/v1/media/{id}
curl -X DELETE "https://orbitwrite.com/api/v1/media/{id}" \
  -H "Authorization: Bearer $ORBITWRITE_API_KEY"
Response
{
  "id": "string",
  "deleted": true
}