Documentation

Openinary Docs

Complete reference for Openinary — a self-hosted, open-source media management platform with Cloudinary-compatible transformation URLs, REST API, JavaScript SDK, and webhooks.

Overview

What Openinary is and what it can do.

Openinary is a self-hosted media management platform. Upload images and videos, apply on-the-fly transformations via URL parameters (resize, crop, format convert, quality compression), organize into folders, and serve via a built-in CDN-friendly URL scheme.

Transformation URLs

Cloudinary-style URL parameters — resize, crop, format, quality, filters.

Video Support

Transcode to MP4/WebM/GIF, extract frames, generate sprite sheets.

REST API + SDK

Full REST API with a typed JavaScript/TypeScript SDK.

Webhooks

HTTP callbacks for upload, delete, transcode events.

User Auth

JWT-based auth with Admin / Editor / Viewer roles and API keys.

Upload Presets

Reusable upload configs — format restrictions, size limits, auto-tags.

Installation

Running Openinary with Docker Compose.

docker-compose.ymlyaml
services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: openinary
      POSTGRES_USER: openinary
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data

  app:
    image: ghcr.io/yourorg/openinary:latest
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://openinary:secret@postgres:5432/openinary
      UPLOAD_DIR: /data/uploads
      CACHE_DIR: /data/cache
      PUBLIC_URL: http://localhost:3000
      AUTH_ENABLED: "true"
      DASHBOARD_USERNAME: admin
      DASHBOARD_PASSWORD: change-me-in-production
      IMG_SIGNING_SECRET: change-me-in-production
    volumes:
      - uploads:/data/uploads
      - cache:/data/cache
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  pgdata:
  uploads:
  cache:
Start servicesbash
docker compose up -d
# Migrations run automatically on first startup
# Open http://localhost:3000 and sign in

Configuration

Server environment variables.

ParameterTypeDescription
DATABASE_URLrequired
stringPostgreSQL connection string.
PUBLIC_URLrequired
stringPublic-facing base URL of the app (e.g. https://media.example.com).
AUTH_ENABLED
booleanRequire login to access the dashboard and API. Default: false.
DASHBOARD_USERNAME
stringAdmin login username. Default: admin.
DASHBOARD_PASSWORD
stringAdmin login password. Default: changeme.
IMG_SIGNING_SECRET
stringHMAC secret for signed transformation URLs.
UPLOAD_DIR
stringAbsolute path for uploaded files. Default: /data/uploads.
CACHE_DIR
stringAbsolute path for transform cache. Default: /data/cache.

Quick Start

Upload your first image and serve it with a transformation.

1. Upload via API

curl -X POST http://localhost:3000/api/upload \
  -H "Authorization: Bearer <your-jwt-token>" \
  -F "file=@photo.jpg" \
  -F "folder=marketing"
Response200
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "filename": "photo.jpg",
  "folder": "marketing",
  "url": "http://localhost:3000/api/uploads/marketing/photo.jpg",
  "size": 245120,
  "mime": "image/jpeg",
  "width": 1920,
  "height": 1080
}

2. Serve with transformations

Append transform parameters to the image URL using the path format:

# Pattern
GET /img/<transforms>/<file-key>

# Examples
GET /img/w_400,h_400,c_fill/marketing/photo.jpg        → 400×400 crop
GET /img/w_800,f_webp,q_80/marketing/photo.jpg         → 800px wide WebP
GET /img/w_200,h_200,r_max/marketing/photo.jpg         → circular thumbnail
GET /img/e_grayscale/marketing/photo.jpg               → black & white
GET /img/t_thumbnail/marketing/photo.jpg               → named preset

3. Use the JavaScript SDK

import { OpeninaryClient } from "openinary";

const client = new OpeninaryClient({
  baseUrl: "http://localhost:3000",
  apiKey: "ok_yourkey",
});

// Upload a file
const asset = await client.assets.upload(file, { folder: "avatars" });

// Build transformation URL
const url = client.assets.url(asset.file_key, {
  width: 200, height: 200, crop: "fill", format: "webp", quality: 80,
});
console.log(url);
// → http://localhost:3000/img/w_200,h_200,c_fill,f_webp,q_80/avatars/photo.jpg

