- From the admin: the Library page at app.member.dev, the quickest way to upload and organize files.
- With the
mioCLI: the whole media lifecycle from a terminal, including publishing to a hub. - With the API: the requests the admin and the CLI send, for your own integration.
From the admin
The admin is the web app at app.member.dev where creators run their hubs. Its Library page is where you upload files, sort them into folders, and build playlists. Anyone on the team can use it. If you are new to the admin, Getting started in the admin takes you from sign-up to your first hub. Two more terms:- A folder groups files for your own housekeeping. Folders can sit inside other folders. Members never see them.
- A playlist is an ordered list of files, such as the lessons of a course. You can publish a playlist to a hub, and one file can be in several playlists.
Open the Library
Select Library in the admin’s sidebar. The Upload media to your library card on the Getting started page opens it too, with Go to library. The page has two parts:- A left panel titled Library. Its Folders section starts with All files and then shows your folders as a nested tree: a folder that holds other folders has a chevron that opens and closes them. A Library section below lists two more pages, Playlists and Library settings. On a phone, open the panel with the navigation button above the files.
- The main area. Its heading says where you are: All files, or the path of the open folder. Above the files, an Upload row opens the upload dialog, and a toolbar holds a Search files… box and a grid/list switch. When a folder has subfolders, they are listed above its files.
- Grid view shows a card for each file, with a thumbnail and the title. A card shows Processing while the file is still being processed after upload.
- List view shows the same files as a table. Select the Title or Size column heading to sort by it.
- Search matches text in file titles and ignores case. It does not search transcripts. To search transcripts, use Search and transcripts.
- Sort in list view: select the Title or Size column heading. Without a sort, files stay in Default order (the order the API returns them).
- Folders: select a folder in the left panel to see only the files stored directly in it. The heading becomes a path such as All files › Courses › Module 1; select any part of it to go up a level, or use the back button. Select All files to see every file again.
/library?view=list&sort=title opens the list view sorted by title, and /library?media=<file_id> opens that file’s detail panel.
The Library is in alpha. The Library loads 20 files at a time as you scroll, and sort works only on the files loaded so far. Keep scrolling to the end to see every file in the right order. The folder tree shows the first 5,000 folders; if you have more, a note under the tree says so.
Upload files
- Select Upload in the row above the files. The Upload media dialog opens.
- Drop files onto the drop zone, or select Browse files. You can add several files at once.
- Each file gets a row with a progress bar. Up to three files upload at the same time; the rest wait as Queued. A row reads Done when its file has uploaded.
- The × button cancels an upload in progress.
- Retry starts a failed or canceled upload again. A failed row shows the reason, such as a full storage quota or too many uploads in the last hour.
- The trash button clears the row from the list. It does not delete a file that has already uploaded.
- takes its file name, extension included, as its title. Change it in the detail panel.
- lands at the top level of the Library, even when a folder is selected. Move it afterwards.
- starts as private. Select the file and choose Change visibility in the selection bar to set Private, Public, or Unlisted; see Visibility: the three-layer model.
PUT the bytes to storage, then finalize. See Upload with the API.
Organize files in folders
Create a folder: select the + button (New folder) next to Folders in the left panel. In the Create folder dialog, enter a Name of up to 255 characters and select Create. When a folder is open, the new folder goes inside it; otherwise it goes at the top level. Folders show as a nested tree, and you can also open a subfolder from the list above the files. Rename, move, or delete a folder: open the ⋯ menu (Folder actions) next to the folder in the left panel.- Rename opens Rename folder. Enter the new name and select Rename.
- Move opens Move folder. Choose the new parent folder, or No folder (root) for the top level, and select Move here. Everything inside the folder moves with it. You cannot move a folder into itself or into one of its own subfolders, so those are greyed out.
- Delete asks you to confirm, then deletes the folder. You can delete only an empty folder: while it still holds files or other folders, the admin shows an error and keeps it. Move or delete its contents first.
Select several files
In grid view, point at a card and select the check box in its top-left corner. In list view, use the check box at the start of each row. A bar appears at the bottom of the page with the number selected and these actions:- Move to folder: see Organize files in folders.
- Change visibility: set the selected files to Private, Public, or Unlisted.
- Add to playlist: see Build playlists.
Build playlists
Playlists have their own page. In the left panel, select Playlists (under Library) to open Library playlists. It has two tabs, All and Deleted. Create a playlist: select New playlist in the page header. In the New library playlist dialog, enter a Name of up to 255 characters and create it. A playlist created this way starts as private. To change that, or to add a description, open the playlist’s ⋯ menu (Playlist actions) and choose Settings. Add files to a playlist:- Select the files (see Select several files).
- Choose Add to playlist in the selection bar.
- Choose a playlist and select Add.
Added 3 files. A playlist holds each file only once. A file that is already in it is not added again, and for now the admin counts it as failed, for example Added 2 files, 1 failed.
Reorder and remove files: on Library playlists, select a playlist to open it. Its files are listed in playlist order, with the length of each video or audio file.
- Drag a file by its handle to move it. With a keyboard, focus the handle, press Space or Enter to pick the file up, use the arrow keys to move it, then press Space to drop it or Escape to cancel.
- The remove button at the end of a row takes the file out of the playlist. The file stays in the Library.
- A file that was deleted from the Library shows as Unavailable file until you remove it.
Library settings
Select Library settings in the left panel (under Library) to set how new uploads are handled. The Uploads group has Default language (“What transcripts are written in, unless a file says otherwise.”). Make your changes and select Save, or Discard to drop them.View and edit a file
Select a file in grid or list view. Its detail panel opens on the right, headed with the file’s title. At the top, the panel previews the file:- Video and audio play in the Membership.io player. While a new video or audio file is still processing, the player shows that instead.
- Images are shown in the panel.
- PDFs open in an embedded viewer.
- Other file types show Preview not available.
- Move opens the Move to folder dialog for this file.
- Replace opens Replace media. Drop in, or browse to, one new file to take this file’s place. The file keeps its ID, its folder, and its place in every playlist, and the new bytes are processed again. The admin cannot replace a file with one larger than 100 MB; use
mio media files replace, which uploads in parts (see Wait for processing, and replace a file). - Delete asks Delete file?, then deletes the file and its stored copy. This cannot be undone. A file whose upload has not finished cannot be deleted yet; the admin shows an error instead.
- Download opens the original file in a new browser tab. If your browser blocks pop-ups, it opens in the current tab instead.
The folder tree and Library pages
The Library has a Library column on the left with All files, a Folders section, Playlists, and Library settings. The + button (New folder) next to Folders creates a folder. With no folders, the section says No folders. The folder tree. Select the arrow beside a folder to show or hide its subfolders, or select the folder’s name to open it. Each row has a Folder actions menu (Rename, Move, Delete), which appears when you point at the row or focus it. Delete works as in Organize files in folders, and the dialog asks you to confirm. The tree works with the keyboard. Focus a folder, then press:
Playlists. Select Playlists to see a page titled Library playlists, with All and Deleted tabs. Each playlist is a row with its title, its description and the date it was last updated, and a visibility label. If there are many playlists, select Load more. With none, the page says No library playlists yet.
- New playlist (top right) opens New library playlist. Enter a Name and select Create playlist.
- Select a row to open it. Its files are listed in order with a drag handle and a remove button on each. Drag a file, or pick it up with the space or Enter key and move it with the arrow keys, to reorder. A playlist with no files says “No items in this playlist yet.”
- The Playlist actions menu on a row has Settings, Rename, and Delete. Settings opens Playlist settings, where you change the title, description, visibility, which hub it is Available in (Every hub or one hub), the cover image, and attached documents. Delete asks you to confirm. A deleted playlist keeps its files in the Library and can be restored.
- The Deleted tab lists deleted playlists, each with a Restore button. Restored playlists stay unpublished until you publish them to a hub.
- Use the system default uses the Membership.io default language.
- Detect automatically lets the transcription service detect the language of each file.
What the Library does not do yet
For these, use themio media command group or the API:
- publish files and playlists to a hub, or change what a hub shows its members;
- edit transcripts, chapters, or cards, or search transcripts.
The mio media command group
The mio media command group drives the media library end to end from the CLI — the full upload lifecycle included, no raw API calls required:
media files— library CRUD plus ingest.mio media files upload ./clip.mp4 --title "Intro"runs create → presigned S3 PUT → finalize in one command; files over 100 MB upload in parts automatically (see Large files: multipart). Alsoreplace,finalize,transcode,register-synthetic,durable-url, and per-filecards/chapters.media folders— CRUD plusmove.media playlists— CRUD,set-cover, andplaylists itemsto curate contents.media search— hybrid search over the team’s transcripts.media transcripts—get/vtt/content/versions, plusedit/revertfor authored transcripts.media attachments— inspect and manage media attachment rows.media hub-media/media hub-playlists— publish standalone files / playlists to a hub.
Large files: multipart
mio media files upload and mio media files replace switch to S3 multipart automatically for any file larger than 100 MB (104,857,600 bytes; the CLI’s MB is 1024 × 1024 bytes). A file of 100 MB or less goes up as one presigned PUT, so a 45 MB video or an 18 MB PDF is a single PUT with no flag, and a 101 MB file goes multipart with no flag.
--multipartforces the multipart path at any size.--part-size-mbsets the part size in MB: default 16, minimum 5 (S3’s floor for every part except the last). It is read only on the multipart path. A value under 5 on that path exits2before any request is sent.
status_upload: READY. A multipart upload runs init → presign and PUT each part → complete → finalize. A multipart replace ends at its complete call, which relinks the file itself, so there is no separate finalize.
--help lags on released binaries. Through v0.22.0, mio media files upload --help says “Single-part upload only (multipart for very large files is a follow-on)” and mio media files replace --help says “Single-part upload.” Both sentences are wrong: those versions dispatch exactly as described here, and multipart has been automatic above 100 MB since v0.8.0. From the release after v0.22.0, both helps state this threshold, rendered from the value the CLI dispatches on.
Wait for processing, and replace a file
upload --wait holds until the file finishes processing. --timeout (default 5m) caps the wait, and running out, or a FAILED status, exits 1. For a video it waits at most 30 seconds, or --timeout if that is shorter, for the transcode to start. A backend with video processing switched off never starts one, so after that window the command warns on stderr and exits 0. The warning also fires when a busy queue is slow to start, so check mio media files retrieve <file_id> before treating exit 0 as transcoded.
mio media files replace <file_id> <path> swaps in new bytes and keeps the file id. The file gets a new media_id. Until the new bytes are processed, status_transcode, status_transcribe and duration_seconds read null and the old transcript is detached. The file’s timed cards are cleared.
From v0.24.0 (the release after v0.23.0), replace takes --wait and --timeout too, and they work as they do on upload with one more condition: --wait first waits until the file points at the replacement’s media_id, so it cannot return on the old media’s READY. On v0.23.0 and earlier, replace --wait exits 2 with unknown flag: --wait; instead, poll mio media files retrieve <file_id> until media_id changes and the statuses you need read READY.
Durable image URLs for page trees
Thevariants map on a file is imgproxy-signed and expires (see Image variant URLs and expiry). For a URL you can inline into a page-builder image node, use durable-url — it prints the non-expiring durable image URL, already scoped and ready to store:
durable_variants entry with the required ?hub_id= param so it resolves for --hub, and the URL 404s until the file is published public to that hub — --publish does that inline (or run mio media hub-media publish --hub hub_abc123 --file-id file_hero --visibility public beforehand). --preset picks one variant (thumbnail-160, medium-720, large-1440, webp-medium); omit it to print them all. Durable URLs are image-only.
Curate a playlist
set-cover and items add take the file id (the media id is resolved for you). items remove and items reorder take the item id — the id returned by items list, not the file id.
Visibility: the three-layer model
Whether a member or visitor can actually open a piece of media is decided by an AND-gate across three independent layers — each layer can only narrow access, never widen it. If any layer in the path is more restrictive than the viewer, they get “Access restricted.”- The file —
media files uploadcreates files atvisibility: privateby default. Change it withmedia files update <id> --visibility public(values:public,unlisted,private). - The playlist it’s reached through (if any) — also
public/unlisted/private; defaults toprivateon create. Set it withmedia playlists update <id> --visibility public. - The hub publication — the
hub_mediarow written bymedia hub-media publish/media hub-playlists publish --visibility(valuespublic/members/private). This defaults tomembers, so a freshly published item is invisible to anonymous visitors until you republish with--visibility public.
Publish media to a hub
media hub-media publish (standalone file) and media hub-playlists publish (playlist) write the hub_media row that surfaces the asset on the hub’s /content grid and homepage content-grid.
Upload with the API
These are the requests the admin’s Upload row andmio media files upload send. Every media route is team-scoped: the team id is in the path, and the caller needs a platform access token for a member of that team or a team API key bound to it. See Authentication and API keys.
POST /files). Upload directly to S3 using that URL, then call /finalize to confirm the upload succeeded and begin processing.
For example, to upload one video in a single PUT:
POST /files returns 201. All three attributes are required: title (1 to 512 characters), mime_type, and size_bytes. The request has no folder or visibility field, so a new file starts at the top level of the library and private; change either with PATCH /files/{file_id}. The PUT URL expires after 15 minutes. POST /files returns 413 when the upload would take the team over its storage quota and 429 when the team has made too many uploads in the last hour. /finalize returns 422 when storage has no object at the expected path or the uploaded object is empty. A zero-byte upload is recorded as FAILED.
List visibility and stale uploads
The defaultGET /files list omits upload-source media whose upload status is PENDING or FAILED once its media row is more than 15 minutes old. The filter is applied before cursor pagination. This affects the list only: GET /files/{file_id} can still return the file while its non-deleted file row and media row exist.
A background cleanup checks every 15 minutes and deletes standalone, unreferenced library uploads that remain PENDING for more than 24 hours. Active multipart uploads are excluded from this cleanup; a separate seven-day sweep handles incomplete multipart uploads.
Reference: Create file · Finalize file · List files · Update file · Delete file · Download a file
If the S3 PUT fails, DELETE the file to clean up the orphaned row: an editor library upload still in PENDING can be deleted. Platform team members and team API keys may delete a READY or FAILED file, and a PENDING file only when it is an editor library upload (other PENDING files return 422). A hub owner using a contact session may delete only that PENDING case and gets 404 for anything else.
To upload in parts instead, use the multipart routes. The API sets no size threshold; the CLI’s 100 MB switch is a client-side choice. S3 itself caps a single PUT at 5 GB, so a larger file has to use these routes.
part_url and keep the ETag S3 returns. After complete, call the same /finalize.
The admin switches to these routes for files larger than 100 MB, the same threshold as the CLI, with parts of 10 MB. For a very large file it makes the parts bigger, so no upload needs more than 10,000 parts.
Reference: Multipart init · Multipart part URL · Multipart complete · Multipart abort
Registering a synthetic file (seed/admin shortcut)
require_team_owner. Creates a READY file + media row directly, without the upload → finalize → transcode pipeline — so operators, seeders, and CLI tooling can reproduce a “seeded document” shape without a real upload.
Request body (JSON:API envelope, type: "files"):
title is required. asset_kind is document or pdf (default document) — synthetic files are download-only; transcoded kinds (video/audio/image) aren’t supported since there’s no real upload behind them to transcode. collection, original_filename, mime_type, size_bytes, visibility, and description are all optional.
Pick a real The
mime_type. It defaults to application/octet-stream, which is a generic blob to every client — mime-based document branches (text-document readers, library previews) deliberately exclude it, so a placeholder registered at the default renders as an unknown file rather than as content. For a text/markdown lesson, pass mime_type: "text/markdown". Hub starter scaffolds do this automatically for their placeholder documents.The default ignores asset_kind: a pdf registered without a mime_type is stored as application/octet-stream too. mio media files register-synthetic sends a mime_type only when you pass --mime-type, so pass it with a PDF:--help example on v0.23.0 and earlier leaves --mime-type out and stores application/octet-stream; from v0.24.0 it passes application/pdf.Byte-less synthetic files have no playback URL
A synthetic file registered withsize_bytes: 0 (the default) is metadata only — no object is ever written to storage. There is nothing to sign, so surfaces that would normally return a signed URL return null instead:
- In
GET /playlist/{playlist_id}/files, a byte-less item comes back withplayback_url: nullandexpires_at: null. The item is still returned, with itstitle,description,positionandfile_idintact — only the URL is absent. Each row also carriesmime_type(the media’s raw MIME, e.g.video/mp4ortext/markdown) — use it as a synchronous kind hint when switching between items, so a player can pick a text vs. media renderer without waiting on a slower signal. - In
GET /file/{file_id}/media(and its authed siblingGET /api/teams/{team_id}/files/{file_id}/media), a byte-less file returns 200 withplayback_url: nullandexpires_at: null— not a 404.mime_type,duration_seconds,variantsandcardsare unaffected. This is the payload a player fetches, so branch onplayback_url == nullto render a reader instead of mounting a media provider. - Real uploaded files in the same playlist are unaffected and keep their signed URLs, so the suppression is per item, not per response.
description field, which is markdown-capable. Render that rather than pointing a player or a download link at the file.
Reading time for text lessons
A text lesson has no playback duration, so hub card projections estimate how long it takes to read instead.GET /api/hub/{hub_id}/playlists/{playlist_id}/item-cards— each row carriesreading_time_seconds(integer, nullable). It is populated only whenasset_kindisdocument; every other kind isnull. A document with an empty body isnulltoo, sonullmeans “no estimate”, not “a very short read”.- The estimate is 200 words per minute, rounded half-up, with a one-minute floor, serialized in seconds so it composes with
duration_secondswithout a unit switch. Words are counted on the Markdown-stripped body, so fenced code blocks and link URLs don’t inflate the number. Divide by 60 to render “n min read”. - Unlike
description,reading_time_secondsis not withheld on a locked row. It is a size signal in the same disclosure tier asduration_secondsandthumbnail, so a future-scheduled teaser can still show “12 min read” while its body staysnull.
duration_seconds on a playlist card is TOTAL time, not playback time. On GET /api/hub/{hub_id}/playlist-cards, duration_seconds is the summed playback duration of the playlist’s time-based media plus the reading time of its document items — the same per-item numbers the item-cards projection returns, so a card total always agrees with the items inside it. A documents-only playlist therefore reports its read time rather than 0. The per-item duration_seconds on item-cards is unchanged and stays playback-only. Treat the playlist total as display copy: it is not a progress denominator (member progress is completion-count based, see viewer_progress).One bound to be aware of: when computing the playlist-card total, each lesson body is read up to 50,000 characters (roughly 10,000 words, a ~50-minute read). A lesson longer than that contributes a clipped reading time to the total. This keeps an anonymous, unauthenticated card request from having to read unbounded prose. The per-item reading_time_seconds on item-cards is not clipped — it is always the exact figure for the full body, so an outlier lesson’s own card and the playlist total can disagree.s3_path field — it is always server-generated (team-namespaced), so a caller can never point a registered file at another team’s S3 key. Download/playback routes blindly sign whatever s3_path is stored, so a client-supplied path would be a cross-tenant read vector.
The route does not emit upload/transcode events and the file is not indexed into media search (nothing was actually uploaded or validated). It is not idempotent — every call mints a fresh media_id and a new row; callers that re-run must guard against duplicates themselves.
Organize files with the API
Folders and playlists help structure media libraries. These routes are the ones behind the Library’s folder and playlist actions (see From the admin):PATCH body needs its id:
visibility defaults to private. An item’s position is 0-based and defaults to 0: the new item goes in at that position, the items from there on move one place later, and positions stay 0 to N−1. A playlist holds each file once; adding a file that is already in it returns 422 (Item already in playlist). Deleting a folder that still holds files or folders returns 409; moving a folder into itself or one of its subfolders returns 422.
Reference: List folders · Create folder · Rename folder · Move folder · Delete folder · Create playlist · Update playlist · Delete playlist · List playlist items · Attach playlist item · Update playlist item · Delete playlist item
Use media in pages and content
Attachment routes connect media to page sections, hub pages, playlist covers, and other targets. The generated API reference lists each attachment route undermedia-admin-attachments.
Member uploads
Members can request upload URLs for member-owned uploads:Search and transcripts
Media search supports title, description, transcript, and semantic search when embeddings are enabled.Hub media search access control
The hub-scoped route (/api/v1/hub/{hub_id}/search/media) supports both authenticated and unauthenticated callers:
Anonymous callers may search without a JWT on public hubs. Results are limited to media with
visibility=public attached to published, public content nodes.
Unlike community search (/api/v1/hubs/{hub_id}/search/community), these 403 responses carry only a detail message — no machine-readable errors[0].code (e.g. no hub_membership_required/hub_private). Match on status code, not error code, when handling denial here.
Linking a hub search result
Each row on the hub route carries the ids a client needs to open the result. Check them in this order:
All three are nullable. A row with none of them set has no page the caller can open, so render it as not clickable.
Decoration and lesson attachments
The hub route leaves out media that only decorate something. A file is left out when every attachment it has in this hub is a decorative one: hub branding art (logo, favicon, social image, background, widget and auth images) or a lesson or playlist thumbnail. Only attachments on live targets in the searched hub count, so a file used as a playlist cover in another hub is not hidden here, and attachments on comments, messages or deleted items are ignored. A file with no attachments at all, such as one published only inside a playlist, still appears, and so does a lesson’s own media. The team route (/api/v1/teams/{team_id}/search/media) still returns everything.
Each row also carries attachment_role, the role the media plays on content_node_id:
To keep a lesson’s extra files out of a list of lessons, drop rows whose
attachment_role is attachment. The field is optional, so treat a missing value as null.
Rate limits (hub search)
- Members: 30 requests per 60 seconds, keyed by
contact_id. - Anonymous: 10 requests per 60 seconds, keyed by client IP (
X-Forwarded-Forfirst, then socket).
Pagination metadata
Themeta object on search responses includes:
title and content_node_id on media search result items are nullable. title is null when the file has no title set; content_node_id is null when the media item is not linked to a content node. Always handle null for both fields.Transcript editing (MIO-751)
Any active team member can edit a media transcript after it has been generated — this uses the same team-membership gate as the other transcript routes, not an admin-only check. Edits are non-destructive: each edit creates a new active version while archiving the previous one, forming an append-only version ledger. Only one version is active at a time.Edit flow
- Fetch word-level content via
GET .../transcript/contentto get the current editable representation. - Submit edits via
PATCH .../transcript. The backend creates a new active version, archives the prior version, then refreshes lexical search indexes and semantic embeddings automatically. - To undo an edit, call
POST .../transcript/revertwith the target version number. Revert is append-only — it creates a new version whose content matches the specified prior version; it does not delete history.
VTT captions after editing
Edited versions do not yet regenerate WebVTT captions.GET .../transcript.vtt returns 404 for any version created by an edit. Caption regeneration from edited words is a planned follow-up.
Transcription language
A per-team default transcription language controls which language the transcription provider uses. The language can be overridden per media item.media.transcription_language → team setting → server default (en).
Setting the value to auto enables provider-side language detection.
Domain events
When a transcript is edited or reverted the following events are emitted:Chapters: authored vs. auto-generated
Chapters mark timestamped sections within a media item. A file can have authored chapters (admin-entered, transcript-independent) or auto chapters (derived from the current transcript) — never both at once. Authored chapters, when present, always take precedence.GET returns the effective chapter list: authored rows if any are active, otherwise the current transcript’s auto chapters, otherwise an empty list. It never 404s for “no chapters” — an authorized file with none returns data: [].
PUT atomically replaces the full authored chapter list for the file:
startis milliseconds from the beginning of the media.- Include a chapter’s existing
idto keep updating it across calls; omitidfor a new chapter. Anidthat doesn’t belong to an active authored row on this file is a 422 (Chapter '{id}' does not belong to file '{file_id}'.), not a 404. - Up to 100 chapters per file; duplicate
ids or duplicatestartvalues in the same request are rejected. - Every
startmust be<=the media’s known duration in milliseconds, or the request is rejected with 422.
PUT with an empty chapters array ({"data": {"type": "file_chapters", "attributes": {"chapters": []}}}) to clear all authored chapters and revert the file to auto (transcript-derived) chapters.
Both endpoints respond with the same effective-chapter resource shape, including source ("authored" or "auto") so clients can tell which mode is active:
Image variant URLs and expiry
Imgproxy variant URLs are signed and time-limited. The default TTL is 24 hours. After the URL expires imgproxy rejects the request regardless of the signature. The API returns avariants_expires_at timestamp alongside the variants map, and cover_url_expires_at alongside cover_url on playlist resources, so clients know when to refresh:
expires_at describes the playback_url (CloudFront, ~300 s TTL). variants_expires_at describes the imgproxy variants map (24–48 h TTL). Use the right timestamp for each URL type.
Both playback_url and expires_at are nullable, and they are always null together — a byte-less synthetic file has no stored object to sign (see Byte-less synthetic files have no playback URL). Treat a null playback_url as “this item is not playable media” rather than as an error.
The exp:{unix_timestamp} component is sealed inside the HMAC signature. imgproxy rejects any request whose current clock time exceeds the embedded expiry.
Client guidance: do not cache imgproxy URLs (variants, cover) beyond their *_expires_at companion. Re-fetch the media or playlist resource to get a fresh set of signed URLs. This closes the “leaked URL grants permanent access” risk class — a URL that leaks after its expiry is unusable.
Avatar URLs on community and discussion/comment resources also carry matching expiry fields (photo_url_expires_at, photo_thumbnail_url_expires_at, author_photo_url_expires_at, author_photo_thumbnail_url_expires_at). Null means no image or unsigned dev mode; re-fetch the parent resource for a fresh URL.
Durable image URLs (MIO-2525)
Thevariants map above is great for displaying an image right after you fetch it, but it’s the wrong thing to persist. Every URL in variants is imgproxy-signed with a short TTL (24–48 hours, see variants_expires_at), and a fresh set is signed on every read. If you inline one of those URLs into stored content — a page tree, a rich-text block, anything that outlives the request — it rots within about two days.
For that use case, image files also expose durable_variants: a map of the same preset names to a stable, non-expiring URL of the form:
exp in the URL itself. Instead, it 302-redirects to a freshly-signed variant on every request, resolving access at request time rather than at sign time. Store or inline the durable_variants URL, not the variants URL — it’s the one that’s safe to keep around.
durable_variants is present on image files and empty ({}) for non-image files, same as variants.The public media/playlist read routes on this page (
/file/{file_id}/..., /playlist/{playlist_id}/...) are canonically served under /api/v1 (MIO-2839). The un-prefixed root paths remain live for backward compatibility but are undocumented legacy aliases hidden from the OpenAPI schema — the edge Worker in front of custom branded domains only forwards /api/v1/*, so the root form is unreachable there. Always build clients against the /api/v1 form.data.attributes.durable_variants returns all four presets: thumbnail-160, medium-720, large-1440, webp-medium.
From the CLI, mio media files durable-url <file_id> --hub <hub> [--preset <name>] [--publish] prints these URLs with ?hub_id= already appended — see Durable image URLs for page trees.
Hub scoping is required
The durable image route is hub-scoped — append?hub_id={hub_id} to the stored URL when rendering inside a hub:
?hub_id= also returns 404.
Anonymous access
Logged-out visitors can load durable images for public hub media — the route 302s straight to a signed image URL, no auth required for public content. Caching caveat: anonymous 302 responses are cacheable at the edge. If media is later made private, a previously-cached public redirect can keep serving until that CDN cache entry expires (bounded by the signed image target’s own ~24-hour expiry either way). Credentialed (member) requests are never shared-cacheable, so this staleness window only applies to anonymous traffic.HLS video playback and session expiry
READY transcoded videos and audio files are served via CloudFront signed HLS URLs. The signed custom policy grants access to every playlist and segment file under the file’s media folder for the TTL window (default: 4 hours).GET /api/v1/file/{file_id}/media returns data.meta.playback for READY videos and READY audio. kind is "video" or "audio"; branch on it rather than treating the presence of playback as meaning video. Audio has no storyboard, so its thumbnails is always [], and data.attributes.playback_url still points at the original file as a fallback. A video example:
hls_expires_at field is the exact UTC datetime when the CloudFront signed policy expires. CloudFront validates the policy on every HLS child playlist and segment request, so a long video will fail mid-playback if the policy expires before the player finishes.
thumbnails is a signed WebVTT storyboard track for hover seek-preview thumbnails, sharing the same shape as captions/chapters (language is always null since a storyboard has no spoken language). It is populated whenever the transcoder wrote a storyboard sidecar for the video, independent of transcription — an untranscribed video can still have thumbnails. It’s [] when no sidecar exists. The cue image references inside the VTT resolve relative to the VTT URL and do not inherit its signed query parameters automatically; the player must append the same Policy/Signature/Key-Pair-Id params to those image requests (the folder-wildcard policy already authorizes them).
Client guidance: refresh the playback payload (re-call GET /api/v1/file/{file_id}/media) before hls_expires_at. Caption, chapter, and thumbnail URLs share the same TTL and are refreshed alongside the manifest. The player must propagate the Policy, Signature, and Key-Pair-Id query parameters from hls_manifest to all child HLS requests (quality playlists and .ts segment files) as well as to storyboard image requests.