Media Service API v1
REST API for image upload with automatic resizing and secure, Range-capable video streaming.
| Base URL | https://video.digibhai.store/api/v1 |
|---|---|
| Format | JSON (uploads: multipart/form-data) |
| Authentication | Bearer token (Authorization: Bearer <token>) |
| Machine-readable spec | openapi.yaml · interactive explorer |
Quick start
- Register an account for your application.
- Wait for an administrator to approve the account.
- Create an API token on the API Tokens page.
- 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.
| Status | Tokens | API calls | Media delivery |
|---|---|---|---|
| pending | cannot be created | 403 | – |
| approved | yes | allowed | served |
| suspended | kept but refused | 403 | 404 (including public media and signed URLs) |
| expired | kept but refused | 403 | 404 (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 exceededonfile). 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
| Ability | Grants |
|---|---|
media:read | List/show media, issue signed URLs, fetch private bytes with the token |
media:write | Upload, change visibility |
media:delete | Delete 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.
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."] } }
| Status | Meaning |
|---|---|
401 | Missing, invalid, expired or revoked token |
403 | Account not approved, suspended or expired · administrator account · token lacks ability · invalid/expired signed URL |
404 | Media not found, owned by another account, or private without credentials. These cases can't be told apart, so UUIDs can't be probed. |
409 | Variant or stream requested while still processing |
413 | Upload larger than the server allows |
416 | Range not satisfiable |
422 | Validation failed, including an upload over the storage quota (errors populated) |
429 | Rate 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.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /auth/tokens | credentials | Exchange email/password for a token |
| GET | /auth/me | token | Account + token info |
| DELETE | /auth/tokens/current | token | Revoke current token |
| GET | /media | media:read | List own media |
| POST | /media | media:write | Upload |
| GET | /media/{id} | media:read | Media object |
| PATCH | /media/{id} | media:write | Change visibility |
| DELETE | /media/{id} | media:delete | Delete media and files |
| GET | /media/{id}/signed-url | media:read | Temporary signed URL |
| GET | /media/{id}/{variant} | public · signed · token | Variant / thumbnail / original bytes |
| GET | /videos/{id}/stream | public · signed · token | Range-capable video stream |
Auth endpoints
/auth/tokensthrottled: 5/minExchange 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.
/auth/meReturns 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": "…" }
} }
/auth/tokens/currentRevokes the token used for the request.
Upload
/media · /images · /videosmedia:write| Field | Type | Description |
|---|---|---|
file | file, required | The image or video |
visibility | private | public | Default private |
Files are validated by their content (magic bytes), not by the name or the declared type:
| Images | Videos | |
|---|---|---|
| Types | image/jpeg, image/png, image/webp, image/gif | video/mp4, video/webm, video/quicktime |
| Extensions | jpg, jpeg, jpe, png, webp, gif | mp4, m4v, webm, mov, qt |
| Max size | 10 MB | 500 MB |
| Extra | ≤ 40 megapixels | container 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:
originalat once; the variants appear whenstatusisready. - Videos: empty until
ready, thenoriginalandstream. Poster thumbnails (thumbnail,small,medium,large, taken from one frame) are added only when FFmpeg is installed on the server.
List, get, update, delete
/mediamedia:readQuery: type (image|video), status, visibility, per_page (1–100, default 20), page. Newest first, own media only.
/media/{id}media:readReturns the media object. /images/{id} and /videos/{id} return 404 for the other type.
/media/{id}media:write{ "visibility": "public" }/media/{id}media:deleteDeletes the record immediately. The original and all variants are removed from storage by a background job.
Signed URLs
/media/{id}/signed-url?variant=medium&ttl=600media:read| Param | Default | Rules |
|---|---|---|
variant | original (images), stream (videos) | one of the media's urls keys |
ttl | 3600 s | 60 – 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
Authorizationheader is needed. It works for every Range request a video player makes. - A
variantthe media does not have →422. Invalid or expired signature →403. Suspended or expired account →404.
Image variants
/media/{id}/{variant} · /images/{id}/{variant}public · signed · token| Variant | Max width | Format | Typical use |
|---|---|---|---|
thumbnail | 150 px | WEBP (q82) | avatars, lists, grids |
small | 320 px | WEBP (q82) | mobile cards |
medium | 640 px | WEBP (q82) | tablet / desktop cards |
large | 1280 px | WEBP (q82) | detail views, lightboxes |
original | as uploaded | unchanged | downloads, 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
/videos/{id}/streampublic · signed · tokenStreams only the requested bytes, so players can start instantly and seek anywhere. Videos must be ready (409 while processing, 404 if processing failed).
| Request header | Effect |
|---|---|
Range: bytes=0-1048575 | first MiB (inclusive range) |
Range: bytes=5000000- | from byte 5,000,000 onwards (seeking), at most 2 MiB per response |
Range: bytes=-1024 | last 1024 bytes |
Range: bytes=0-99,500-599 | multiple ranges → multipart/byteranges |
If-Range: "<etag>" or HTTP-date | honour Range only if unchanged, else full 200 |
If-None-Match / If-Modified-Since | 304 Not Modified when unchanged |
| HEAD | same 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. ReadContent-Rangeand 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
| Response | Headers |
|---|---|
| Public bytes | Cache-Control: public, max-age=86400 |
| Private bytes | Cache-Control: private, max-age=3600, Vary: Authorization |
| All bytes | ETag, Last-Modified, Accept-Ranges: bytes, Content-Type, Content-Length, X-Content-Type-Options: nosniff, Content-Security-Policy: sandbox, Content-Disposition: inline |
| Token / signed-URL responses | Cache-Control: no-store |
ETag is the SHA-256 of the served file. Send it back as If-None-Match to get 304.
Rate limits
| Scope | Limit / minute | Keyed by |
|---|---|---|
| JSON API | 120 | account |
| Uploads | 30 | account |
| Media delivery / streaming | 600 | token or IP |
POST /auth/tokens | 5 | email + 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: