Developer documentation · v1

Connect any app to UQ Cloud in minutes.

A single REST endpoint to ingest media, a URL-driven transformation grammar, and signed webhooks for asynchronous events. Everything you need to build a production integration is on this page.

One click copies a complete, AI-ready brief of every endpoint, auth flow, and example — paste into ChatGPT, Claude, Cursor, or any model to have it wire your app to UQ Cloud instantly.

REST + JSON
One endpoint. Any language. cURL to production.
URL transforms
Resize, compress, convert — all in the path.
Signed webhooks
HMAC-SHA256 delivery you can trust.
Overview

Introduction

UQ Cloud is a headless media pipeline. Your app POSTs a source URL, we fetch, validate, store, and return a stable public_id. From that moment, any resized, compressed, or format-converted version of the asset is available at a predictable URL — no re-uploads, no batch jobs.

01
Ingest
POST a URL, receive a public_id.
02
Store
Original preserved, provenance tracked.
03
Transform
/t/w_800,q_80,f_webp/… — done.
04
Notify
Signed webhooks for every event.
60 seconds

Quickstart

  1. 1
    Create a key
    Visit API Keys and generate a key. Copy the secret — it's shown once.
  2. 2
    Send your first upload
    Point us at any publicly reachable URL. We fetch it server-side.
  3. 3
    Use the returned public_id
    Compose a transform URL and drop it into an <img> tag.
bash
curl -X POST https://cloud.zeni.host/api/public/uploads \
  -H "Authorization: Bearer pfk_XXXX.YYYY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://picsum.photos/1200"}'
Bearer tokens

Authentication

Every request must include an Authorization header. Keys are formatted <prefix>.<secret>. The prefix is public (safe to log); the secret is hashed with SHA-256 server-side and compared in constant time.

Never ship the secret to browsers
Keys grant write access. Store them server-side only.
Rotate freely
Revoke a key on the Keys page — it stops working instantly.
Scoped by user
A key can only touch its owner's assets.
http
Authorization: Bearer pfk_9f3a2c.6b41d0…c9e2
POST /api/public/uploads

Upload API

POST/api/public/uploads

Ingest a remote asset. UQ Cloud fetches it server-side (SSRF-guarded and size-capped at 50 MB), validates the MIME type, stores the original, and returns a stable identifier.

Request body

FieldTypeRequiredDescription
urlstringyesPublicly reachable http(s) URL of the source asset.
filenamestringnoOverride filename. Defaults to the URL's last segment.
folderstringnoSlash-delimited folder path. Nested folders are created on demand (e.g. "clients/acme/logos").
drivestringnoDestination drive: "supabase" (default), a drive UUID, or a drive label/provider (e.g. "koofr", "r2", "idrive"). Call GET /api/public/drives to discover ids.

Response · 201

json
{
  "id": "e8b1…",
  "public_id": "sunset-cliffs-4k",
  "filename": "sunset.jpg",
  "bytes": 482113,
  "kind": "image",
  "drive": "Supabase (built-in)",
  "drive_id": "supabase",
  "storage_path": "uploads/images/2026/07/sunset-cliffs-4k.jpg"
}

Try it live

Multi-cloud storage

Drives

Route uploads to the built-in Supabase bucket or to any cloud drive configured by your workspace admin — Cloudflare R2, S3, Backblaze B2, Koofr, IDrive Sync, or any WebDAV endpoint. Each drive advertises its own capacity, so external clients can pick the cheapest or emptiest destination programmatically.

List available drives

GET/api/public/drives
bash
curl https://cloud.zeni.host/api/public/drives \
  -H "Authorization: Bearer $UQCLOUD_KEY"

Response · 200

json
{
  "drives": [
    {
      "id": "supabase",
      "label": "Supabase (built-in)",
      "provider": "supabase",
      "enabled": true,
      "max_bytes": null,
      "used_bytes": null,
      "free_bytes": null
    },
    {
      "id": "9f2e6c1a-…",
      "label": "Koofr Main",
      "provider": "webdav",
      "enabled": true,
      "max_bytes": 10737418240,
      "used_bytes": 12345,
      "free_bytes": 10737405895
    },
    {
      "id": "3aa8b4d2-…",
      "label": "R2 Account 1",
      "provider": "r2",
      "enabled": true,
      "max_bytes": 53687091200,
      "used_bytes": 8000,
      "free_bytes": 53687083200
    }
  ]
}

supabase reports null capacity (managed pool). Cloud drives report the quota assigned to the drive.

Upload to a specific drive

bash
curl -X POST https://cloud.zeni.host/api/public/uploads \
  -H "Authorization: Bearer $UQCLOUD_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/photo.jpg",
    "filename": "products/photo.jpg",
    "drive": "koofr"
  }'

Drive selection rules

ValueMatches
"supabase"Built-in Supabase bucket (default).
<uuid>Exact drive id from GET /drives.
<label>Drive label, case-insensitive (e.g. "Koofr Main").
<provider>First enabled drive of that provider: r2 · s3 · b2 · webdav.

Storage layout

DrivePath pattern
supabase{user_id}/{folder?}/{public_id}.{ext} — required by RLS.
cloud (r2 · s3 · webdav)uploads/{images|videos|models|files}/YYYY/MM/{folder?}/{public_id}.{ext}

Drive-specific errors

StatusMeaning
404Drive not found — id, label, or provider did not match.
409Drive is disabled.
502Upstream drive rejected the upload (auth, bucket, network).
507Drive is full — free_bytes < file size. Pick another via GET /drives.
URL grammar

Transformations

Compose transforms in the path. Order is irrelevant, unknown keys are rejected, and every response is cached at the edge with immutable.

text
https://cdn.cloud.zeni.host/t/<transforms>/<user_id>/<public_id>.<ext>

Parameters

KeyExampleMeaning
w_w_800Width in pixels
h_h_600Height in pixels
q_q_80Quality (1–100)
f_f_webpFormat: webp · avif · jpg · png
c_c_fillFit: cover · contain · fill · inside
b_b_10Gaussian blur sigma
g_g_1Grayscale

Playground

Transform preview
Width800px
Quality80
Blur0σ
Format
https://cdn.cloud.zeni.host/t/w_800,q_80,f_webp/<user_id>/hero.jpg
How assets reach your users

Delivery & CDN

Every asset is served through one of two URL shapes. Both are designed for edge delivery — use the transform URL in your app whenever possible, even when you don't need a transform.

1. Transform URL (recommended)

text
https://cdn.cloud.zeni.host/t/<transforms>/<user_id>/<public_id>.<ext>
  • Served by the UQ Cloud edge worker + transform service.
  • Cached immutable for 1 year across 200+ Cloudflare POPs.
  • Works for every drive — the worker fetches the origin object once, transforms it, and stores the derivative. Subsequent requests hit the edge, not your drive.
  • Use /t// (no transforms) to serve the original bytes through the CDN.

2. Raw signed URL (exact original bytes)

Drive typeURL shapeEdge cache
R2 · S3 · B2Provider presigned URLR2 is edge-cached by default; S3 needs CloudFront.
SupabaseSupabase Storage signed URLCached by Supabase's edge.
Koofr · IDrive (WebDAV)/api/storage-proxy/<backendId>/<key>?exp=…&sig=…Not edge-cached by default — proxied same-origin.

Rule of thumb

In any external app, always link the /t/…/public_id.exttransform URL. The drive choice becomes an internal storage detail — end users always get sub-100 ms edge delivery, and you can migrate an asset between drives later without changing the URL your customers embed.

Example

html
<!-- Same asset, three drives, one URL shape -->
<img src="https://cdn.cloud.zeni.host/t/w_800,f_webp/USER_ID/PUBLIC_ID.jpg" />
Event delivery

Webhooks

Configure endpoints on the Webhooks page. Every delivery includes an X-UQ Cloud-Signature header — an HMAC-SHA256 of the raw JSON body, computed with your endpoint secret.

Verify a signature (Node)

js
import { createHmac, timingSafeEqual } from "crypto";

export function verify(rawBody, header, secret) {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(header || "", "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Event catalog

EventFires when
asset.uploadedAn asset is successfully ingested.
asset.deletedAn asset is removed from your library.
asset.analyzedAI analysis (tags, alt text) completes.
preset.appliedA batch preset finishes running.
Copy · paste · ship

SDKs & Snippets

React hook
tsx
export function useUQ CloudUpload(key: string) {
  return async (url: string) => {
    const r = await fetch("/api/public/uploads", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ url }),
    });
    if (!r.ok) throw new Error(await r.text());
    return r.json();
  };
}
Next.js server action
ts
"use server";
export async function ingest(url: string) {
  const r = await fetch(process.env.PF_URL + "/api/public/uploads", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PF_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ url }),
  });
  return r.json();
}
Consistent JSON

Errors

Errors return a JSON body { "error": "message" } with a semantic status code.

StatusMeaning
400Invalid input (bad URL, unsupported host).
401Missing or invalid Authorization header.
402Quota exceeded (assets or storage).
403Key lacks the required scope.
404Drive not found (see Drives section).
409Drive is disabled.
413Payload exceeds 50 MB cap.
502Upstream drive rejected the upload.
507Selected drive is full.
500Internal error — safe to retry with backoff.
Fair use

Rate limits

Uploads / min
120
Max file size
50 MB
Transform edge cache
1 year

Hitting a limit returns 429 with a Retry-After header. Contact us to lift caps.

Ready?

Generate a key and ship your first integration.

Most teams have their first asset flowing in under five minutes.

Create API key