Authentication

JWT-based auth and API key auth.

ℹ️ Note: When AUTH_ENABLED=false, all endpoints are public. When enabled, every request must include either a Bearer JWT or an ApiKey token in the Authorization header.

Login

POST/api/auth/login

Exchange credentials for a JWT token.

curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@example.com","password":"secret"}'
Response200
{
  "token": "eyJhbGciOiJIUzI1NiJ9...",
  "user": {
    "id": "...",
    "email": "admin@example.com",
    "role": "admin"
  }
}

Using tokens

# JWT token (from login)
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

# API key (created in dashboard → API Keys)
Authorization: ApiKey ok_yourkey_here

Other auth endpoints

GET/api/auth/me🔐 Auth required

Get the currently authenticated user.

Upload

Upload files by form, URL, or base64.

File upload

POST/api/upload🔐 Auth required

Upload one or more files via multipart/form-data.

ParameterTypeDescription
filerequired
File (multipart)The file to upload. Repeat for multiple files.
folder
stringDestination folder. Default: default.
tags
stringComma-separated tags to apply.
preset
stringName of an upload preset to apply.
curl -X POST http://localhost:3000/api/upload \
  -H "Authorization: Bearer <token>" \
  -F "file=@image.jpg" \
  -F "folder=products" \
  -F "tags=hero,2024"

URL import

POST/api/upload/from-url🔐 Auth required

Fetch a remote URL and store it as an asset.

curl -X POST http://localhost:3000/api/upload/from-url \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/photo.jpg","folder":"imports"}'

Base64 upload

POST/api/upload/base64🔐 Auth required

Upload a base64-encoded file payload.

curl -X POST http://localhost:3000/api/upload/base64 \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"data":"data:image/jpeg;base64,/9j/4AAQ...","filename":"avatar.jpg","folder":"users"}'

Unsigned upload (preset required)

⚠️ Warning: Unsigned uploads bypass authentication but still enforce upload preset constraints (allowed formats, max size, folder). Never use unsigned uploads without a preset.
POST/api/upload/unsigned/:preset

Upload without a token when the preset has unsigned=true.

Assets

CRUD operations on uploaded assets.

GET/api/assets🔐 Auth required

List assets with optional filters.

GET/api/asset/:id🔐 Auth required

Get a single asset by ID.

PATCH/api/asset/:id🔐 Auth required

Update asset metadata (filename, folder, context).

DELETE/api/asset/:id🔐 Auth required

Soft-delete (move to trash).

DELETE/api/asset/:id/permanent🔐 Auth required

Permanently delete from disk.

POST/api/asset/:id/restore🔐 Auth required

Restore from trash.

POST/api/asset/:id/replace🔐 Auth required

Replace file contents (keeps same ID & metadata).

GET /assets query params

ParameterTypeDescription
folder
stringFilter by folder name.
tag
stringFilter by tag.
mime
stringFilter by MIME prefix (e.g. image/, video/).
limit
numberMax results per page. Default: 50, max: 200.
offset
numberPagination offset.
sort
stringSort field: created_at | filename | size.
order
stringasc or desc. Default: desc.

Bulk operations

POST/api/assets/bulk-delete🔐 Auth required

Soft-delete multiple assets.

POST/api/assets/bulk-tag🔐 Auth required

Add tags to multiple assets.

POST/api/assets/bulk-move🔐 Auth required

Move multiple assets to a folder.

# Bulk delete
curl -X POST http://localhost:3000/api/assets/bulk-delete \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"ids":["uuid-1","uuid-2","uuid-3"]}'

Tags

POST/api/asset/:id/tags🔐 Auth required

Add tags to an asset.

DELETE/api/asset/:id/tags🔐 Auth required

Remove tags from an asset.

GET/api/tags🔐 Auth required

List all tags with usage counts.

Versions

GET/api/asset/:id/versions🔐 Auth required

List version history for an asset.

POST/api/asset/:id/restore-version/:vId🔐 Auth required

Restore a previous version.

Search

GET/api/search🔐 Auth required

Full-text search across filenames and tags.

