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.

Errors

Every error response carries a single typed envelope under an error key. Branch on type, not HTTP status.

Shape

{
  "error": {
    "type": "not_found",
    "code": "file_not_found",
    "message": "Canvas cvs_abc123 not found",
    "doc_url": "https://docs.moda.app/errors/file_not_found",
    "request_id": "019d8996-16b3-73ee-841a-5bc5038eb972",
    "details": null,
    "retry_after_ms": null,
    "causes": null
  }
}

Fields

FieldTypeNotes
typestringStable high-level category. Branch your retry logic on this.
codestringNarrow machine-readable identifier. Stable once published.
messagestringFor developers. Not localized, not user-facing. Don’t parse.
doc_urlstringPermalink to the doc page for this code.
request_idstringCorrelator. Also echoed in the X-Request-ID response header.
detailsobject / nullCode-specific. Example for validation_failed: {"fields": [{"field": "body.canvas_id", "code": "string_too_short"}]}
retry_after_msnumber / nullHint for transient / rate-limited errors
causesarray / nullNested envelopes for aggregated failures (e.g. multi-page export with per-page errors)

Types (branch on these)

TypeHTTPRetryable?Action
invalid_request400NoFix the request shape / values
authentication401NoRenew / check the API key
permission403NoMissing scope, or resource belongs to another team
not_found404NoBad ID, or the key’s team doesn’t see it
conflict409NoName collision, resource state conflict
idempotency_conflict409NoSame idempotency_key, different body
unprocessable422NoWell-formed request but validation failed
rate_limited429YesRespect Retry-After header (seconds) or retry_after_ms
upstream_error502 / 503 / 504YesTransient — back off and retry
internal_error500YesServer bug — retry with backoff; include request_id when reporting

Selected codes worth knowing

CodeTypeTypical trigger
unsupported_versioninvalid_requestUnknown Moda-Version header. details.supported lists valid versions.
validation_failedunprocessableBody field failed a validation rule. details.fields[] names the offenders.
share_link_revokednot_foundShare link exists but is disabled.
share_link_not_foundnot_foundNo share with that token.
scraping_user_errorunprocessableBrand-kit URL scrape failed (blocked by robots, 404, etc.).
canvas_active_jobconflictExport attempted on a canvas with an in-flight design task. 409 + Retry-After: 10. See canvases-and-exports.md.

Retry rules

Error typeRetry rule
rate_limitedSleep Retry-After seconds (or retry_after_ms); retry. Exponential backoff on repeats.
upstream_errorSleep 1s, 5s, 30s on attempts 1, 2, 3. Give up after 3.
internal_errorSame as upstream_error.
Everything elseDon’t retry.
Never silently retry invalid_request / authentication / permission / not_found / conflict / idempotency_conflict / unprocessable — they’re deterministic.

Rate limiting

  • Surfaced as 429 rate_limited with Retry-After header (seconds).
  • Applied per API key and per endpoint (e.g. exports are individually rate-capped).
  • Slow down, don’t hammer.
  • The RateLimit-* response headers standardized in RFC 9240 are not shipped yet. Don’t read them. Use Retry-After and the error’s retry_after_ms.

Logging & support

  • Always log request_id on every error. When you email support@moda.app, include it.
  • Log type and code; don’t log message as the signal (it can change without notice).
  • Redact the API key from any log line.

Error handling template (TypeScript)

async function modaFetch(path: string, init?: RequestInit) {
  const res = await fetch(`https://api.moda.app${path}`, {
    ...init,
    headers: { ...HEADERS, ...(init?.headers ?? {}) },
  });
  if (res.ok) return res.json();

  const body = await res.json().catch(() => null);
  const err = body?.error;
  if (!err) throw new Error(`Moda ${res.status} (no envelope)`);

  if (err.type === "rate_limited" || err.type === "upstream_error" || err.type === "internal_error") {
    const ra = Number(res.headers.get("Retry-After") ?? 0) * 1000 || err.retry_after_ms || 3000;
    await new Promise(r => setTimeout(r, ra));
    return modaFetch(path, init); // caller should cap recursion
  }

  throw new Error(
    `Moda ${err.type}/${err.code}: ${err.message} (request_id=${err.request_id})`,
  );
}

Error handling template (Python)

import time, httpx

def moda_fetch(client: httpx.Client, method: str, path: str, *, attempt: int = 1, **kw):
    r = client.request(method, path, **kw)
    if r.is_success:
        return r.json()

    body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
    err = body.get("error") or {}

    retryable = {"rate_limited", "upstream_error", "internal_error"}
    if err.get("type") in retryable and attempt <= 3:
        ra = int(r.headers.get("Retry-After", 0)) or ((err.get("retry_after_ms") or 3000) // 1000) or 3
        time.sleep(ra)
        return moda_fetch(client, method, path, attempt=attempt + 1, **kw)

    raise RuntimeError(
        f"Moda {err.get('type')}/{err.get('code')}: {err.get('message')} "
        f"(request_id={err.get('request_id')})"
    )

Common wrong guesses

  • Branching on HTTP status only. Status codes collapse types (both rate_limited and idempotency_conflict can be 409-adjacent in practice). Branch on type.
  • Parsing message. Can change without notice. Use type and code for logic; message for display only.
  • Retrying not_found / permission. Deterministic.
  • Ignoring Retry-After on 429. You’ll be back to rate-limited in seconds.
  • Expecting RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset response headers. Not shipped yet. Use Retry-After and retry_after_ms.
  • Not including request_id in support requests. Without it, logs are hard to find.

Upstream