Uploads
Uploads use a three-step flow: your backend mints a short-lived permit, the client uploads with it to your account's CDN host, and the CDN records the upload to Oblien (async, durable node→API recording — retried until acknowledged). Your backend then reads the file from the Oblien API. See How it works for the mental model.
your backend ── POST /cdn/token ─────────────▶ Oblien API (quota gate; mints a one-shot permit for a namespace)
client ── POST https://<host>/api Bearer <permit> + file ──▶ CDN edge
◀── { url, variants, recorded } ──
CDN edge ── records (async, retried) ─────────▶ Oblien API (authoritative record)
your backend ── GET /cdn/files ───────────────▶ Oblien API (sees the upload)1. Mint a permit
Your backend calls POST /cdn/token with its API key. The permit is a
short-lived (≈1 min), single-use JWT — reusing it returns
401 permit_already_used, so mint one per upload operation.
const { token } = await oblien.cdn.token({
namespace: 'customer-123', // API-side grouping: quota + sort (optional)
tag: 'avatar', // classification string — fetch by it later (optional)
variants: { // API-issued processing directives (optional)
variantNames: ['thumb', 'card'],
customVariants: { card: { width: 600, height: 400, fit: 'cover' } },
},
metadata: { orderId: 'A-1001' }, // OPAQUE — stored + echoed, never interpreted
// cdnHost: 'cdn.yourbrand.com', // optional verified custom delivery domain
});POST /cdn/token
X-Client-ID: <id>
X-Client-Secret: <secret>
Content-Type: application/json
{
"namespace": "customer-123",
"variants": {
"variantNames": ["thumb", "card"],
"customVariants": { "card": { "width": 600, "height": 400, "fit": "cover" } }
},
"metadata": { "orderId": "A-1001" }
}curl -X POST https://api.oblien.com/cdn/token \
-H "X-Client-ID: $OBLIEN_CLIENT_ID" \
-H "X-Client-Secret: $OBLIEN_CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{"namespace":"customer-123","variants":{"variantNames":["thumb"]}}'The permit carries your delivery host — your account's
<slug>.cdn.oblien.com — the namespace tag (for quota/sort, recorded as
cdn_files.namespace), and your variants directives. The host defaults to
your account host; pass cdnHost only to use a
custom domain you've verified (also account-level).
Any other host is rejected with 400 invalid_cdn_host. Namespace is not the
host — all your namespaces serve from the one account host.
An optional tag is a plain classification string (≤64 chars, stored
verbatim) recorded on every file uploaded with this permit as
cdn_files.tag, so you can later fetch all files of a kind — e.g. every
avatar — optionally scoped to a namespace (see step 3).
It's a first-class, indexed field, distinct from the opaque metadata: use
tag to classify ("what kind of file"), metadata for everything else.
Mint on your backend with your API key, then hand only the permit to the
client. Never ship X-Client-Secret to a browser.
Token scopes & permissions
POST /cdn/token (oblien.cdn.token()) mints a user permit — permissions
upload + process — the default for client uploads. For backend management at
the edge (read / list / info / delete), mint an admin permit with
POST /cdn/token/admin (oblien.cdn.adminToken()), which requires an admin API
key. Both are ~1-minute and single-use; a permit missing a required permission
is rejected with 403 forbidden.
| Scope | Minted by | Permissions |
|---|---|---|
user | any admin- or namespace-scoped key | upload, process |
admin | admin key only | upload, process, read, list, info, delete |
2. Upload
The client uploads to your account host (<slug>.cdn.oblien.com, from the mint).
The field is file (single) or files (batch), authorized with the permit.
// Server-side one-shot (mints + uploads): oblien.cdn.upload(file, { namespace })
const out = await oblien.cdn.upload(
{ data: bytes, filename: 'photo.jpg', contentType: 'image/jpeg' },
{ namespace: 'customer-123' },
);
// Or upload from the client with a permit your backend minted:
const form = new FormData();
form.append('file', file);
const res = await fetch(`https://${cdnHost}/api`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form,
});
const out2 = await res.json();POST https://<host>/api
Authorization: Bearer <permit>
Content-Type: multipart/form-data (field: "file")Batch: POST https://<host>/api/multiple (field files), or ingest by URL:
POST https://<host>/api/process-urls with { "urls": ["https://…"], "maxBatch": 10, "concurrency": 3 }.
curl -X POST "https://$CDN_HOST/api" \
-H "Authorization: Bearer $TOKEN" \
-F "file=@./photo.jpg"
# batch
curl -X POST "https://$CDN_HOST/api/multiple" \
-H "Authorization: Bearer $TOKEN" \
-F "files=@./a.jpg" -F "files=@./b.jpg"Response:
{
"success": true,
"file_id": "…",
"url": "https://<slug>.cdn.oblien.com/static/ab/cd/file.jpg",
"filename": "file.jpg",
"size": 123456,
"mime": "image/jpeg",
"tag": "avatar",
"variants": [{ "variant": "card", "url": "…", "size": 20480, "width": 600, "height": 400 }],
"expires_at": 1730000000000,
"recorded": true
}recorded: true means Oblien has registered the file and all its variants
before returning this response (server-to-server) — so the file_id is
immediately fetchable from the API (see step 3). On the rare
API hiccup it comes back false: the file is stored and registration is queued
for retry, so it will appear shortly. expires_at appears only when a
TTL is set.
Variants
Variants are chosen per request via the token's dedicated variants object —
there are no server-side presets, and the default is none: an upload with
no variants stores just the original. Fields (all optional):
variantNames— which variants to produce (omit → none).customVariants—{ name: { width?, height?, fit?, quality?, blur?, format?, withoutEnlargement? } }. A spec with no width/height is a same-size re-encode (justquality/format/blur).keepOriginal— keep the uploaded original (defaulttrue; can't discard all copies).primary— which produced variant is the top-levelurl(defaultfull).optimizeOriginal—{ maxBytes, quality, format }to re-encode an original larger thanmaxBytes.ttl/expireAt— auto-delete the upload (see Auto-delete).maxBytes— lower the per-file size cap for this upload.
Save only a compressed copy (no original, no resize):
variants: {
keepOriginal: false,
variantNames: ['c'],
customVariants: { c: { quality: 60, format: 'webp' } }, // same size, recompressed
primary: 'c',
}
// → add e.g. thumb:{width:150,height:150} + blur:{width:75,height:75,blur:5} for compressed + thumb + blurvariants is validated at mint; malformed input is rejected with a specific
error code — e.g. variant_name_invalid, variant_width_invalid,
variant_quality_invalid, variant_spec_empty, variants_too_many,
custom_variant_key_invalid, variant_ttl_invalid. metadata is separate and
opaque: stored with the file and echoed back, never interpreted for
processing.
Discover limits & schema
The edge exposes two read-only helpers to introspect what's allowed:
GET https://<host>/api/limits(oblien.cdn.getLimits()) — max file size per type + max files per request.GET https://<host>/api/variants(oblien.cdn.getVariantOptions()) — the custom-variant field schema (width,height,fit,quality,blur,format).
/api/multiple (batch) and /api/process-urls (ingest by URL) share the
token's variants and return a files[] array, each entry with its own
file_id.
3. See the upload
The CDN registers every upload to Oblien server-to-server, so your backend doesn't need to trust the client's response. Two ways to update your own DB:
Fetch by file_id — the cleanest for the direct-client-upload flow. Your
client relays the file_id it got back to your backend, which fetches the
authoritative record (GET /cdn/files/<file_id> accepts the file_id). Because
recorded: true means it's already registered, this succeeds immediately:
// Your backend, given the file_id the client relayed:
const { data } = await oblien.cdn.get(fileId);
// data → { id, node_file_id, filename, cdn_url, size, total_bytes, mime_type,
// namespace, tag, variants:[{variant,url,size,width,height}],
// metadata, created_at } → store in your DBGET /cdn/files/<file_id>
X-Client-ID: <id>
X-Client-Secret: <secret>curl "https://api.oblien.com/cdn/files/$FILE_ID" \
-H "X-Client-ID: $OBLIEN_CLIENT_ID" -H "X-Client-Secret: $OBLIEN_CLIENT_SECRET"Or list — reconcile files (filter/sort/paginate), optionally by tag:
const { data } = await oblien.cdn.list({ namespace: 'customer-123' });
const avatars = await oblien.cdn.list({ tag: 'avatar' }); // all files tagged 'avatar'
const scoped = await oblien.cdn.list({ namespace: 'customer-123', tag: 'avatar' });
const { data: t } = await oblien.cdn.tags({ namespace: 'customer-123' }); // → { tags: [...] }Fetches are scoped to your account, so you only ever read your own files.
Anything you put in the permit's opaque metadata is stored with the file and
returned here, so you can correlate an upload to your own records.
Prefer not to route the id through the client at all? An outbound webhook
(Oblien → your backend on register) is on the roadmap — until then, the
fetch-by-file_id pull above is the authoritative path.