ParameterTypeDescription
qrequired
stringSearch query.
folder
stringScope to folder.
tag
stringScope to tag.
limit
numberMax results.

Transformation URLs

On-the-fly image and video processing via URL parameters.

Tip: Transformation results are cached on disk. The same parameter combination for the same file always serves the cached output — no reprocessing.

URL format

GET /img/<transform-string>/<file-key>

The transform string is a comma-separated list of key_value pairs. Multiple transform stages can be chained with /.

Image parameters

ParameterTypeDescription
w
numberTarget width in pixels.
h
numberTarget height in pixels.
c
stringCrop mode: fill | fit | scale | thumb | pad | crop.
g
stringGravity: center | north | south | east | west | face.
f
stringOutput format: webp | avif | jpeg | jpg | png | gif.
q
numberQuality 1–100. JPEG/WebP/AVIF only.
r
stringBorder radius: number (px) or max (circle).
e
stringEffect: grayscale | blur | sharpen | negate | flip | flop.
b
stringBackground color for pad mode, hex without #.
t
stringNamed transformation. e.g. t_thumbnail.
dpr
numberDevice pixel ratio multiplier (1–3).

Video parameters

ParameterTypeDescription
f
stringOutput format: mp4 | webm | ogv | gif.
w
numberWidth (maintains aspect unless h also set).
h
numberHeight.
c
stringCrop mode for video: fill | fit | scale.
so
numberStart offset in seconds (trim).
eo
numberEnd offset in seconds (trim).
q
numberQuality / CRF (0–51, lower = better).
fps
numberOutput frame rate.
vc
stringVideo codec: h264 | vp9 | libx265.
ac
stringAudio codec: aac | libopus | none.
br
stringBitrate: 1000k | 2M.

Examples

# 400×400 fill crop, WebP, quality 80
/img/w_400,h_400,c_fill,f_webp,q_80/photos/portrait.jpg

# Circular avatar, 200px
/img/w_200,h_200,c_fill,r_max/users/avatar.png

# Grayscale + blur
/img/e_grayscale/img/e_blur/product.jpg     ← chained stages

# Named preset (t_thumbnail must exist in Transformations page)
/img/t_thumbnail/marketing/hero.jpg

# Device pixel ratio for Retina displays
/img/w_200,dpr_2/logo.png                  ← serves 400px

# Trim video from 5s to 15s, output WebM
/img/so_5,eo_15,f_webm/videos/clip.mp4

Signed URL endpoint

GET/api/asset/:id/signed-url🔐 Auth required

Generate a time-limited signed transformation URL.

ParameterTypeDescription
transformsrequired
stringTransform string (e.g. w_400,c_fill).
expires
numberExpiry in seconds from now. Default: 3600.

Folders & Tags

Organize assets into folder hierarchies and tag collections.

GET/api/folders🔐 Auth required

List all folders with asset counts and sizes.

DELETE/api/folders/:folder🔐 Auth required

Delete an empty folder.

GET/api/tags🔐 Auth required

List all tags with usage counts.

ℹ️ Note: Folders are implicit — they are created when the first asset is uploaded to them and deleted when empty. Nested folders use forward-slash paths: photos/2024/january.
Response200
[
  {
    "name": "marketing",
    "asset_count": 42,
    "total_size": 104857600
  },
  {
    "name": "marketing/heroes",
    "asset_count": 5,
    "total_size": 20971520
  },
  {
    "name": "products",
    "asset_count": 118,
    "total_size": 524288000
  }
]

Upload Presets

Reusable upload configurations.

GET/api/upload-presets🔐 Auth required

List all upload presets.

POST/api/upload-presets🔐 Auth required

Create a new upload preset.

DELETE/api/upload-presets/:name🔐 Auth required

Delete an upload preset.

GET/api/upload-presets/:name/sign🔐 Auth required

Generate a signed upload URL for a preset.

Preset fields

ParameterTypeDescription
namerequired
stringUnique identifier (alphanumeric, hyphens, underscores).
folder
stringDefault upload folder. Default: default.
allowed_formats
string[]Whitelisted extensions. Empty = any.
max_size
numberMax file size in bytes. Default: 52428800 (50 MB).
tags
string[]Tags automatically applied to uploads.
transform_string
stringTransform applied on ingest.
unsigned
booleanAllow uploads without a token. Default: false.

