Moda's public REST API at https://api.moda.app/v1 is the right surface when there is no human in the loop — scheduled jobs, CI pipelines, webhook-driven workers, server-side integrations, custom dashboards. Authentication is Bearer token with a moda_live_… API key.
If a user is chatting with an AI agent (Claude Desktop, Cursor, etc.), use the moda-mcp skill instead — it handles OAuth, session context, and chat-shaped flows. The REST API and MCP can both run against the same Moda account.
Prerequisites
- Base URL:
https://api.moda.app/v1 - Auth:
Authorization: Bearer moda_live_<suffix>. Create keys in Settings → Developer → REST API. Keys are shown once; store in a secret manager. Seereferences/authentication.mdfor the 13 scopes. - Version: pin
Moda-Version: 2026-05-01on every request. Omitting it resolves to the current default (2026-04-12today, advancing on sunset dates). Seereferences/versioning.md.
Every write in this skill's examples uses Moda-Version: 2026-05-01. Every body JSON uses prefixed IDs (cvs_…, task_…, bk_…, file_…).
Before you call POST /v1/tasks — HARD GATE
Design tasks take 2–10 minutes. Getting the input right up front saves a lot of wasted compute and retry cycles. Run this pre-flight checklist before every task start:
- Format. Set
format_categoryexplicitly for anything other than slides (slides/social/carousel/pdf/diagram/ui/other). Omitting it produces a slide deck regardless of intent. Seereferences/canvases-and-exports.mdandreferences/task-envelope.mdfor allowed values and dimensions. - Brand. Fetch
GET /v1/brand-kitsand pickbrand_kit_id. The team default auto-applies when omitted. Setskip_brand_kit: trueto opt out explicitly. - References. If the task needs a source doc or style reference,
POST /v1/uploadsfirst, then passattachments: [{file_id, role}]on the task. For an existing canvas as inspiration, usereference_canvas_ids. - Idempotency. For retry-safe scheduled jobs, pass an
idempotency_keyderived from a stable key (e.g."weekly-deck:2026-W17"). Repeat calls with the same key return the same task, not a duplicate. Seereferences/idempotency.md. - Delivery strategy. Choose one up front:
- Webhook (
callback_url) — fires once on terminal state; best for cron / backend workers. API-key auth only (OAuth callers get400). - Poll
GET /v1/tasks/{id}at theretry_after_mscadence (~3s) — best when the caller is short-lived. Prefer: wait=<s>(max 30s) — best for fast operations (brand-kit creation, remix-without-prompt). Design tasks almost always exceed 30s;waitwill time out and return the non-terminal envelope. Seereferences/task-envelope.md#prefer-wait.
- Webhook (
- IDs. Every ID in a JSON body must be prefixed (
cvs_…,task_…,bk_…,file_…,conv_…). Path parameters tolerate bare UUIDs for convenience. Sending a bare UUID in a body returns400 invalid_request. Seereferences/ids.md.
The canonical flow
(1) create API key + pick scopes → Settings → Developer → REST API
(2) ensure a brand kit → GET /v1/brand-kits (create if missing)
(3) upload references (if any) → POST /v1/uploads or POST /v1/uploads/from-url
(4) start the design task → POST /v1/tasks with format_category + callback_url or Prefer: wait
(5) receive terminal state → webhook (task.succeeded / failed / canceled) OR GET /v1/tasks/{id} poll
(6) export the canvas → POST /v1/canvases/{id}/export (synchronous; returns signed URL)
Minimum viable example
TypeScript (Node 20+, fetch):
const HEADERS = {
Authorization: `Bearer ${process.env.MODA_API_KEY!}`,
"Moda-Version": "2026-05-01",
"Content-Type": "application/json",
};
// 4. start the task
const taskRes = await fetch("https://api.moda.app/v1/tasks", {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
prompt: "Create a 10-slide pitch deck for FocusTime...",
format: { category: "slides", width: 1920, height: 1080 },
number_of_slides: 10,
callback_url: "https://your-server.com/webhooks/moda",
idempotency_key: "focustime-v1",
}),
});
const task = await taskRes.json();
// { id: "task_01HT9...", kind: "design", status: "queued", retry_after_ms: 3000, ... }
// 5a. poll (alternative to webhook)
let status = task;
while (!["succeeded", "failed", "canceled", "expired"].includes(status.status)) {
await new Promise(r => setTimeout(r, status.retry_after_ms ?? 3000));
status = await fetch(`https://api.moda.app/v1/tasks/${task.id}`, { headers: HEADERS }).then(r => r.json());
}
// 6. export
if (status.status === "succeeded") {
const exportRes = await fetch(
`https://api.moda.app/v1/canvases/${status.result.canvas_id}/export?format=pptx`,
{ method: "POST", headers: HEADERS },
);
const { url } = await exportRes.json();
}
Python (3.11+, httpx):
import os, time, 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=30) as c:
# 4. start
task = c.post("/tasks", json={
"prompt": "Create a 10-slide pitch deck for FocusTime...",
"format": {"category": "slides", "width": 1920, "height": 1080},
"number_of_slides": 10,
"callback_url": "https://your-server.com/webhooks/moda",
"idempotency_key": "focustime-v1",
}).json()
# 5. poll
while task["status"] not in {"succeeded", "failed", "canceled", "expired"}:
time.sleep((task.get("retry_after_ms") or 3000) / 1000)
task = c.get(f"/tasks/{task['id']}").json()
# 6. export
if task["status"] == "succeeded":
exp = c.post(f"/canvases/{task['result']['canvas_id']}/export",
params={"format": "pptx"}).json()
Core concepts
-
13 scopes, minimum-privilege principle. Pick only what the integration needs.
tasks:writelets you start design tasks;designs:exportlets you export;brand_kits:writelets you create kits. Full list:references/authentication.md. -
Moda-Version: 2026-05-01is canonical. Pin explicitly on every request. Unpinned traffic resolves to the current default and changes shape when the default advances on sunset dates.references/versioning.md. -
Task envelope. Every task-shaped operation (design, export task, remix, brand_kit_extract) returns
{id, kind, status, result, error, progress, input, credits, links, retry_after_ms}. Branch your code onstatus(queued/running/succeeded/failed/canceled/expired).references/task-envelope.md. -
Prefixed IDs. Response
idfields always come back prefixed (cvs_01HT9…,task_01HT9…,bk_01HT9…,file_01HT9…,conv_01HT9…,evt_01HT9…,org_…,team_…). Request body fields require prefixed form — bare UUIDs get400 invalid_request. Path parameters tolerate bare UUIDs as a convenience.references/ids.md. -
Typed error envelope.
{error: {type, code, message, doc_url, request_id, details?, retry_after_ms?, causes?}}. Branch ontype, not HTTP status code. Logrequest_idon every error. Retryupstream_errorandrate_limited; fix everything else.references/errors.md. -
Cursor pagination. List endpoints return
{data: [...], next_cursor}. Iterate untilnext_cursor === null. Cursors are opaque + signed — don't hand-craft. Sort is(created_at DESC, id DESC)on immutable columns.references/pagination.md. -
idempotency_keyonPOST /v1/tasks. Retry-safe across network timeouts and worker restarts. Reusing the same key returns the existing task, not a duplicate.references/idempotency.md. -
callback_urlis API-key-auth only. OAuth callers get400 "callback_url is only supported for API-key authenticated callers". Use polling from OAuth clients. Webhooks fire terminal-only (task.succeeded/task.failed/task.canceled/export.succeeded/export.failed) — sign verification is HMAC-SHA256 over{timestamp}.{body}.references/webhooks.md.
Common tasks
| Job-to-be-done | Recipe |
|---|---|
| Weekly cron: generate a status deck, post URL to Slack | recipes/scheduled-generation.md |
| CSV of 50 prospects → 50 personalized decks | recipes/bulk-personalization.md |
| Export every team canvas as PDF to S3/Drive | recipes/export-pipeline.md |
| Receive + verify + process webhooks | recipes/webhook-receiver.md |
| Upload a brief PDF → branded deck → PPTX | recipes/brief-to-deck-pdf-intake.md |
| GitHub Action: regenerate theme tokens from a canonical canvas | recipes/design-to-code-ci.md |
Errors & retries — quick table
error.type |
Retry? | Action |
|---|---|---|
invalid_request |
No | Fix the request (missing / malformed field) |
authentication |
No | Check the API key |
permission |
No | Add the missing scope, or access a resource the key's team owns |
not_found |
No | Check the ID; confirm team access |
conflict |
No | Resolve state conflict (usually a name collision) |
idempotency_conflict |
No | Reusing an idempotency_key with a different body — fix the key or the body |
rate_limited |
Yes | Respect Retry-After (seconds) |
upstream_error |
Yes | Transient upstream failure — back off and retry |
internal_error |
Yes | Server error — retry with backoff; include request_id when reporting |
unprocessable |
No | Validation failed on well-formed input |
Full catalog + code examples: references/errors.md.
Further reading
docs.moda.app/api— overviewdocs.moda.app/api/authentication— keys + scopesdocs.moda.app/api/versioning— Moda-Version header, migration mapdocs.moda.app/api/webhooks— webhook payload + signingdocs.moda.app/api/tasks/startTask— full per-field referencedocs.moda.app/llms.txt/docs.moda.app/llms-full.txt— plain-text for LLMs