Hosted Sessions
AI pipeline integration guide: see /llms.txt for a condensed, LLM-friendly version of this document.
audiocutter.online accepts a hosted session — a JSON project manifest hosted at any URL, opened by visiting https://audiocutter.online/?session=<URL>. After the user edits the project in the browser, the app posts the edited manifest and rendered audio back to a callback URL declared in the manifest.
This unlocks integrations like:
- AI pipelines (HITL) — an AI generates a draft audio project, the user refines it, the pipeline gets the refined result back.
- Branded review flows — a partner site sends users to audiocutter with their logo/colors and a prefilled project, then collects the rendered audio.
- Prefilled editor handoffs — any external system that wants to deep-link into the editor with a project.
The same ProjectManifest schema also powers the local .zip save/load feature. The only difference: in a hosted session, audio file references are remote (https:// URLs or inline data: URIs) instead of relative paths inside a ZIP.
Quick start
Build a project manifest. The live demo manifest served from /demo-session.json is shown below — copy it, adjust the source / callbackUrl / branding to taste, and host it anywhere with appropriate CORS headers (see CORS requirements):
{
"version": 2,
"project": {
"volume": 0.9,
"fadeIn": {
"duration": 2000000
},
"fadeOut": {
"duration": 3000000
},
"compression": {
"enabled": true,
"threshold": -20,
"ratio": 3,
"knee": 20,
"attack": 0.005,
"release": 0.3,
"makeupGain": 3
},
"filter": {
"enabled": true,
"type": "highpass",
"frequency": 60,
"q": 0.707
},
"files": [
{
"id": "file-1",
"filename": "funky-boogie-brothers-the-little-big-beat-track.mp3",
"source": "https://cdn.audiocutter.online/sounds/funky-boogie-brothers-the-little-big-beat-track.mp3"
}
],
"fragments": [
{
"id": "demo-fragment-intro",
"name": "Intro",
"start": 0,
"fileId": "file-1",
"fileStart": 0,
"fileEnd": 6000000,
"volume": 0.8,
"fadeIn": 0,
"fadeOut": 1000000
},
{
"id": "demo-fragment-main",
"name": "Main Beat",
"start": 5000000,
"fileId": "file-1",
"fileStart": 20000000,
"fileEnd": 35000000,
"volume": 1,
"fadeIn": 1000000,
"fadeOut": 1000000
},
{
"id": "demo-fragment-break",
"name": "Break",
"start": 19000000,
"fileId": "file-1",
"fileStart": 95000000,
"fileEnd": 110000000,
"volume": 0.75,
"fadeIn": 1000000,
"fadeOut": 1000000
},
{
"id": "demo-fragment-finale",
"name": "Finale",
"start": 33000000,
"fileId": "file-1",
"fileStart": 120000000,
"fileEnd": 135000000,
"volume": 1,
"fadeIn": 1000000,
"fadeOut": 0
}
]
},
"session": {
"ui": {
"title": "Hosted session demo",
"instruction": "This is what an integration looks like. Edit the remix, then Submit to echo the payload back via httpbin.org.",
"submitLabel": "Submit (demo)",
"accentColor": "#7c3aed"
},
"output": {
"mode": "multipart",
"callbackUrl": "https://httpbin.org/post",
"format": "mp3"
}
}
}Most fields have sensible defaults (see Defaults) — the bare minimum is a file source/id and a fragment with id/fileId. Omitted fileEnd means "the whole file". version, project volume/fades, compression, filter, the fragment's remaining fields — all defaulted.
Open https://audiocutter.online/?session=/demo-session.json to load the demo as a live session — banner with the partner accent color, audio fetched from the CDN, Submit echoes to httpbin.org/post. Your own manifests work the same way: open https://audiocutter.online/?session=<URL-to-your-manifest>.
URL parameter contract
| Param | Type | Description |
|---|---|---|
session | https:// URL | Absolute URL pointing to a manifest JSON document. Must be URL-encoded if it has special characters. |
Only one parameter is recognized; everything else is reserved for future use. A request without ?session= opens the editor normally.
Manifest schema
The hosted-session manifest is the same shape as the local-ZIP project manifest. The two extension points for hosted sessions are:
- The
sessionblock (optional) carries branding and submit-back config. - Each
project.files[].sourcevalue is one of three forms (see below).
Top-level
{
version: 2, // PROJECT_FILE_VERSION
project: { ... }, // see "Project" below
session?: {
ui?: { ... }, // optional branding
output?: { ... } // omit to disable submit-back; user falls back to local download
}
}Project
{
volume: 0..1,
fadeIn: { duration: number }, // microseconds
fadeOut: { duration: number }, // microseconds
compression: { enabled, threshold, ratio, knee, attack, release, makeupGain },
filter: { enabled, type: 'lowpass'|'highpass'|'bandpass'|'notch', frequency, q },
files: Array<{
id: string,
source: string, // see "source forms" below
filename?: string // optional only when source is a data: URI; required for URL / path
}>,
fragments: Array<{
id: string,
name: string,
start: number, // microseconds, position on timeline
fileId: string, // references files[].id
fileStart: number, // microseconds, slice start within the source file
fileEnd?: number, // microseconds, slice end. Omit → whole file.
volume: 0..1,
fadeIn: number, // microseconds
fadeOut: number // microseconds
}>
}Time values are stored in microseconds. 1 sec = 1_000_000.
Defaults
Most fields can be omitted; the loader fills them in. Required fields are the bare minimum to identify and place audio:
| Required | Why |
|---|---|
project.files[].id | Fragments reference files by id. |
project.files[].source | The audio bytes — can't be defaulted. |
project.fragments[].id | Identifies the fragment. |
project.fragments[].fileId | Binds the fragment to a file. |
Everything else has a default:
| Field | Default |
|---|---|
version | 2 |
project.volume | 1 |
project.fadeIn.duration | 0 |
project.fadeOut.duration | 0 |
project.compression | { enabled: false, threshold: -24, ratio: 4, knee: 30, attack: 0.003, release: 0.25, makeupGain: 0 } |
project.filter | { enabled: false, type: 'lowpass', frequency: 350, q: 1 } |
project.files[].filename | derived from MIME (only when source is a data: URI); required otherwise |
project.fragments[].name | "" |
project.fragments[].start | 0 |
project.fragments[].fileStart | 0 |
project.fragments[].fileEnd | full file duration (resolved at load time) |
project.fragments[].volume | 1 |
project.fragments[].fadeIn | 0 |
project.fragments[].fadeOut | 0 |
source forms
source is checked against three forms in order:
| Form | Example | Resolution |
|---|---|---|
data: URI (base64) | data:audio/mpeg;base64,SUQzBAA... | Decoded inline. Self-contained. No CORS hop. ~33% size overhead — best for short clips. |
https:// URL (absolute) | https://cdn.example.com/clip.mp3 | Fetched as-is. Requires CORS-friendly headers (see below). |
| Relative path | audio/clip.mp3 or /audio/clip.mp3 | In a hosted session: resolved against the manifest URL with new URL(source, manifestUrl) — so a manifest at https://cdn.example.com/sessions/abc.json with source: "audio/clip.mp3" fetches https://cdn.example.com/sessions/audio/clip.mp3. In a local ZIP: looked up inside the ZIP. |
Only https:// is honored for absolute network URLs — http:// is rejected to avoid mixed-content and downgrade attacks. (Relative paths inherit the manifest URL's scheme — so an http://localhost manifest can serve http://localhost audio for local development, because browsers treat localhost as a secure context.)
session.ui
| Field | Type | Description |
|---|---|---|
title | string | Banner title. Defaults to "Editing in a hosted session". |
instruction | string | Small muted text under the title. |
submitLabel | string | Submit button label. Defaults to "Submit". |
accentColor | CSS color | Tints the banner background and Submit button. |
logoUrl | https:// URL | Image displayed on the left of the banner. Must be CORS-friendly. |
All fields are rendered as plain text — no HTML/Markdown. JavaScript can never be injected via these fields.
session.output
Three modes are supported. Choose mode to pick the dispatch strategy.
mode: 'multipart' (recommended for most pipelines)
One POST with the full payload as multipart/form-data.
| Field | Type | Description |
|---|---|---|
mode | "multipart" | Required. |
callbackUrl | https:// URL | Required. Receives the POST. |
format | audio extension | mp3, wav, ogg, mp4, flac, aac, aiff. Defaults to wav. |
headers | Record<string,string> | Sent on the POST. Use for Authorization. |
successRedirectUrl | https:// URL | If set, the browser navigates here after success. |
The POST body contains two parts:
manifest— a JSON Blob (the edited manifest, same schema).audio— a Blob of the rendered audio in the chosen format. Filename issession.<ext>.
mode: 'split'
Audio is uploaded first, manifest second. Use when your audio store and metadata store are separate services.
audioUploadUrl(https://URL, required) — audio uploaded here first.audioUploadMethod("POST" | "PUT") — defaults toPOST(multipart).PUTsends raw bytes withContent-Type.audioUploadField(string) — multipart field name, defaults toaudio(POST only).manifestUrl(https://URL, required) — manifest JSON POSTed here after the audio upload.format,headers,successRedirectUrl— same as multipart.
The audio upload response body is forwarded into the manifest POST as the X-Audio-Upload-Response header (stringified). Use this to correlate the two requests server-side.
mode: 'presigned'
Audio is uploaded directly to a pre-signed URL (e.g. S3 PUT), manifest sent to your service.
audioUrl(https://URL, required) — audio is PUT here withContent-Typeof the chosen format. No auth headers are added — the URL is expected to be signed.manifestUrl(https://URL, required) — manifest JSON POSTed here after the PUT.format,headers,successRedirectUrl— same as multipart.headersare applied only to the manifest POST, not the audio PUT.
CORS requirements
audiocutter.online is served with strict cross-origin headers (Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Embedder-Policy: require-corp) so it can use SharedArrayBuffer for FFmpeg-based audio conversion. As a result, every cross-origin resource the page fetches must opt in.
For each https:// resource you serve:
- The manifest JSON (
?session=<URL>) must include:Access-Control-Allow-Origin: https://audiocutter.online(or*)Cross-Origin-Resource-Policy: cross-origin
- Each audio file referenced by an
https://sourcemust include the same two headers. - Logo images referenced in
session.ui.logoUrlneed both as well.
data: URI sources bypass CORS entirely.
Callback servers
For output.callbackUrl / output.manifestUrl / output.audioUploadUrl, the browser makes a cross-origin POST/PUT. Your server must respond to CORS preflight with:
Access-Control-Allow-Origin: https://audiocutter.onlineAccess-Control-Allow-Methods: POST, PUT, OPTIONSAccess-Control-Allow-Headers: Content-Type, Authorization, X-Audio-Upload-Response, <plus any custom headers you set>
For mode: 'presigned', your signed URL provider (S3, R2, etc.) must also allow cross-origin PUTs. AWS S3 CORS config example:
[
{
"AllowedOrigins": ["https://audiocutter.online"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"],
"MaxAgeSeconds": 3000
}
]Authentication
Use session.output.headers to attach Authorization (or any other) headers to the callback requests:
"output": {
"mode": "multipart",
"callbackUrl": "https://your.api/callback",
"headers": { "Authorization": "Bearer eyJhbGciOi..." }
}Security note: the manifest is fetched by the browser and is visible to anyone with the session URL. Do not embed long-lived secrets directly. Recommended patterns:
- Issue short-lived, one-time session URLs (JWT-signed manifests, or signed S3 URLs to a manifest blob).
- Treat the session URL itself as the authentication: anyone with it can edit. Make it unguessable.
- For high-value flows, the
callbackUrlshould validate a signature from the manifest (e.g. an HMAC of the manifest contents) so an edited callback URL can't be substituted.
End-to-end recipes
Python (FastAPI) receiver — multipart mode
from fastapi import FastAPI, File, UploadFile
import json
app = FastAPI()
@app.post("/callback")
async def receive(manifest: UploadFile = File(...), audio: UploadFile = File(...)):
manifest_data = json.loads(await manifest.read())
audio_bytes = await audio.read()
# ...persist, kick off the next pipeline stage, etc.
return {"ok": True}Node (Express + multer) receiver — multipart mode
import express from 'express'
import multer from 'multer'
const upload = multer()
const app = express()
app.post('/callback', upload.fields([{ name: 'manifest' }, { name: 'audio' }]), (req, res) => {
const manifest = JSON.parse(req.files.manifest[0].buffer.toString())
const audio = req.files.audio[0].buffer
// ...persist
res.json({ ok: true })
})S3 presigned PUT example
- Pipeline backend generates a presigned PUT URL for
audio/<session-id>.mp3in your bucket. - Backend writes the manifest with
output.mode = 'presigned',audioUrl = <signed URL>,manifestUrl = https://your.api/callback. - User edits and clicks submit. Audio lands in S3, then your
/callbackreceives the manifest JSON. Correlate by inspecting the manifest'ssessionfields or by including a session id inmanifestUrl's query string.
Error handling
| Failure | User sees |
|---|---|
| Manifest URL unreachable / CORS-fail | toast.error("Failed to load session") with the underlying message. Browser CORS errors are opaque; check your headers. |
| Manifest fails schema validation | toast.error("Invalid session manifest: <field>: <message>"). Validate locally with the Zod schema before serving. |
| Audio fetch fails | toast.error(...). Same CORS rules apply to every audio URL. |
| Submit fails (callback 4xx/5xx) | toast.error(<message>) with a "Your edits are preserved" hint. The user can retry or download locally. |
The local Save Project (Cmd/Ctrl+Shift+S) entry remains available during a hosted session, so a user can always download a ZIP of their work if your callback is broken.
Versioning
PROJECT_FILE_VERSION is a single integer that bumps on breaking schema changes:
| Version | Change |
|---|---|
| 1 | Initial release. Files used zipPath. |
| 2 | zipPath renamed to source and generalized to accept data: / https:// / relative ZIP path. session block added. |
The loader accepts both v1 (with zipPath) and v2 (with source) for backwards compatibility. Always emit v2 for new integrations.
Security considerations
- No XSS via branding.
ui.title,ui.instruction,ui.submitLabelare rendered as plain text, never as HTML or Markdown. - No remote code. Manifests carry data only — never executable scripts, plugins, or eval-able strings.
- Submission destination is visible. The Submit button POSTs to the URL declared in the manifest. Users should only open session URLs from sources they trust.
http://is rejected. Network sources and logos must be HTTPS.- Data URI guidance. Manifests with multi-megabyte
data:URIs are functional but expensive to fetch. Preferhttps://URLs for anything over ~5 MB.
Future work
These items are intentionally out of scope for the initial implementation but are tracked here:
- Progress webhooks — declare a
progressUrlin the manifest; the app POSTs lifecycle events (opened,edited,submitted,cancelled) so an AI pipeline knows when a user abandons a session. - Resumability — store an edited manifest snapshot in
localStoragekeyed by session URL so a user can come back after closing the tab. - Inline manifest —
?sessionData=<base64>to skip the manifest CORS hop entirely. postMessagevariant —?session=openerrequests the manifest fromwindow.openervia postMessage.- Iframe embed mode — render without the landing/header chrome when embedded; expose a minimal postMessage API to the parent.
- Submission preview — confirmation modal summarizing what's about to be sent.
- Conflict detection — manifest declares
etag/updatedAt; the callback can return409 Conflict. - Signed/one-time manifests — pattern guidance for short-lived URLs with embedded JWT.
- Telemetry hooks — anonymized timing stats for pipeline optimization.
Questions?
Have a question or need help with the integration? and we’ll get back to you.