Named Transformations

Save reusable transform presets and reference them with t_name.

GET/api/transformations🔐 Auth required

List all named transformations.

POST/api/transformations🔐 Auth required

Create or upsert a named transformation.

PATCH/api/transformations/:name🔐 Auth required

Update the transform string.

DELETE/api/transformations/:name🔐 Auth required

Delete a named transformation.

# Create "thumbnail" preset
curl -X POST http://localhost:3000/api/transformations \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"name":"thumbnail","transform_string":"w_200,h_200,c_fill,f_webp,q_80"}'

# Use it in any image URL
GET /img/t_thumbnail/photos/portrait.jpg

Video Transcoding

Async transcoding jobs for format conversion and processing.

ℹ️ Note: Transcoding is asynchronous. Submit a job and poll its status, or listen for the transcode.complete webhook event.
POST/api/asset/:id/transcode🔐 Auth required

Enqueue a transcoding job. Returns 202 Accepted.

GET/api/asset/:id/transcode-status🔐 Auth required

List all transcode jobs for an asset.

GET/api/transcode-jobs/:jobId🔐 Auth required

Poll a specific job status.

GET/api/asset/:id/sprite🔐 Auth required

Generate a sprite sheet of video thumbnails.

GET/api/asset/:id/frame🔐 Auth required

Extract a single frame at a timestamp.

Job request body

ParameterTypeDescription
format
stringOutput format: mp4 | webm | ogv | gif.
width
numberOutput width.
height
numberOutput height.
quality
numberCRF quality (lower = better, 0–51).
bitrate
stringe.g. 1500k or 2M.
fps
numberOutput frame rate.
startOffset
numberTrim start in seconds.
endOffset
numberTrim end in seconds.
# Submit a transcoding job
curl -X POST http://localhost:3000/api/asset/uuid-here/transcode \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"format":"webm","width":1280,"quality":28}'

# Poll status
curl http://localhost:3000/api/transcode-jobs/<job-id> \
  -H "Authorization: Bearer <token>"
Response200
{
  "jobId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "assetId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "done",
  "outputUrl": "https://openinary.example.com/uploads/transcoded/f47ac10b.webm",
  "createdAt": "2024-01-15T10:30:00Z",
  "updatedAt": "2024-01-15T10:30:45Z"
}

Analytics

Storage, bandwidth, and transformation usage metrics.

GET/api/analytics/storage🔐 Auth required

Storage breakdown by folder and MIME type.

GET/api/analytics/bandwidth🔐 Auth required

Request and bandwidth usage over time.

GET/api/analytics/transforms🔐 Auth required

Top transformation presets by usage.

GET/api/analytics/requests🔐 Auth required

Raw request log with path/method/status filters.

ParameterTypeDescription
days
numberLookback window in days. Default: 30.
path
string(requests only) Filter by URL path prefix.
method
string(requests only) Filter by HTTP method.
status
number(requests only) Filter by HTTP status code.
limit
numberMax rows.
offset
numberPagination offset.

Webhooks

Receive HTTP POST callbacks on events.

GET/api/webhooks🔐 Auth required

List webhooks.

POST/api/webhooks🔐 Auth required

Create a webhook.

PATCH/api/webhooks/:id🔐 Auth required

Update URL, events, or enabled state.

DELETE/api/webhooks/:id🔐 Auth required

Delete a webhook.

GET/api/webhooks/:id/deliveries🔐 Auth required

List delivery history (last 50).

POST/api/webhooks/:id/test🔐 Auth required

Send a test ping.

Available events

upload.completeasset.deletedasset.restoredasset.updatedtranscode.completemoderation.flagged

Webhook payload

Response200
{
  "event": "upload.complete",
  "timestamp": "2024-01-15T10:30:00Z",
  "data": {
    "asset_id": "550e8400-e29b-41d4-a716-446655440000",
    "filename": "photo.jpg",
    "folder": "marketing",
    "url": "https://openinary.example.com/uploads/marketing/photo.jpg"
  }
}

Signature verification

