MUVIOM Media

Media Service API v1

REST API for image upload with automatic resizing and secure, Range-capable video streaming.

Base URLhttps://video.digibhai.store/api/v1
FormatJSON (uploads: multipart/form-data)
AuthenticationBearer token (Authorization: Bearer <token>)
Machine-readable specopenapi.yaml · interactive explorer

Quick start

  1. Register an account for your application.
  2. Wait for an administrator to approve the account.
  3. Create an API token on the API Tokens page.
  4. Upload a file and use the returned URLs:
curl -X POST https://video.digibhai.store/api/v1/media \
  -H "Authorization: Bearer $MEDIA_TOKEN" \
  -H "Accept: application/json" \
  -F "file=@photo.jpg" \
  -F "visibility=private"

The Media Lab lets you try every endpoint from the browser.

Accounts & approval

The service is multi-tenant: every account represents one consuming application and only sees its own media. New registrations must be approved by an administrator.

StatusTokensAPI callsMedia delivery
pendingcannot be created403–
approvedyesallowedserved
suspendedkept but refused403404 (including public media and signed URLs)
expiredkept but refused403404 (including public media and signed URLs)

A blocked account gets a 403 response that says why. errors.account_status is pending, suspended or expired:

HTTP/1.1 403 Forbidden
{
  "success": false,
  "message": "Your account is pending approval by an administrator.",
  "errors": { "account_status": ["pending"] }
}

Subscription & storage quota

An administrator can give an account a subscription end date and a storage quota. Both are shown on your dashboard and returned by GET /auth/me.

  • Subscription: after the end date the account is expired. Nothing is deleted, and access returns as soon as the administrator extends it.
  • Storage quota: the total of your originals and variants. An upload that would exceed it is rejected with 422 (Storage quota exceeded on file). Delete media to free space.

Administrator accounts manage the service and cannot use this API: they get 403 with errors.account_type = ["admin"].

Authentication

Send a personal access token with every request:

Authorization: Bearer 12|pRq8...

Abilities

AbilityGrants
media:readList/show media, issue signed URLs, fetch private bytes with the token
media:writeUpload, change visibility
media:deleteDelete media

A request needs the ability and ownership of the media. A token missing an ability gets 403. Invalid, revoked or expired tokens get 401.

Keep tokens on your server. Browsers and mobile apps should receive media URLs from your backend. Private URLs in API responses are already signed, so frontends never need the token.

Responses & errors

Every JSON response uses the same envelope:

// success
{ "success": true, "data": { ... }, "message": null }

// lists add pagination
{ "success": true, "data": [ ... ], "message": null,
  "meta": { "current_page": 1, "per_page": 20, "total": 57, "last_page": 3, "next_page_url": "...", "prev_page_url": null } }

// error
{ "success": false, "message": "Validation failed", "errors": { "file": ["The file is not a valid image."] } }
StatusMeaning
401Missing, invalid, expired or revoked token
403Account not approved, suspended or expired · administrator account · token lacks ability · invalid/expired signed URL
404Media not found, owned by another account, or private without credentials. These cases can't be told apart, so UUIDs can't be probed.
409Variant or stream requested while still processing
413Upload larger than the server allows
416Range not satisfiable
422Validation failed, including an upload over the storage quota (errors populated)
429Rate limited (Retry-After header)

errors is always an object: field name → list of messages, or {} for errors that are not about a field.

Every response carries an X-Request-Id header. Send your own to correlate logs across services.

Endpoints overview

/media accepts images and videos. /images and /videos behave the same but only accept or return that type.

MethodPathAuthDescription
POST/auth/tokenscredentialsExchange email/password for a token
GET/auth/metokenAccount + token info
DELETE/auth/tokens/currenttokenRevoke current token
GET/mediamedia:readList own media
POST/mediamedia:writeUpload
GET/media/{id}media:readMedia object
PATCH/media/{id}media:writeChange visibility
DELETE/media/{id}media:deleteDelete media and files
GET/media/{id}/signed-urlmedia:readTemporary signed URL
GET/media/{id}/{variant}public · signed · tokenVariant / thumbnail / original bytes
GET/videos/{id}/streampublic · signed · tokenRange-capable video stream

Auth endpoints

POST/auth/tokensthrottled: 5/min

Exchange account credentials for a token (approved accounts only). Tokens can also be created in the dashboard. token_name is required; abilities is optional and defaults to all three.

curl -X POST https://video.digibhai.store/api/v1/auth/tokens -H "Content-Type: application/json" -H "Accept: application/json" \
  -d '{"email":"dev@app.com","password":"secret","token_name":"nextjs-prod","abilities":["media:read","media:write"]}'

201 Created
{ "success": true, "message": "Token issued.",
  "data": { "token": "3|Xy...", "token_type": "Bearer", "abilities": ["media:read","media:write"], "expires_at": "2026-11-01T10:00:00+00:00" } }

Tokens issued here expire after 30 days. Wrong credentials: 422 on email (the same message whether or not the account exists). Account pending, suspended or expired, or an administrator account: 403.

GET/auth/me

Returns the account and the token used for the request:

