Skip to main content

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.

Export pipeline

Problem: Archive every canvas on the team as a PDF. Upload each to S3 / Google Drive. Skip canvases that have an in-flight design task.

Primitives

  • GET /v1/canvases — cursor-paginate all canvases
  • POST /v1/canvases/{id}/export?format=pdfsynchronous; returns a signed URL
  • Handle 409 canvas_active_job — back off and retry
  • Upload the file bytes to your storage of choice

TypeScript (Node 20+)

import fs from "node:fs";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

const HEADERS = {
  Authorization: `Bearer ${process.env.MODA_API_KEY!}`,
  "Moda-Version": "2026-05-01",
};
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.EXPORT_BUCKET!;

async function* listAllCanvases() {
  let cursor: string | null = null;
  for (;;) {
    const u = new URL("https://api.moda.app/v1/canvases");
    u.searchParams.set("limit", "100");
    if (cursor) u.searchParams.set("cursor", cursor);
    const { data, next_cursor } = await fetch(u, { headers: HEADERS }).then(r => r.json());
    for (const c of data) yield c;
    if (!next_cursor) return;
    cursor = next_cursor;
  }
}

async function exportWithRetry(canvasId: string, retries = 3): Promise<string | null> {
  for (let attempt = 1; attempt <= retries; attempt++) {
    const res = await fetch(
      `https://api.moda.app/v1/canvases/${canvasId}/export?format=pdf`,
      { method: "POST", headers: HEADERS },
    );

    if (res.ok) {
      const { url } = await res.json();
      return url;
    }

    if (res.status === 409) {                                    // canvas_active_job
      const wait = Number(res.headers.get("Retry-After") ?? 10) * 1000;
      console.log(`Canvas ${canvasId}: task running; waiting ${wait / 1000}s`);
      await new Promise(r => setTimeout(r, wait));
      continue;
    }

    if (res.status === 429) {                                    // rate limit
      const wait = Number(res.headers.get("Retry-After") ?? 10) * 1000;
      await new Promise(r => setTimeout(r, wait));
      continue;
    }

    const body = await res.json().catch(() => null);
    console.error(`Canvas ${canvasId}: export failed`, body?.error);
    return null;
  }
  return null;
}

for await (const canvas of listAllCanvases()) {
  const exportUrl = await exportWithRetry(canvas.id);
  if (!exportUrl) continue;

  const bytes = Buffer.from(await (await fetch(exportUrl)).arrayBuffer());
  await s3.send(new PutObjectCommand({
    Bucket: BUCKET,
    Key: `canvases/${canvas.id}.pdf`,
    Body: bytes,
    ContentType: "application/pdf",
    Metadata: { "canvas-name": canvas.name, "updated-at": canvas.updated_at },
  }));
  console.log(`Archived ${canvas.name} (${canvas.id})`);
}

Python (httpx)

import os, time, httpx, boto3

HEADERS = {
    "Authorization": f"Bearer {os.environ['MODA_API_KEY']}",
    "Moda-Version": "2026-05-01",
}
s3 = boto3.client("s3")
BUCKET = os.environ["EXPORT_BUCKET"]

def iter_canvases(c):
    cursor = None
    while True:
        params = {"limit": 100}
        if cursor:
            params["cursor"] = cursor
        resp = c.get("/canvases", params=params).json()
        yield from resp["data"]
        cursor = resp["next_cursor"]
        if cursor is None:
            return

def export_with_retry(c, canvas_id, attempts=3):
    for _ in range(attempts):
        r = c.post(f"/canvases/{canvas_id}/export", params={"format": "pdf"})
        if r.is_success:
            return r.json()["url"]
        if r.status_code in (409, 429):
            wait = int(r.headers.get("Retry-After", 10))
            time.sleep(wait)
            continue
        print(f"export failed for {canvas_id}: {r.json().get('error')}")
        return None
    return None

with httpx.Client(base_url="https://api.moda.app/v1", headers=HEADERS, timeout=120) as c:
    for canvas in iter_canvases(c):
        url = export_with_retry(c, canvas["id"])
        if not url:
            continue
        # download and push to S3
        content = httpx.get(url, timeout=120).content
        s3.put_object(
            Bucket=BUCKET,
            Key=f"canvases/{canvas['id']}.pdf",
            Body=content,
            ContentType="application/pdf",
            Metadata={"canvas-name": canvas["name"], "updated-at": canvas["updated_at"]},
        )
        print(f"Archived {canvas['name']} ({canvas['id']})")

Gotchas

  • Export is synchronous. No task polling. One POST returns the signed URL (or a retryable 409).
  • Signed URLs expire after 7 days. Download and re-upload immediately — don’t persist the signed URL itself.
  • 409 canvas_active_job is the retry signal when a design task is running on the canvas. Respect Retry-After (default 10s).
  • 429 rate_limited applies per-endpoint — designs_export has its own cap. Respect Retry-After.
  • Cursor pagination is sequential. Can’t parallelize page fetches. Parallelize the per-canvas work within a page instead (cap to ~4–8 concurrent exports).
  • Scope requirements: canvases:read for listing, designs:export for the export endpoint. Team membership required — a share-token-only caller can’t export.
  • Don’t export the same canvas twice in a row. If you re-run this job often, compare updated_at against your archive and skip unchanged canvases.
If you only want canvases matching a pattern:
resp = c.get("/canvases/search", params={"query": "Q2 client decks", "limit": 100}).json()
/canvases/search is also cursor-paginated.

See also