Every delivery includes an X-Openinary-Signature header.

import { createHmac } from "crypto";

function verifyWebhook(body: string, signature: string, secret: string): boolean {
  const expected = "sha256=" + createHmac("sha256", secret)
    .update(body)
    .digest("hex");
  return signature === expected;
}

// Express example
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.headers["x-openinary-signature"] as string;
  if (!verifyWebhook(req.body.toString(), sig, process.env.WEBHOOK_SECRET!)) {
    return res.status(401).send("Invalid signature");
  }
  const payload = JSON.parse(req.body.toString());
  console.log("Event:", payload.event);
  res.sendStatus(200);
});

Moderation

Review and approve or flag uploaded assets.

GET/api/moderation🔐 Auth required

Get assets by moderation status with counts.

PATCH/api/asset/:id/moderation🔐 Auth required

Set moderation status: approved | flagged | pending.

ParameterTypeDescription
status
approved | flagged | pendingFilter assets by status. Default: flagged.
limit
numberMax assets to return. Default: 50.
# Approve an asset
curl -X PATCH http://localhost:3000/api/asset/uuid/moderation \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"status":"approved"}'

Users & API Keys

Manage users and programmatic API keys.

Users

GET/api/users🔐 Auth required

List all users (admin only).

PATCH/api/users/:id🔐 Auth required

Update role (admin only).

DELETE/api/users/:id🔐 Auth required

Delete a user (admin only).

ParameterTypeDescription
role
admin | editor | viewerUser role. Admins can manage everything. Editors can upload. Viewers are read-only.

API Keys

GET/api/api-keys🔐 Auth required

List your API keys.

POST/api/api-keys🔐 Auth required

Create a new API key. The full key is only shown once.

DELETE/api/api-keys/:id🔐 Auth required

Revoke an API key.

JavaScript SDK — Installation

The official typed SDK for Node.js and browsers.

npm install openinary
# or
pnpm add openinary
ℹ️ Note: The SDK ships both ESM and CommonJS builds with full TypeScript declaration files. React components (openinary/upload-widget and openinary/media-library) require React ≥ 17 as a peer dependency.

OpeninaryClient

Create a client instance and configure authentication.

import { OpeninaryClient } from "openinary";

const client = new OpeninaryClient({
  baseUrl: "http://localhost:3000",  // required
  apiKey:  "ok_yourkey",           // API key or…
  token:   "eyJhbGci...",            // …JWT token
});

Constructor options

ParameterTypeDescription
baseUrlrequired
stringBase URL of your Openinary server.
apiKey
stringAPI key for server-to-server calls.
token
stringJWT token for browser-side use.
fetch
functionCustom fetch implementation (for Node <18 or testing).

Asset Methods

client.assets.*

const client = new OpeninaryClient({ baseUrl, apiKey });

// List assets
const assets = await client.assets.list({ folder: "marketing", limit: 50 });

// Upload a file (Node.js Buffer or browser File)
const asset = await client.assets.upload(file, {
  folder: "products",
  tags: ["hero", "2024"],
});

// Upload from URL
const asset = await client.assets.uploadFromUrl("https://example.com/img.jpg", {
  folder: "imports",
});

// Upload from base64
const asset = await client.assets.uploadFromBase64(
  "data:image/jpeg;base64,/9j/4AAQ...",
  { filename: "avatar.jpg", folder: "users" }
);

// Get single asset
const asset = await client.assets.get("550e8400-...");

// Update metadata
const updated = await client.assets.update("550e8400-...", {
  filename: "new-name.jpg",
  folder:   "archived",
  context:  { author: "Alice", campaign: "summer" },
});

// Delete (soft)
await client.assets.delete("550e8400-...");

// Restore from trash
await client.assets.restore("550e8400-...");

// Search
const results = await client.assets.search("mountains", { folder: "travel" });

// Add / remove tags
await client.assets.addTags("uuid", ["featured", "sale"]);
await client.assets.removeTags("uuid", ["sale"]);

// Bulk operations
await client.assets.bulkDelete(["uuid-1", "uuid-2"]);
await client.assets.bulkMove(["uuid-1", "uuid-2"], "archived");

Transform Helpers

Build transformation URLs without a round-trip.

// Build a URL with transform params
const url = client.assets.url(asset.file_key, {
  width:   400,
  height:  400,
  crop:    "fill",
  format:  "webp",
  quality: 80,
});
// → http://localhost:3000/img/w_400,h_400,c_fill,f_webp,q_80/photos/img.jpg

// All supported TransformOptions fields:
type TransformOptions = {
  width?:      number;           // w_
  height?:     number;           // h_
  crop?:       string;           // c_ (fill|fit|scale|thumb|pad|crop)
  gravity?:    string;           // g_ (center|face|north|south…)
  quality?:    number;           // q_
  format?:     string;           // f_ (webp|avif|jpeg|png|gif|mp4…)
  radius?:     string | number;  // r_ (px or "max" for circle)
  effect?:     string;           // e_ (grayscale|blur|sharpen…)
  background?: string;           // b_
  named?:      string;           // t_ (named preset)
  dpr?:        number;           // dpr_
  startOffset?: number;          // so_ (video trim start)
  endOffset?:   number;          // eo_ (video trim end)
  fps?:         number;          // fps_
  bitrate?:     string;          // br_
};

// Multi-stage transforms (chained with /)
const url = client.assets.buildTransformString([
  { width: 800, crop: "fill" },
  { effect: "grayscale", quality: 70 },
]);
// → w_800,c_fill/e_grayscale,q_70

// Get a signed URL (requires server call)
const { url, expires_at } = await client.assets.signedUrl("uuid", {
  transforms: "w_400,c_fill",
  expires: 3600,
});

Webhook Methods

client.webhooks.*

// List webhooks
const hooks = await client.webhooks.list();

// Create
const hook = await client.webhooks.create({
  url:    "https://your-server.com/webhook",
  events: ["upload.complete", "transcode.complete"],
});
console.log(hook.secret); // ← store securely, shown once

// Toggle enabled
await client.webhooks.update(hook.id, { enabled: false });

// Delivery history
const deliveries = await client.webhooks.deliveries(hook.id);

// Delete
await client.webhooks.delete(hook.id);

Admin Methods

client.admin.* — require admin role.

// Users
const users   = await client.admin.users.list();
const updated = await client.admin.users.updateRole(userId, "editor");
await client.admin.users.delete(userId);

// API Keys
const keys    = await client.admin.apiKeys.list();
const created = await client.admin.apiKeys.create("CI pipeline");
console.log(created.key); // ← shown once
await client.admin.apiKeys.revoke(keyId);

// Named transformations
await client.admin.namedTransformations.create("avatar", "w_200,h_200,c_fill,r_max");
await client.admin.namedTransformations.delete("avatar");

// Upload presets
await client.admin.uploadPresets.create({
  name: "strict_images",
  allowed_formats: ["jpg","png","webp"],
  max_size: 5 * 1024 * 1024,
  unsigned: false,
});

// Analytics
const storage   = await client.admin.analytics.storage();
const bandwidth = await client.admin.analytics.bandwidth({ days: 7 });

Guide: Transform URLs

How to construct transformation URLs manually.

All transformation parameters follow the key_value format separated by commas. Multiple pipeline stages are separated by /.

// Helper to build transform strings in your own frontend
function buildTransformUrl(baseUrl: string, fileKey: string, transforms: string) {
  return `${baseUrl}/img/${transforms}/${fileKey}`;
}

// Progressive enhancement: serve WebP with JPEG fallback
const webpUrl  = buildTransformUrl(BASE, key, "w_800,f_webp,q_80");
const jpegUrl  = buildTransformUrl(BASE, key, "w_800,q_80");

// <picture> tag
// <picture>
//   <source srcSet={webpUrl} type="image/webp" />
//   <img src={jpegUrl} alt="photo" />
// </picture>

// Responsive srcset
const srcset = [400, 800, 1200].map(
  (w) => `${buildTransformUrl(BASE, key, `w_${w},f_webp,q_80`)} ${w}w`
).join(", ");

Guide: Upload Widget

Drop-in React component for file uploads.

npm install openinary
import { OpeninaryUploadWidget } from "openinary/upload-widget";

export function MyUploader() {
  return (
    <OpeninaryUploadWidget
      uploadUrl="http://localhost:3000/api/upload"
      authToken={userJwt}
      folder="user-uploads"
      maxFiles={10}
      accept="image/*,video/mp4"
      onSuccess={(assets) => {
        console.log("Uploaded:", assets);
      }}
      onError={(err) => console.error(err)}
    />
  );
}

Props

ParameterTypeDescription
uploadUrlrequired
stringServer upload endpoint URL.
authToken
stringJWT or API key for authenticated uploads.
folder
stringDestination folder.
maxFiles
numberMax simultaneous uploads. Default: unlimited.
accept
stringMIME type filter for the file picker.
onSuccess
functionCalled with an array of uploaded Asset objects.
onError
functionCalled with an error message string.
className
stringCSS class for the root element.

Guide: Media Library Widget

Embeddable asset picker for selecting existing media.

import { OpeninaryMediaLibrary } from "openinary/media-library";

export function AssetPicker({ onSelect }: { onSelect: (url: string) => void }) {
  return (
    <OpeninaryMediaLibrary
      baseUrl="http://localhost:3000"
      authToken={userJwt}
      mode="modal"            // "modal" | "inline"
      multiSelect={false}
      onSelect={(assets) => onSelect(assets[0].url)}
      onClose={() => {}}
    />
  );
}

Guide: Verifying Webhooks

How to validate incoming webhook signatures.

import { createHmac, timingSafeEqual } from "crypto";

/**
 * Call this in your webhook handler to verify authenticity.
 * @param rawBody  The raw request body buffer (do NOT parse to JSON first)
 * @param signature  Value of the X-Openinary-Signature header
 * @param secret  Your webhook's signing secret
 */
function verifyWebhook(rawBody: Buffer, signature: string, secret: string): boolean {
  if (!signature.startsWith("sha256=")) return false;
  const expected = "sha256=" + createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
  try {
    return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  } catch {
    return false;
  }
}

// Next.js App Router example
export async function POST(req: Request) {
  const rawBody = Buffer.from(await req.arrayBuffer());
  const sig = req.headers.get("x-openinary-signature") ?? "";

  if (!verifyWebhook(rawBody, sig, process.env.WEBHOOK_SECRET!)) {
    return new Response("Unauthorized", { status: 401 });
  }

  const payload = JSON.parse(rawBody.toString());

  switch (payload.event) {
    case "upload.complete":
      await indexAsset(payload.data);
      break;
    case "transcode.complete":
      await notifyUser(payload.data.job_id);
      break;
  }

  return new Response(null, { status: 200 });
}
⚠️ Warning: Always use timingSafeEqual (or equivalent constant-time comparison) to prevent timing attacks when comparing HMAC signatures.

Guide: Video Transcoding

Async job workflow for video conversion.

import { OpeninaryClient } from "openinary";

const client = new OpeninaryClient({ baseUrl, apiKey });

// 1. Enqueue a transcoding job
const { job_id } = await client.assets.enqueueTranscode(assetId, {
  format:  "webm",
  width:   1280,
  quality: 28,         // CRF — lower = better quality
  bitrate: "2M",
});

// 2. Poll for completion
async function waitForTranscode(jobId: string, maxWait = 300_000) {
  const start = Date.now();
  while (Date.now() - start < maxWait) {
    const job = await client.assets.getTranscodeJob(jobId);
    if (job.status === "done")   return job.outputUrl;
    if (job.status === "failed") throw new Error(job.error);
    await new Promise((r) => setTimeout(r, 3000));
  }
  throw new Error("Transcode timed out");
}

const outputUrl = await waitForTranscode(job_id);
console.log("Transcoded video:", outputUrl);

// 3. Alternative: listen for the webhook instead of polling
// Set up a webhook for "transcode.complete" and update your DB there.

// 4. Extract a frame (for thumbnails)
const frameUrl = `${baseUrl}/api/asset/${assetId}/frame?t=5.0`;

// 5. Sprite sheet for video preview
const spriteUrl = `${baseUrl}/api/asset/${assetId}/sprite?cols=5&rows=4&thumb_w=160`;