{ "success": true, "message": null, "data": {
  "user": { "id": 12, "name": "Web app", "email": "dev@app.com", "company": "Acme", "status": "approved",
            "subscription_ends_at": "2027-01-31T23:59:59+00:00",        // null = no end date
            "storage": { "used_bytes": 734003200, "limit_bytes": 2147483648 } },   // limit null = unlimited
  "token": { "name": "nextjs-prod", "abilities": ["media:read"], "expires_at": "…", "last_used_at": "…" }
} }
DELETE/auth/tokens/current

Revokes the token used for the request.

Upload

POST/media · /images · /videosmedia:write
FieldTypeDescription
filefile, requiredThe image or video
visibilityprivate | publicDefault private

Files are validated by their content (magic bytes), not by the name or the declared type:

ImagesVideos
Typesimage/jpeg, image/png, image/webp, image/gifvideo/mp4, video/webm, video/quicktime
Extensionsjpg, jpeg, jpe, png, webp, gifmp4, m4v, webm, mov, qt
Max size10 MB500 MB
Extra≤ 40 megapixelscontainer parsed on the queue

Response: 201 Created with a Location header and the media object. Processing runs in the background: status is pending → processing → ready (or failed). Poll GET /media/{id} until it's ready.

SVG, HTML, PHP, PDF and other types are rejected with 422. So is an upload that does not fit in your storage quota. Original filenames are stored for display only and never used for storage paths.

A file that turns out to be corrupt or unreadable during processing ends as failed with a failure_reason. Failed uploads are not delivered and are removed automatically after 7 days; delete them yourself to free the space sooner.

Media object

{
  "id": "9f1c1b8e-3a47-4d0b-9b0e-1b8d6f0a2c11",
  "type": "image",                     // image | video
  "visibility": "private",             // private | public
  "status": "ready",                   // pending | processing | ready | failed
  "original_filename": "holiday.jpg",
  "mime_type": "image/jpeg",
  "extension": "jpg",
  "size": 2483117,                     // bytes
  "checksum": "5d41402a…",             // SHA-256 of the original
  "width": 4000, "height": 3000,
  "duration": null,                    // seconds (videos)
  "metadata": {},                      // videos: video_codec, audio_codec, bitrate, frame_rate, container, extractor
  "variants": [
    { "name": "thumbnail", "width": 150, "height": 113, "size": 4821, "mime_type": "image/webp", "url": "…" }
  ],
  "urls": { "thumbnail": "…", "small": "…", "medium": "…", "large": "…", "original": "…" },  // videos: see below
  "urls_expire_at": "2026-10-01T13:00:00+00:00",   // private media only
  "failure_reason": null,
  "processed_at": "…", "created_at": "…", "updated_at": "…"
}

For private media every URL is a temporary signed URL that works directly in <img> / <video>. Refresh it before urls_expire_at. For public media, URLs are stable and CDN-cacheable.

urls only lists what can be fetched right now, so check a key before using it:

  • Images: original at once; the variants appear when status is ready.
  • Videos: empty until ready, then original and stream. Poster thumbnails (thumbnail, small, medium, large, taken from one frame) are added only when FFmpeg is installed on the server.

List, get, update, delete

GET/mediamedia:read

Query: type (image|video), status, visibility, per_page (1–100, default 20), page. Newest first, own media only.

GET/media/{id}media:read

Returns the media object. /images/{id} and /videos/{id} return 404 for the other type.

PATCH/media/{id}media:write
{ "visibility": "public" }
DELETE/media/{id}media:delete

Deletes the record immediately. The original and all variants are removed from storage by a background job.

Signed URLs

GET/media/{id}/signed-url?variant=medium&ttl=600media:read
ParamDefaultRules
variantoriginal (images), stream (videos)one of the media's urls keys
ttl3600 s60 – 86400 s
{ "success": true, "data": { "url": "https://video.digibhai.store/api/v1/videos/9f1c…/stream?expires=1790000000&signature=…", "variant": "stream", "expires_at": "…" } }
  • The signature (HMAC-SHA256) covers the path and query, so it can't be reused for other media or variants, and the expiry can't be changed.
  • No Authorization header is needed. It works for every Range request a video player makes.
  • A variant the media does not have → 422. Invalid or expired signature → 403. Suspended or expired account → 404.

Image variants

GET/media/{id}/{variant} · /images/{id}/{variant}public · signed · token
VariantMax widthFormatTypical use
thumbnail150 pxWEBP (q82)avatars, lists, grids
small320 pxWEBP (q82)mobile cards
medium640 pxWEBP (q82)tablet / desktop cards
large1280 pxWEBP (q82)detail views, lightboxes
originalas uploadedunchangeddownloads, zoom

Aspect ratio is always preserved and images are never upscaled (a 200 px upload has a 200 px large). EXIF orientation is applied and metadata such as GPS is stripped. Request the smallest variant that fits your layout.

Video streaming & HTTP Range

GET/videos/{id}/streampublic · signed · token

Streams only the requested bytes, so players can start instantly and seek anywhere. Videos must be ready (409 while processing, 404 if processing failed).

