API reference
7 endpoints, served at https://orbitwrite.com/api/v1. Each one lists the credentials it accepts, its parameters and every response it can give.
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.
An API key or OAuth access token as a bearer token, or a signed-in session.
namestringRequiredOriginal 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_bytesintegerRequiredExact 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_idstringRequired for session callers without an active organization. Ignored for API keys, which are already scoped to one workspace.
At least 1 character.
alt_textstringAt most 2000 characters.
titlestringPDF documents only — LinkedIn requires a document title.
At most 300 characters.
widthintegerAt least 0.
heightintegerAt least 0.
duration_secintegerAt least 0.
page_countintegerAt least 0.
200CreateUploadUrlResultMedia record created and upload URL signed
media_idstringRequiredupload_urlstringRequiredSigned PUT URL. Send the raw bytes with the same Content-Type you declared. Expires — upload promptly.
namestringRequiredexpires_atstringRequiredISO 8601.
mediaMediaRequiredidstringRequirednamestringRequiredmime_typestringRequiredsize_bytesintegerRequiredStorage 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 | nullRequiredByte 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.
statusstringRequiredpending 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"urlstringRequiredPublic CDN URL. Only serves bytes once the upload has completed.
alt_textstring | nullRequiredtitlestring | nullRequiredwidthinteger | nullRequiredheightinteger | nullRequiredduration_secinteger | nullRequiredpage_countinteger | nullRequiredcreated_atstringRequireduploaded_atstring | nullRequiredcurl -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
}'{
"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"
}
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
namestringRequiredBetween 1 and 255 characters.
mime_typestringRequired"video/mp4""video/quicktime""video/webm"size_bytesintegerRequiredBetween 0 and 1000000000.
organization_idstringRequired for session callers without an active organization. Ignored for API keys, which are already scoped to one workspace.
At least 1 character.
alt_textstringAt most 2000 characters.
titlestringAt most 300 characters.
widthintegerAt least 0.
heightintegerAt least 0.
duration_secintegerAt least 0.
200CreateStreamUploadResultVideo reserved and upload ticket signed
media_idstringRequiredendpointstringRequiredTUS endpoint to upload to.
video_idstringRequiredlibrary_idstringRequiredsignaturestringRequiredSend 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_secondsintegerRequiredSend verbatim as the AuthorizationExpire header. It is part of the signed material, so changing it invalidates the signature.
mediaMediaRequiredidstringRequirednamestringRequiredmime_typestringRequiredsize_bytesintegerRequiredStorage 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 | nullRequiredByte 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.
statusstringRequiredpending 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"urlstringRequiredPublic CDN URL. Only serves bytes once the upload has completed.
alt_textstring | nullRequiredtitlestring | nullRequiredwidthinteger | nullRequiredheightinteger | nullRequiredduration_secinteger | nullRequiredpage_countinteger | nullRequiredcreated_atstringRequireduploaded_atstring | nullRequiredcurl -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
}'{
"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"
}
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
organizationIdstringSession callers only — an API key already determines the workspace.
At least 1 character.
offsetinteger | nullAt least 0. Defaults to 0.
limitintegerBetween 1 and 100. Defaults to 10.
typestring | string[]Filter by kind. Repeat for OR (e.g. ?type=image&type=video).
postIdstringOnly 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.
groupIdstringOnly 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.
200MediaListA page of media
dataMedia[]RequiredidstringRequirednamestringRequiredmime_typestringRequiredsize_bytesintegerRequiredStorage 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 | nullRequiredByte 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.
statusstringRequiredpending 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"urlstringRequiredPublic CDN URL. Only serves bytes once the upload has completed.
alt_textstring | nullRequiredtitlestring | nullRequiredwidthinteger | nullRequiredheightinteger | nullRequiredduration_secinteger | nullRequiredpage_countinteger | nullRequiredcreated_atstringRequireduploaded_atstring | nullRequiredcurl -X GET "https://orbitwrite.com/api/v1/media" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"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"
}
}Uploads and total bytes for the workspace since the start of the current UTC calendar month. Reported only — no plan currently caps media uploads.
An API key or OAuth access token as a bearer token, or a signed-in session.
organizationIdstringSession callers only — an API key already determines the workspace.
At least 1 character.
200MediaUsageUsage for the current period
uploadsintegerRequiredbytesintegerRequiredperiod_startstringRequiredperiod_endstringRequiredcurl -X GET "https://orbitwrite.com/api/v1/media/usage" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"uploads": 0,
"bytes": 0,
"period_start": "string",
"period_end": "string"
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
organizationIdstringSession callers only — an API key already determines the workspace.
At least 1 character.
media_idsstring[]RequiredIds returned by /media/create-upload-url or /media/create-stream-upload, after the bytes have been sent.
Between 1 and 200 items.
200ConfirmMediaResultThe records as they now stand
mediaMedia[]RequiredidstringRequirednamestringRequiredmime_typestringRequiredsize_bytesintegerRequiredStorage 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 | nullRequiredByte 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.
statusstringRequiredpending 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"urlstringRequiredPublic CDN URL. Only serves bytes once the upload has completed.
alt_textstring | nullRequiredtitlestring | nullRequiredwidthinteger | nullRequiredheightinteger | nullRequiredduration_secinteger | nullRequiredpage_countinteger | nullRequiredcreated_atstringRequireduploaded_atstring | nullRequiredcurl -X POST "https://orbitwrite.com/api/v1/media/confirm" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"media_ids": [
"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"
}
]
}An API key or OAuth access token as a bearer token, or a signed-in session.
idstringRequiredorganizationIdstringSession callers only — an API key already determines the workspace.
At least 1 character.
200MediaThe media record
idstringRequirednamestringRequiredmime_typestringRequiredsize_bytesintegerRequiredStorage 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 | nullRequiredByte 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.
statusstringRequiredpending 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"urlstringRequiredPublic CDN URL. Only serves bytes once the upload has completed.
alt_textstring | nullRequiredtitlestring | nullRequiredwidthinteger | nullRequiredheightinteger | nullRequiredduration_secinteger | nullRequiredpage_countinteger | nullRequiredcreated_atstringRequireduploaded_atstring | nullRequiredcurl -X GET "https://orbitwrite.com/api/v1/media/{id}" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"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"
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
idstringRequiredorganizationIdstringSession callers only — an API key already determines the workspace.
At least 1 character.
500ErrorThe record cannot name its stored object (an invalid stream path); the record is kept so the delete stays retryable after the data is repaired
curl -X DELETE "https://orbitwrite.com/api/v1/media/{id}" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"id": "string",
"deleted": true
}