Skip to content

Assets (file uploads)

Any workflow input of a file type — image, video, audio, document, model3d, or data — is supplied as a file, not inline. The flow is always the same: get a storage path once — by uploading the file, importing it from a URL, or via a presigned upload — then reference that path as asset_path in your execution inputs. This document covers uploading (multipart, presigned), importing from a URL, the supported formats and limits, structured-data inspection, managing assets, and composing a variable collection of assets into madoo.bundle-manifest/v1.

Assets live inside your workspace (the one your API key is bound to).

For the user-facing mental model and the equivalent procedures in the editor, MCP, Madoo AI Agent, and AI Assistant, read the canonical Bundle manifest authoring guide.


POST {BASE_URL}/api/v1/assets

This is a multipart/form-data request with a single form field named file.

Terminal window
curl -s -X POST "$BASE_URL/api/v1/assets" \
-H "Authorization: Bearer $TOKEN" \
-F "file=@./product-hero.jpg"
const form = new FormData();
// In the browser/Deno you can append a File/Blob directly:
form.append("file", fileBlob, "product-hero.jpg");
const res = await fetch(`${BASE_URL}/api/v1/assets`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` }, // do NOT set Content-Type — fetch sets the multipart boundary
body: form,
});
const asset = await res.json();

On success you get HTTP 201 Created:

{
"path": "uploads/ws-12/a3/product-hero.jpg", // ← use THIS as asset_path
"name": "product-hero.jpg",
"content_type": "image/jpeg",
"size_bytes": 248173
}

The path is what matters. Pass it straight into an execution input:

"inputs": {
"product_image_0": { "asset_path": "uploads/ws-12/a3/product-hero.jpg" }
}

If the file already lives at a public https URL (your own storage, a signed URL, a CDN), let Madoo fetch it for you instead of streaming the bytes through your request:

POST {BASE_URL}/api/v1/assets/import
Terminal window
curl -s -X POST "$BASE_URL/api/v1/assets/import" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "url": "https://cdn.example.com/product-hero.jpg" }'
Field Type Description
url string Required. Public https URL of the file. The server fetches it.
file_name string Optional. Name (with extension) to store it under and to derive the content type. When omitted, it is taken from the URL path or the response.

The response is HTTP 201 Created with the same shape as an upload (§1) — use path as asset_path:

{
"path": "uploads/ws-12/a3/product-hero.jpg",
"name": "product-hero.jpg",
"content_type": "image/jpeg",
"size_bytes": 248173
}

Why use this over multipart upload? No multipart/base64 encoding, the bytes never pass through your request body, and it comfortably handles large files. Same allowed formats and 250 MB cap as the upload endpoint (§4); requires the assets:write scope, the same as upload. For a local large file, see also the presigned upload (§3).

The server fetches the URL, hardened against SSRF: https only, public hosts, with per-redirect re-validation and connect-time IP pinning. The fetched bytes are sniffed against the resolved content type — a URL that lies about its type is rejected (content_mismatch). A URL that can’t be fetched returns 502 (fetch_failed); see the full error table in §9.


3. Presigned upload (large or local files)

Section titled “3. Presigned upload (large or local files)”

Availability. This flow is enabled per environment. Where it is off, its session endpoints return HTTP 501 (feature_disabled) — fall back to multipart upload (§1) or import-from-URL (§2), which are always available.

For a large or local file the most robust path is a presigned upload: ask the server for a short-lived, create-only upload URL, PUT the raw bytes straight to storage (they never pass through your API request or get base64-encoded), then finalize to get the durable path.

POST {BASE_URL}/api/v1/assets/uploads
Field Type Description
file_name string Required. Name with extension; the extension sets the content type (allow-list in §4).
size_bytes integer Required. File size in bytes (quota pre-check; the actual bytes are re-validated at finalize).
upload_mode string auto (recommended), single_put, or multipart. auto selects resumable multipart above the configured threshold.

Optionally send an Idempotency-Key header (8–255 characters): the same key with the same file replays the same session; reusing it with a different file is rejected (409 idempotency_conflict).

Terminal window
curl -s -X POST "$BASE_URL/api/v1/assets/uploads" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: render-2026-06-25-001" \
-d '{ "file_name": "render.mp4", "size_bytes": 52428800, "upload_mode": "auto" }'

HTTP 200 with status: "pending":

{
"status": "pending",
"upload_id": "9f8c…", // ← pass to finalize
"file_name": "render.mp4",
"content_type": "video/mp4",
"max_size_bytes": 262144000,
"strategy": "single_put",
"upload_url": "https://…/staging/…?sig=…", // ← PUT the bytes here
"upload_method": "PUT",
"required_headers": { "x-ms-blob-type": "BlockBlob" }, // send each verbatim
"url_expires_at": "2026-06-25T10:10:00Z", // PUT before this
"finalize_expires_at": "2026-06-25T11:00:00Z" // finalize before this
}

An idempotent retry can also return status: "in_progress" (a finalize is already running — just retry finalize) or status: "finalized" (already done — path + size_bytes are returned directly, skip straight to using it).

For strategy: "multipart", there is no whole-file upload_url. The response instead includes:

{
"status": "pending",
"upload_id": "9f8c…",
"strategy": "multipart",
"part_size_bytes": 33554432,
"total_parts": 7,
"checksum_algorithm": "md5",
"recommended_parallel_parts": 4,
"recommended_max_in_flight_bytes": 134217728,
"finalize_expires_at": "2026-06-28T10:00:00Z"
}

For each missing part, calculate its base64 MD5 and request a short-lived grant. The last part has the exact remaining size; all earlier parts have part_size_bytes:

POST /api/v1/assets/uploads/{upload_id}/parts:sign
Content-Type: application/json
{ "parts": [{ "part_number": 1, "size_bytes": 33554432, "checksum_base64": "…" }] }

PUT the part bytes directly to the returned upload_url, sending every required_headers entry verbatim. Keep no signed URL: ask for a fresh one when retrying. Upload up to recommended_parallel_parts, while respecting recommended_max_in_flight_bytes.

To resume, call GET /api/v1/assets/uploads/{upload_id}. Madoo reconciles provider receipts and returns each part with confirmed: true|false; resend only missing parts. The provider receipt, not a client confirmation call, is authoritative.

After all parts are confirmed, call POST /api/v1/assets/uploads/{upload_id}:complete. A 202 with status: "in_progress" means assembly succeeded and the worker is validating/publishing the asset. Poll the GET status endpoint until finalized and use its path. Completion and polling are idempotent. To discard an unfinished session, call POST /api/v1/assets/uploads/{upload_id}:abort.

PUT the raw file bytes to upload_url, sending every required_headers entry verbatim. The bytes go directly to storage, not through Madoo:

Terminal window
curl -s -X PUT "$UPLOAD_URL" \
-H "x-ms-blob-type: BlockBlob" \
--data-binary "@./render.mp4"
POST {BASE_URL}/api/v1/assets/uploads/{upload_id}/finalize
Terminal window
curl -s -X POST "$BASE_URL/api/v1/assets/uploads/9f8c…/finalize" \
-H "Authorization: Bearer $TOKEN"

The server validates the staged bytes (size, content sniff, quota) and promotes them into the asset area. HTTP 200 with status: "finalized" — use path as asset_path:

{
"status": "finalized",
"upload_id": "9f8c…",
"file_name": "render.mp4",
"content_type": "video/mp4",
"path": "uploads/ws-12/9f/render.mp4", // ← use THIS as asset_path
"size_bytes": 52428800
}

Finalize is idempotent: re-finalizing a completed upload replays the same result; one still being finalized returns status: "in_progress" (retry shortly).

Which upload should I use? Multipart (§1) is simplest for small files already in hand. Import-from-URL (§2) is best when the file already lives at a URL. Presigned upload (this section) is the most robust for large or local files — the bytes go straight to storage, out of band, and multipart retries only failed parts. All three require the assets:write scope and currently share the same formats and 250 MB cap (§4). The multipart contract is deliberately independent of this cap so it can be raised after the large-media execution path is file-backed and qualified.


Maximum file size: 250 MB. A larger file is rejected with HTTP 413 (file_too_large).

The file type is validated by extension. Allowed types:

Category Extensions
Images .jpg, .jpeg, .png, .gif, .webp, .avif, .svg, .bmp, .tiff / .tif
Documents / data .pdf, .json, .csv, .xlsx, .txt, .md
Video .mp4, .webm, .mov
Audio .mp3, .wav, .ogg, .flac, .aac, .m4a
3D .glb, .gltf, .obj, .fbx, .stl, .usdz

An unsupported extension is rejected with HTTP 400 (invalid_file_type); an empty/missing file with 400 (invalid_file).

Match the file’s extension to its real content. The extension selects the allowed canonical type; finalization then inspects the bytes and rejects a mismatch before the asset becomes usable.

SVG files are cleaned when stored. An SVG is a document that can run scripts and load content from elsewhere, so every SVG that enters Madoo — uploaded, imported, finalized, or produced by a node — is stored without what can act or reach outside the file: scripts, event handlers (onload, onclick, …), animations, embedded web content (foreignObject, iframe), and links or styles pointing outside the file. Links inside the file (#id) and embedded PNG/JPEG/GIF/WebP images stay, so the drawing looks the same. A file with the .svg extension that is not a well-formed SVG document is rejected with invalid_content (upload) or content_mismatch (import, finalize); a compressed .svgz is not accepted.

SVG stays vector in document templates and in image outputs: output/image with format original (the default) stores it as .svg. The other formats of output/image — png, jpg, webp — really convert the image, SVG or raster: the stored bytes, the extension and the content type all match, and jpg, which has no transparency, is flattened on white. Nodes that need pixels — AI nodes, image editing (resize, crop, filters, …), video overlays — receive a transparent PNG made from the SVG, 2048 px on its longest side, drawn the same way as in templates. An SVG the renderer cannot draw (an embedded <image>, more than 2,000 elements) fails those nodes with the reason, and in a template counts as a failed image in the layout report instead of leaving a silent empty box. AI models that produce SVG (vectorization) have their result stored as .svg.


Once uploaded, the path can be reused across as many executions as you like — upload a product photo once, run ten workflows against it. There is no separate “register” step; the path is the handle.

// Upload, then run.
const { path } = await (await fetch(`${BASE_URL}/api/v1/assets`, {
method: "POST", headers: { Authorization: `Bearer ${token}` }, body: form,
})).json();
await fetch(`${BASE_URL}/api/v1/executions`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
workflow: WF,
inputs: { product_image_0: { asset_path: path } },
}),
});

Pattern: pull a file from a URL you already have. If your image lives at a URL (your own storage, a signed URL, a CDN), don’t download-and-re-upload it yourself — use §2 Import from a URL and let the server fetch it directly.

5.1 Inspect a CSV, JSON or XLSX dataset before authoring

Section titled “5.1 Inspect a CSV, JSON or XLSX dataset before authoring”
POST {BASE_URL}/api/v1/structured-data/inspect

Use this authoring endpoint after upload and before configuring a data-driven workflow. It reads the workspace-owned asset through the same tenant/finalization gate used by execution, then returns the portable madoo.dataset-profile/v1 shape: exact row count, columns, probable logical types, a bounded sample and any warnings. It does not run a workflow and does not consume AI credits.

{
"asset_path": "org-token/ws-token/assets/products.csv",
"format": "auto",
"header_row": 1,
"locale": "it-IT",
"sample_rows": 10,
"max_rows": 100000,
"delimiter": ";"
}

For JSON, set table to the returned array path (for example $.products) when the document has more than one candidate array. For a root array the selected table is $. Nested objects and arrays remain JSON values: inspection never flattens them into invented columns. CSV header names are trimmed and compared case-insensitively; duplicates are rejected because a later mapping would be ambiguous.

How CSV cells become typed values:

  • Numbers follow locale, and a number is read only when it is unambiguous. With the default invariant, only plain numbers with a dot are numbers (19.90, -3, 1500). With a locale, its decimal separator and correctly placed group separators are read: it-IT reads 1.234,50 and 19,90, en-US reads 1,234.50. Anything else stays text, never a silently different number: 19,90 without a locale is the text "19,90" (not 1990), and 3.5 under it-IT is the text "3.5". Each column holding such values gets a CSV_AMBIGUOUS_NUMBER warning naming the column (path) and a sample; set the file’s locale and inspect again.
  • Values with a leading zero (001, 00501) stay text: they are codes, not numbers.
  • true / false are booleans in any letter case (TRUE, False).
  • Rows whose every cell is empty (;;;, what a spreadsheet exports for formatted empty rows) are not data: they are skipped and not counted.
  • The file is read as UTF-8 (with or without BOM). A file that is not UTF-8 is read as Windows-1252 — what Excel saves as “CSV” in Western locales — so à and € survive, and the profile carries a CSV_WINDOWS_1252 warning. Saving as “CSV UTF-8” removes the warning.

For XLSX, table is the worksheet name. It can be omitted only when there is exactly one visible worksheet; hidden worksheets are reported but selected only explicitly. formula_policy is either cached_value (default: read the value saved by Excel and emit a warning) or reject. Madoo never executes formulas, macros, external links or data connections. .xls and .xlsm are rejected.

The general upload cap remains 250 MB. XLSX inspection has a stricter 64 MB compressed-package cap and also checks entry count, expanded size, individual entries and compression ratio before Open XML reads the workbook. The endpoint requires both assets:read scope and ws:assets:read permission.

After inspection, configure an input/data node with the same options (format, table, headerRow, locale, formulaPolicy, emptyBehavior, maxRows, and optional CSV delimiter). At execution time its data input accepts the uploaded asset_path; a runtime asset overrides any authoring-time default. The node is intentionally scalar: it emits a portable madoo.dataset/v1 manifest, a madoo.dataset-profile/v1 profile, and row_count. Row fan-out is a separate operation, so loading a spreadsheet alone does not unexpectedly multiply downstream work or its credit estimate.

For row fan-out, connect input/data.dataset to enumerate/data_rows.dataset. Its rowMapping.columns array uses exact sourceColumn names from the inspection profile, stable outputPort graph handles, an outputType (text, number, boolean, json, url, or any), selected, and strict conversion. An empty cell is null on a number, boolean or url port (not a failed row); a text port accepts cells read as numbers or booleans and passes their digits unchanged (a numeric SKU such as 12345). Every selected row also emits the complete row values object, original zero-based row_index, and deterministic row_id. The row-store scope, byte length, SHA-256 digest, row indices, presence metadata, and declared row count are verified before iterations are admitted.

POST {BASE_URL}/api/v1/bundle-manifests/compose

This is the normal high-level path. Upload the files first, then provide only their returned paths and the human/business metadata that matters to the workflow:

Do not calculate storageRef, MIME, byte length, or SHA-256 in your client. The same Composer used by all Madoo authoring surfaces owns those technical fields. id, relative_path, and metadata are logical information; the canonical guide explains their exact roles and why storage references are portable opaque paths rather than public URLs.

{
"bundle_id": "property-roma-001",
"include_checksum": true,
"assets": [
{
"asset_path": "org-token/ws-token/assets/.../kitchen-01.png",
"file_name": "kitchen-01.png",
"metadata": { "role": "interior", "room": "kitchen" }
}
]
}

file_name, id, relative_path, and metadata are optional. When omitted, Madoo derives a stable logical id and path from the stored file name. It always verifies workspace ownership and reads authoritative storage metadata; with include_checksum: true (the default) it also streams the bytes once and adds SHA-256. The response is immediately usable as a saved node parameter or runtime value.

Composition is read-only: it does not upload, copy, persist, execute, or consume AI credits. The editor’s Build bundle mode and the compose_bundle_manifest MCP/Agent/Assistant tools use this same application service.

See Bundle manifest authoring — REST v1 for the complete upload → compose → validate → save/override sequence.

POST {BASE_URL}/api/v1/bundle-manifests/validate

Use a bundle when the number and roles of uploaded assets vary per execution. Upload every file with the normal asset API, then describe the collection with one madoo.bundle-manifest/v1:

{
"manifest": {
"schema": "madoo.bundle-manifest/v1",
"bundleId": "property-roma-001",
"assets": [
{
"id": "kitchen-01",
"relativePath": "photos/kitchen-01.png",
"storageRef": "org-token/ws-token/assets/kitchen-01.png",
"mediaType": "image/png",
"sizeBytes": 248120,
"sha256": "64-lowercase-hex-characters",
"metadata": { "role": "interior", "room": "kitchen" }
}
]
},
"validation": "fail",
"verify_checksum": "if_present"
}

The endpoint applies the same service as input/bundle_manifest: JSON Schema, unique IDs and logical paths, portable path rules, finalized-upload and workspace ownership checks, authoritative size/MIME, content signature, and SHA-256 according to always, if_present, or never. It consumes no AI credits. The response contains the normalized bundle, supplied_asset_count, accepted asset_count, valid, and portable issues.

The node parameter names are camelCase: validation, verifyChecksum, and the technical-only allowExternalUrls=false. In fail mode an invalid result stops execution. In warn mode rejected entries are omitted from the normalized bundle and reported with valid=false; unauthorized or temporarily unverifiable storage never becomes a warning. Pass the complete manifest as the input’s inline JSON value, not as an asset_path. Use enumerate/json on bundle.assets when downstream work must fan out over the accepted assets.


GET {BASE_URL}/api/v1/assets
Query param Type Description
limit integer 1–100, default 25.
prefix string Return only paths under this prefix.
name string Return only files whose name contains this text (case-insensitive), e.g. lamp or .pdf. total_count counts the matches.
starting_after string Pagination cursor: the path of the last item from the previous page (returned as next_cursor).
Terminal window
curl -s -H "Authorization: Bearer $TOKEN" "$BASE_URL/api/v1/assets?prefix=uploads/ws-12/"

Returns the standard paginated envelope of asset objects (path, name, content_type, size_bytes, created_at).

Listing is lightweight. To stay fast, the list does not fetch per-file metadata, so size_bytes and created_at are placeholders in list results. When you need authoritative size and content type for a specific file, rely on the values returned by the upload response (§1) or keep your own record keyed by path.


GET {BASE_URL}/api/v1/assets/download-url?path={path}

Returns a public URL to fetch the file you uploaded.

Terminal window
curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/api/v1/assets/download-url?path=uploads/ws-12/a3/product-hero.jpg"
# → { "url": "https://cdn-testing.madoo.ai/…/product-hero.jpg" }

As with output URLs, these are public and non-guessable but unauthenticated — see the privacy note in 04-executions §6.


DELETE {BASE_URL}/api/v1/assets?path={path}
Terminal window
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/api/v1/assets?path=uploads/ws-12/a3/product-hero.jpg"

Returns HTTP 204 No Content on success, 404 if the path does not exist.

Deleting an asset that a past execution used does not retroactively affect that execution’s already-generated outputs — but a future execution referencing the deleted path will fail. Clean up only assets you no longer need as inputs.


HTTP code Cause
400 invalid_file No file provided or the file is empty (upload).
400 invalid_file_type Extension not in the allowed list (§4) (upload).
400 invalid_request Missing/invalid path parameter, missing url on import, or missing file_name on presigned create.
400 invalid_url Import: the url is missing, malformed, or not allowed by the fetch policy.
400 unsupported_type Import / presigned create: the resolved type is not on the allow-list.
400 content_mismatch Import / finalize: the bytes don’t match the resolved content type.
400 invalid_content Upload: the file cannot be stored as its type — for example an .svg that is not a well-formed SVG document.
400 invalid_size Presigned create: size_bytes is non-positive or over the cap.
400 invalid_idempotency_key Presigned create: the Idempotency-Key header is malformed (not 8–255 chars, or has control characters).
402 quota_exceeded Storage quota would be exceeded.
403 forbidden The asset belongs to a different workspace.
404 not_found No asset at that path, or no presigned upload with that id for your workspace.
409 idempotency_conflict Presigned create: the Idempotency-Key was reused with a different file.
409 window_closing Presigned create: too close to the finalize deadline — start a new upload.
409 file_not_uploaded Finalize: the bytes were never PUT to the upload URL.
410 upload_expired Finalize: the upload window elapsed — start a new upload.
412 precondition_failed Finalize: the staged bytes changed since validation — re-upload and finalize.
413 file_too_large File exceeds 250 MB (upload, import, or finalize).
429 rate_limited Import / presigned create is temporarily throttled — retry shortly.
501 feature_disabled Presigned upload is not enabled for this environment — use §1 or §2.
502 fetch_failed Import: the source URL could not be fetched (HTTP error, timeout, connection).

Storage usage and quota for your workspace are reported by GET /api/v1/storage/usage and GET /api/v1/storage/quota — see 08-reference.md.

Document templates print with the built-in font families and with fonts your workspace uploads. The font catalogue is read with assets:read and changed with assets:write (MCP: list_fonts).

GET {BASE_URL}/api/v1/fonts?query=playfair&category=serif&scope=workspace

All parameters are optional: query matches family and display name, category is sans-serif, serif, monospace, display or handwriting, scope is system or workspace. The response lists every family with its familyGuid, family (the name a template’s fontFamily uses), displayName, category, scope, the current versionGuid and revisionNumber, and its files: one per variant with variant, weight, style, format, sha256, a downloadUrl and the portable reference (madoo.font-reference/v1) that pins that exact file in a template.

Upload a private font as one family, with one TTF or OTF file per variant; the regular variant (weight 400, normal style) is required:

Terminal window
curl -s -X POST "$BASE_URL/api/v1/fonts" \
-H "Authorization: Bearer $TOKEN" \
-F "displayName=Righteous" -F "category=display" -F "licenseAcknowledged=true" \
-F "files=@Righteous-Regular.ttf"

licenseAcknowledged must be true: you confirm that the licence allows the workspace to use the font, and the acknowledgement is recorded. systemFallbackFamilyGuid optionally names a built-in family to print with if the file ever becomes unavailable. A file may be up to 20 MB, a family up to 60 MB; web formats (WOFF, WOFF2), variable fonts and collections are rejected. Every file is validated (tables, glyphs, names) and scanned before it is stored. The response (201) returns the familyGuid, versionGuid, revisionNumber, family, fingerprint and fileCount. (The font endpoints use camelCase names.)

Operation Endpoint
New revision of a family (earlier revisions stay, templates that pin them keep printing them) POST /api/v1/fonts/{familyGuid}/revisions (same form as the upload)
Archive a family for new authoring (referenced revisions stay available) POST /api/v1/fonts/{familyGuid}/archive
Download one exact file GET /api/v1/fonts/{familyGuid}/versions/{versionGuid}/files/{fileGuid}
Download the file of a variant GET /api/v1/fonts/{familyGuid}/versions/{versionGuid}/variants/{variant}

Errors use the FONT_* codes — for example FONT_REGULAR_REQUIRED, FONT_LICENSE_ACKNOWLEDGEMENT_REQUIRED, FONT_VARIABLE_UNSUPPORTED, FONT_WEB_FORMAT_UNSUPPORTED, FONT_FAMILY_ALREADY_EXISTS.


Next: 06-advanced-batch.md (advanced) — running one workflow over many input sets at once.