Request headerEffect
Range: bytes=0-1048575first MiB (inclusive range)
Range: bytes=5000000-from byte 5,000,000 onwards (seeking), at most 2 MiB per response
Range: bytes=-1024last 1024 bytes
Range: bytes=0-99,500-599multiple ranges → multipart/byteranges
If-Range: "<etag>" or HTTP-datehonour Range only if unchanged, else full 200
If-None-Match / If-Modified-Since304 Not Modified when unchanged
HEADsame headers, no body
# Partial content
GET /api/v1/videos/{id}/stream
Range: bytes=0-1048575

HTTP/1.1 206 Partial Content
Content-Type: video/mp4
Content-Range: bytes 0-1048575/26215007
Content-Length: 1048576
Accept-Ranges: bytes
# Not satisfiable
GET /api/v1/videos/{id}/stream
Range: bytes=999999999-

HTTP/1.1 416 Range Not Satisfiable
Content-Range: bytes */26215007

# No Range header → 200 OK, whole file,
# Content-Length: 26215007

Rules

  • End beyond the file size is clamped to the last byte.
  • An open-ended range (bytes=N-) is answered with at most 2 MiB. Read Content-Range and request the next part; browsers and native players do this on their own.
  • Start ≥ size, end < start, or malformed syntax → 416.
  • Unknown units (e.g. items=) are ignored → 200.
  • Overlapping ranges are merged. More than 8 ranges → 416.
  • Memory use is bounded (≈1 MiB chunks): the full file is never loaded into memory.
<!-- Browsers and native players send Range requests automatically -->
<video src="SIGNED_OR_PUBLIC_STREAM_URL" controls preload="metadata"></video>

Caching headers

ResponseHeaders
Public bytesCache-Control: public, max-age=86400
Private bytesCache-Control: private, max-age=3600, Vary: Authorization
All bytesETag, Last-Modified, Accept-Ranges: bytes, Content-Type, Content-Length, X-Content-Type-Options: nosniff, Content-Security-Policy: sandbox, Content-Disposition: inline
Token / signed-URL responsesCache-Control: no-store

ETag is the SHA-256 of the served file. Send it back as If-None-Match to get 304.

Rate limits

ScopeLimit / minuteKeyed by
JSON API120account
Uploads30account
Media delivery / streaming600token or IP
POST /auth/tokens5email + IP (and 20 per IP)

When a limit is hit: 429 with Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining.

CORS

Browser clients may call the API cross-origin. Allowed request headers: Authorization, Content-Type, Accept, Range, If-Range, If-None-Match, If-Modified-Since, X-Request-Id, X-Requested-With. Exposed response headers: Accept-Ranges, Content-Range, Content-Length, ETag, Last-Modified, X-Request-Id, Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining.

Integration examples

JavaScript (fetch)

const API = 'https://media.example.com/api/v1';

async function upload(file, token) {
  const body = new FormData();
  body.append('file', file);            // don't set Content-Type yourself
  body.append('visibility', 'private');
  const res = await fetch(`${API}/media`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
    body,
  });
  const json = await res.json();
  if (!json.success) throw new Error(json.message);
  return json.data;                      // media object
}

async function waitUntilReady(id, token) {
  for (;;) {
    const { data } = await (await fetch(`${API}/media/${id}`, {
      headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
    })).json();
    if (data.status === 'ready') return data;
    if (data.status === 'failed') throw new Error(data.failure_reason);
    await new Promise((r) => setTimeout(r, 1000));
  }
}

Next.js (App Router, server-side token)

// lib/media.ts
import 'server-only';
export async function getMedia(id: string) {
  const res = await fetch(`${process.env.MEDIA_API_URL}/media/${id}`, {
    headers: { Authorization: `Bearer ${process.env.MEDIA_API_TOKEN}`, Accept: 'application/json' },
    next: { revalidate: 900 },           // signed URLs live ~1h
  });
  return res.ok ? (await res.json()).data : null;
}

// app/photos/[id]/page.tsx
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const media = await getMedia((await params).id);
  return media.type === 'video'
    ? <video src={media.urls.stream} controls preload="metadata" />
    : <img src={media.urls.large ?? media.urls.original} alt={media.original_filename} />;
}

React

export function ResponsiveImage({ media, width }) {
  const srcSet = media.variants.map((v) => `${v.url} ${v.width}w`).join(', ');
  return <img src={media.urls.medium ?? media.urls.original} srcSet={srcSet}
              sizes={`${width}px`} width={width} alt="" loading="lazy" />;
}

export const VideoPlayer = ({ media }) =>
  <video src={media.urls.stream} controls preload="metadata" playsInline />;

React Native

const body = new FormData();
body.append('file', { uri: asset.uri, name: asset.fileName ?? 'upload.jpg', type: asset.mimeType ?? 'image/jpeg' });
await fetch(`${API}/media`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, body });

<Image source={{ uri: media.urls.small }} style={{ width: 320, aspectRatio: media.width / media.height }} />
<Video source={{ uri: media.urls.stream }} useNativeControls />   // expo-av / react-native-video

OpenAPI spec

An OpenAPI 3.1 description of every endpoint, including the /images and /videos forms, is available for Postman, Insomnia, or client generators: