Skip to content

Quickstart — your first generation, end to end

This is the whole lifecycle in one sitting: authenticate → discover → upload a file → submit → poll → read the output. Every step here is real and runnable; it mirrors a flow we execute against a live Madoo instance, so if you follow along with your own credentials and a workflow ID, it works.

We show each step twice — once with curl (to see the raw HTTP) and once with JavaScript/TypeScript (fetch, runnable in Node 18+ or Deno). Pick whichever you prefer.

Before you start

  • You have a client_id and client_secret (01-authentication.md).
  • You know your environment’s base URL (README §5).
  • You have the ID of a published workflow to run (a wf_… string). You can list available workflows with GET /api/v1/workflows — see 03-workflows.md.

Set up the shared variables:

Terminal window
export BASE_URL="https://testing-api.madoo.ai" # your environment
export CLIENT_ID="…"
export CLIENT_SECRET="…"
export WF="wf_53fb2f6576fa4bc9a6d3c91a7e84de47" # the workflow you want to run

Terminal window
TOKEN=$(curl -s -X POST "$BASE_URL/api/v1/auth/token" \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
echo "${TOKEN:0:12}…" # sanity check: should print a token prefix, not 'null'
const token = await getToken(BASE_URL, CLIENT_ID, CLIENT_SECRET); // helper from 01-authentication §4

Step 2 — Discover the workflow’s interface

Section titled “Step 2 — Discover the workflow’s interface”

Always read the interface before sending inputs. It tells you the exact input keys, their types, and which are required.

Terminal window
curl -s -H "Authorization: Bearer $TOKEN" "$BASE_URL/api/v1/workflows/$WF" | jq '.interface'

Example response (trimmed):

{
"inputs": [
{ "name": "brand_url", "type": "text", "required": true },
{ "name": "audience", "type": "text", "required": true },
{ "name": "tone", "type": "text", "required": true },
{ "name": "block_title", "type": "text", "required": true },
{ "name": "block_brief", "type": "text", "required": true },
{ "name": "product_image_0", "type": "image", "required": true },
{ "name": "product_image_1", "type": "image", "required": false },
{ "name": "product_image_2", "type": "image", "required": false }
],
"outputs": [
{ "name": "hero_image", "type": "image" },
{ "name": "copy_json", "type": "text" }
]
}

So this workflow needs five text inputs, at least one product image (product_image_0), and optionally more. It produces an image (hero_image) and a text blob (copy_json).


For a durable or private file, upload it first, get back a storage path, and reference that path in your inputs. This is the recommended choice when the same asset will be reused.

For a one-run image, video, audio, document, or model3d input, you may instead pass a public HTTPS URL as a JSON-encoded value. Madoo downloads it through its security policy, verifies the declared type and stores an execution-temporary copy before any downstream node runs. URLs that require credentials, cookies or custom headers are not supported.

Structured data inputs (CSV, JSON, XLSX) use an uploaded/imported asset_path, not a public URL in value. You can profile them first with POST /api/v1/structured-data/inspect. If row fan-out reaches paid nodes, pass the same asset_path to the workflow estimate endpoint: Madoo can count the selected rows before the run and return the full projected reservation instead of a deferred subtotal.

Terminal window
ASSET_PATH=$(curl -s -H "Authorization: Bearer $TOKEN" \
-F "file=@./product-hero.jpg" \
"$BASE_URL/api/v1/assets" | jq -r .path)
echo "$ASSET_PATH"
const form = new FormData();
form.append("file", new File([await Deno.readFile("./product-hero.jpg")], "product-hero.jpg", { type: "image/jpeg" }));
const uploadRes = await fetch(`${BASE_URL}/api/v1/assets`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: form,
});
const { path: assetPath } = await uploadRes.json();

More on uploads (formats, size limits, listing/deleting): 05-assets.md.


POST your inputs. Each input is keyed by its interface name and is either a value (for text/number/boolean/JSON inputs, or a public HTTPS media URL) or an asset_path (for an already stored file).

⚠️ The one rule everyone trips on: value is a JSON-encoded string

Section titled “⚠️ The one rule everyone trips on: value is a JSON-encoded string”

The value of a text/number/boolean input is itself a JSON document encoded as a string. So:

  • the text Spring in Bloom → "value": "\"Spring in Bloom\""
  • the number 42 → "value": "42"
  • the boolean true → "value": "true"
  • the media URL https://media.example/clip.mp4 → "value": "\"https://media.example/clip.mp4\""

In code you produce this by running JSON.stringify() on your value and putting the result in the value field. This is explained in full in 04-executions.md; for now, just notice the escaped quotes around text below.

Terminal window
# Build the body in a file to keep the quoting sane.
cat > body.json <<JSON
{
"workflow": "$WF",
"inputs": {
"brand_url": { "value": "\"https://www.maisonlumiere.com\"" },
"audience": { "value": "\"Design-conscious women aged 28-45\"" },
"tone": { "value": "\"Warm, sensorial, premium, concise.\"" },
"block_title": { "value": "\"Spring in Bloom\"" },
"block_brief": { "value": "\"Hero block for the spring newsletter.\"" },
"product_image_0": { "asset_path": "$ASSET_PATH" }
}
}
JSON
RUN_ID=$(curl -s -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
--data @body.json "$BASE_URL/api/v1/executions" | jq -r .id)
echo "$RUN_ID" # e.g. run_b4c8d3e2…
// In code, JSON.stringify() produces the inner JSON string for you.
const text = (v: unknown) => ({ value: JSON.stringify(v) });
const createRes = await fetch(`${BASE_URL}/api/v1/executions`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
workflow: WF,
inputs: {
brand_url: text("https://www.maisonlumiere.com"),
audience: text("Design-conscious women aged 28-45"),
tone: text("Warm, sensorial, premium, concise."),
block_title: text("Spring in Bloom"),
block_brief: text("Hero block for the spring newsletter."),
product_image_0: { asset_path: assetPath },
// product_image_1..N are optional → simply omit them
},
}),
});
const { id: runId } = await createRes.json();

You get HTTP 202 Accepted back immediately with the execution object — the generation runs in the background. Optional inputs (here product_image_1, product_image_2) can simply be omitted.


There are no inbound webhooks in v1, so you poll the execution until it reaches a terminal status: completed, partial_success, failed, or cancelled. A poll interval of 3–5 seconds is reasonable.

Terminal window
while :; do
RESP=$(curl -s -H "Authorization: Bearer $TOKEN" "$BASE_URL/api/v1/executions/$RUN_ID")
STATUS=$(echo "$RESP" | jq -r .status)
echo "status=$STATUS progress=$(echo "$RESP" | jq -r .progress)"
case "$STATUS" in completed|partial_success|failed|cancelled) break ;; esac
sleep 4
done
async function waitForExecution(baseUrl: string, token: string, runId: string, timeoutMs = 120_000) {
const terminal = ["completed", "partial_success", "failed", "cancelled"];
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const res = await fetch(`${baseUrl}/api/v1/executions/${runId}`, {
headers: { Authorization: `Bearer ${token}` },
});
const exec = await res.json();
if (terminal.includes(exec.status)) return exec;
await new Promise((r) => setTimeout(r, 4000));
}
throw new Error(`Timed out waiting for ${runId}`);
}
const execution = await waitForExecution(BASE_URL, token, runId);

A typical single-image run completes in ~15–20 seconds. The progress (0–100) and progress_message fields let you show a progress bar while you wait.


Use GET /api/v1/executions/{id}/result. It returns an indexed by_key map: look up each output by its key and read .value (text/JSON — already parsed) or .url (binary). No find(), no JSON.parse, no type === switch.

Terminal window
curl -s -H "Authorization: Bearer $TOKEN" "$BASE_URL/api/v1/executions/$RUN_ID/result" | jq '.result.by_key'
{
"hero_image": {
"key": "hero_image",
"state": "present",
"url": "https://cdn-testing.madoo.ai/…/hero.png", // binary → fetch the url
"value": null
},
"copy_json": {
"key": "copy_json",
"state": "present",
"value": { "headline": "Spring in Bloom", "body": "…", "cta": "Shop now" } // ← already an object
}
}
const result = await fetch(`${BASE_URL}/api/v1/executions/${runId}/result`, {
headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json());
const heroImageUrl = result.result.by_key.hero_image.url; // "https://…/hero.png"
const copy = result.result.by_key.copy_json.value; // { headline, body, cta } — use directly
console.log("Hero image URL:", heroImageUrl);
console.log("Copy:", copy);

Why this beats reading the raw output list yourself:

  • Indexed by key — result.by_key.hero_image, no scanning and no matching on the GUID-suffixed name.
  • Text/JSON is pre-parsed — including JSON that was produced by a text node and stored on a blob (the classic “I read value and got null” bug). value is an object when the content is JSON, a string for prose. Branch on parse.format if you need to be sure.
  • Skips are explicit — an optional branch that didn’t run comes back as state: "absent" (not a silent empty field).

You can call /result while the run is still going too — it returns 200 with is_terminal: false and each expected key in state: "not_ready". The raw, ungrouped GET /api/v1/executions/{id}/outputs endpoint still exists for advanced/debug use; full details in 04-executions.md §6.

📦 Or skip the boilerplate: the official TypeScript helper examples/madoo-runtime.ts does submit → poll → read in one call (runWorkflowAndGetResult), returning result.by_key directly.


You authenticated, discovered a contract, uploaded an asset, ran a workflow, and read structured outputs. That is the entire core loop — everything else in this guide is depth on each step: