Documentation Index
Fetch the complete documentation index at: https://docs.moda.app/llms.txt
Use this file to discover all available pages before exploring further.
Bulk personalization
Problem: You have a CSV of 50 prospects. Produce one personalized follow-up deck per prospect, using an uploaded brief as the content source and the team’s default brand kit. Collect the canvas URLs when they’re done.
Primitives
POST /v1/uploads — upload the shared brief (once)
POST /v1/tasks — fan out one task per prospect with idempotency_key
callback_url webhook — receive terminal state per task (preferred) OR polling pool (fallback)
TypeScript (Node 20+, fetch)
import fs from "node:fs";
import Papa from "papaparse";
const HEADERS = {
Authorization: `Bearer ${process.env.MODA_API_KEY!}`,
"Moda-Version": "2026-05-01",
"Content-Type": "application/json",
};
const CALLBACK_URL = "https://myapp.com/webhooks/moda";
// 1. upload the brief once
const briefForm = new FormData();
briefForm.set("file", new Blob([fs.readFileSync("brief.pdf")]), "brief.pdf");
const brief = await fetch("https://api.moda.app/v1/uploads", {
method: "POST",
headers: { Authorization: HEADERS.Authorization, "Moda-Version": HEADERS["Moda-Version"] },
body: briefForm,
}).then(r => r.json());
// brief.id = "file_01HT9..."
// 2. fan out — one task per prospect
const csv = Papa.parse(fs.readFileSync("prospects.csv", "utf8"), { header: true });
const kits = await fetch("https://api.moda.app/v1/brand-kits", { headers: HEADERS }).then(r => r.json());
const defaultKit = kits.data.find((k: any) => k.is_default);
const results: { prospect: string; task_id: string }[] = [];
for (const row of csv.data as any[]) {
const res = await fetch("https://api.moda.app/v1/tasks", {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
prompt: `Personalized follow-up deck for ${row.company}.
Prospect: ${row.contact_name}, ${row.contact_role}.
Their focus area: ${row.focus_area}.
Use the attached brief as the source of truth for our product claims.`,
format: { category: "slides", width: 1920, height: 1080 },
number_of_slides: 8,
brand_kit_id: defaultKit?.id,
attachments: [
{ file_id: brief.id, role: "source", label: "Master brief" },
],
callback_url: CALLBACK_URL,
idempotency_key: `prospect-deck:${row.id}`, // stable per prospect
}),
});
if (res.status === 429) {
// rate limited — respect Retry-After and retry this prospect
const waitSec = Number(res.headers.get("Retry-After") ?? 10);
await new Promise(r => setTimeout(r, waitSec * 1000));
// simpler: push back onto the queue; full rate-limit handling left as an exercise
}
const task = await res.json();
results.push({ prospect: row.company, task_id: task.id });
}
fs.writeFileSync("tasks.json", JSON.stringify(results, null, 2));
console.log(`Queued ${results.length} tasks. Webhook will deliver results.`);
Webhook handler (same shape as webhook-receiver.md) looks up event.data.id in tasks.json, matches it to a prospect, writes the canvas URL to a CRM / database / Slack.
Python (httpx)
import csv, os, httpx
HEADERS = {
"Authorization": f"Bearer {os.environ['MODA_API_KEY']}",
"Moda-Version": "2026-05-01",
}
with httpx.Client(base_url="https://api.moda.app/v1", headers=HEADERS, timeout=60) as c:
# 1. upload shared brief
with open("brief.pdf", "rb") as f:
brief = c.post(
"/uploads",
files={"file": ("brief.pdf", f, "application/pdf")},
).json()
# 2. find default brand kit
kits = c.get("/brand-kits").json()
default_kit_id = next(
(k["id"] for k in kits["data"] if k["is_default"]),
None,
)
# 3. fan out
results = []
with open("prospects.csv") as f:
for row in csv.DictReader(f):
resp = c.post("/tasks", json={
"prompt": (
f"Personalized follow-up deck for {row['company']}.\n"
f"Prospect: {row['contact_name']}, {row['contact_role']}.\n"
f"Their focus area: {row['focus_area']}.\n"
"Use the attached brief as the source of truth for our product claims."
),
"format": {"category": "slides", "width": 1920, "height": 1080},
"number_of_slides": 8,
"brand_kit_id": default_kit_id,
"attachments": [
{"file_id": brief["id"], "role": "source", "label": "Master brief"},
],
"callback_url": "https://myapp.com/webhooks/moda",
"idempotency_key": f"prospect-deck:{row['id']}",
})
if resp.status_code == 429:
# respect Retry-After then re-queue this prospect (omitted for brevity)
continue
task = resp.json()
results.append({"prospect": row["company"], "task_id": task["id"]})
# store task_id → prospect mapping for the webhook handler to look up
with open("tasks.json", "w") as f:
import json
json.dump(results, f, indent=2)
Polling fallback (no webhook server)
If you can’t run a webhook receiver, poll all 50 tasks in a concurrency-capped pool instead:
import asyncio, time
async def poll(c: httpx.AsyncClient, task_id: str):
while True:
t = (await c.get(f"/tasks/{task_id}")).json()
if t["status"] in {"succeeded", "failed", "canceled", "expired"}:
return t
await asyncio.sleep((t.get("retry_after_ms") or 3000) / 1000)
async def main(task_ids: list[str]):
sem = asyncio.Semaphore(8) # cap parallelism
async def guarded(tid):
async with sem:
return await poll(c, tid)
async with httpx.AsyncClient(base_url="https://api.moda.app/v1", headers=HEADERS) as c:
return await asyncio.gather(*(guarded(t) for t in task_ids))
Gotchas
idempotency_key per prospect + run. "prospect-deck:{id}" works if each prospect only gets one deck per logical run. If you re-run monthly, include the month: "prospect-deck:{id}:2026-04".
- Rate limits are real. If you fan out 50 tasks in a tight loop, you may hit a per-key or per-team cap. Back off on
429 using Retry-After. Serialize if recurring.
brand_kit_id is the team default by default (so you can technically omit it). Passing it explicitly makes the code’s intent clear and future-proofs it if the team adds more kits.
- Don’t poll AND have a webhook. Pick one. Doubling up wastes your rate budget and may trigger two Slack pings.
- On task failure, you need a way to surface which prospect failed. The
idempotency_key or the mapping file is your link.
See also