> ## 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.

# Versioning

> How the Moda REST API is versioned, how to pin a version with the Moda-Version header, and the sunset schedule for supported versions.

The Moda REST API uses a calendar-dated version string carried on the `Moda-Version` request header. Pinning a version guarantees the wire shape you built against stays stable even when we ship a newer version.

## Supported versions

| Version      | Role                   | Status                               | Sunsets        |
| ------------ | ---------------------- | ------------------------------------ | -------------- |
| `2026-05-01` | **Canonical** (newest) | Latest response shape                | —              |
| `2026-04-12` | **Default** (legacy)   | Current default for unpinned traffic | **2026-05-12** |

* **Canonical** means routes emit this shape natively. Pin this to get the newest response fields.
* **Default** means unpinned requests (no `Moda-Version` header) resolve to this version. The default advances to the next-newest supported version once the current default sunsets.
* **Sunset** means the version stops being supported on the listed date. After that date, pinning the sunset version returns `400 unsupported_version`, and the default advances to the next-newest.

## The `Moda-Version` header

### Sending the header

Pin explicitly on every request:

```bash theme={null}
curl https://api.moda.app/v1/tasks \
  -H "Authorization: Bearer moda_live_abc123..." \
  -H "Moda-Version: 2026-05-01"
```

### Omitting the header

If you omit `Moda-Version`, the server resolves your request to the current **default** (`2026-04-12`). Unpinned integrations keep working across version bumps until a sunset advances the default. **For production, pin explicitly** so future default advancement doesn't silently change your response shapes.

### Unknown versions

Any value other than a currently-supported version returns `400 unsupported_version`:

```json theme={null}
{
  "error": {
    "type": "invalid_request",
    "code": "unsupported_version",
    "message": "Moda-Version '2026-01-01' is not supported. Supported versions: 2026-04-12, 2026-05-01.",
    "details": {
      "requested": "2026-01-01",
      "supported": ["2026-04-12", "2026-05-01"]
    },
    "request_id": "019d8996-16b3-73ee-841a-5bc5038eb972"
  }
}
```

The response's `Moda-Version` header still carries the current default, so clients can detect the drift and upgrade.

### Response header

Every response — success or error — carries the resolved `Moda-Version` back:

```
HTTP/1.1 200 OK
Moda-Version: 2026-04-12
Content-Type: application/json
...
```

If you omit the header and want to record which version the server resolved you to, read it off the response.

## What changes between versions

The `2026-05-01` canonical shape is a cleaner envelope for all task-shaped operations (`POST /v1/tasks`, `GET /v1/tasks/{id}`, `GET /v1/tasks`). The `2026-04-12` legacy shape is the original flat `JobResponse` form.

### Task endpoints

**`2026-04-12` (legacy flat shape)**

```json theme={null}
{
  "job_id": "task_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
  "status": "completed",
  "canvas_id": "cvs_77DX73QNEE9QPTBRB654CN1SBV",
  "canvas_url": "https://app.moda.com/canvas/...",
  "conversation_id": null,
  "message": "Job completed successfully.",
  "task": "Create a sales deck",
  "progress_percent": 100,
  "current_step": null,
  "error": null,
  "created_at": "2026-04-15T12:00:00",
  "started_at": "2026-04-15T12:00:02",
  "completed_at": "2026-04-15T12:01:00",
  "is_terminal": true,
  "can_export": true,
  "retry_after_seconds": null,
  "credits": { "credits_used": 5, "credits_remaining": 12 }
}
```

**`2026-05-01` (canonical Task envelope)**

```json theme={null}
{
  "id": "task_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
  "kind": "design",
  "status": "succeeded",
  "created_at": "2026-04-15T12:00:00",
  "started_at": "2026-04-15T12:00:02",
  "completed_at": "2026-04-15T12:01:00",
  "progress": null,
  "attempt": 1,
  "max_attempts": 3,
  "input": { "prompt": "Create a sales deck" },
  "result": {
    "canvas_id": "cvs_77DX73QNEE9QPTBRB654CN1SBV",
    "canvas_url": "https://app.moda.com/canvas/..."
  },
  "error": null,
  "credits": { "credits_used": 5, "credits_remaining": 12 },
  "links": {
    "self": "/v1/tasks/task_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
    "events": null,
    "cancel": null,
    "canvas": "https://app.moda.com/canvas/..."
  },
  "retry_after_ms": null
}
```

### Quick migration map

| Old (`2026-04-12`)               | New (`2026-05-01`)                                               |
| -------------------------------- | ---------------------------------------------------------------- |
| `response.job_id`                | `response.id`                                                    |
| `response.status == "completed"` | `response.status == "succeeded"`                                 |
| `response.canvas_id`             | `response.result.canvas_id`                                      |
| `response.canvas_url`            | `response.result.canvas_url`                                     |
| `response.conversation_id`       | `response.result.conversation_id`                                |
| `response.task`                  | `response.input.prompt`                                          |
| `response.progress_percent`      | `response.progress.percent`                                      |
| `response.current_step`          | `response.progress.step`                                         |
| `response.error` (string)        | `response.error.message` (object)                                |
| `response.is_terminal`           | derived: `status in ("succeeded","failed","canceled","expired")` |
| `response.can_export`            | derived: `status == "succeeded" && result.canvas_id`             |
| `response.retry_after_seconds`   | `response.retry_after_ms / 1000`                                 |

Per-operation examples are on each endpoint's reference page.

### List endpoints: offset → cursor pagination

Every list endpoint moved from offset pagination to opaque cursor pagination. Iterate until `next_cursor` is `null`:

**Before (`2026-04-12`):**

```json theme={null}
{
  "canvases": [ ... ],
  "total": 123,
  "offset": 20,
  "limit": 20,
  "has_more": true
}
```

**After (`2026-05-01`):**

```json theme={null}
{
  "data": [ ... ],
  "next_cursor": "eyJ2IjoxLCJzIjoiMjAyNi0wNC0xNVQxMjow..."
}
```

**Migration pattern:**

```python theme={null}
cursor = None
while True:
    resp = client.get("/v1/canvases", headers={"Moda-Version": "2026-05-01"}, params={"cursor": cursor}).json()
    for canvas in resp["data"]:
        process(canvas)
    cursor = resp["next_cursor"]
    if cursor is None:
        break
```

| Old (`2026-04-12`)                                            | New (`2026-05-01`)                                    |
| ------------------------------------------------------------- | ----------------------------------------------------- |
| `response.canvases` / `brand_kits` / `organizations` / `jobs` | `response.data`                                       |
| `response.total`                                              | no longer computed — iterate until `next_cursor` null |
| `response.offset` + `response.limit`                          | opaque `cursor` query param (server-managed position) |
| `response.has_more`                                           | `response.next_cursor !== null`                       |
| `?offset=20&limit=20`                                         | `?cursor=<opaque>&limit=20`                           |

Sort order changed from `updated_at DESC` (canvases) / `name ASC` (orgs) to `(created_at DESC, id DESC)` across every list endpoint — immutable columns only, which avoids row skip / duplicate bugs when rows mutate mid-scan.

`GET /v1/brand-kits` also dropped the `team_id` field from the canonical response — it's implicit in the API key context.

### Error responses

Every endpoint now explicitly declares `ErrorEnvelope` responses for `401 / 403 / 404 / 409 / 422 / 429 / 500` in the OpenAPI spec. The wire shape is unchanged — this is a spec-hardening change for SDK generators to emit typed error classes. No migration action required for direct HTTP callers.

### Webhook event taxonomy

Webhook `event.type` values moved from the legacy `job.*` prefix to `task.*` / `export.*`. Legacy spelling `task.cancelled` (two Ls) is retired — canonical uses `task.canceled` (matching the `PublicTaskStatus.CANCELED` enum value). Non-terminal states (`queued`, `running`, `expired`) no longer fire webhooks — the v1 taxonomy is terminal-only.

| Legacy event      | Canonical event                                    |
| ----------------- | -------------------------------------------------- |
| `job.completed`   | `task.succeeded`                                   |
| `job.failed`      | `task.failed`                                      |
| `job.cancelled`   | `task.canceled`                                    |
| `job.dead_letter` | `task.failed` (plus `data.error.retryable: false`) |
| `job.running`     | removed (poll `/v1/tasks/{id}` for progress)       |
| `job.queued`      | removed                                            |

See [Webhooks](/api-reference/webhooks) for the full event envelope.

## What's covered by a version bump

New versions only ship for **breaking** response-shape changes. Additive changes — new endpoints, new optional request parameters, new response fields, new error codes within an existing error type — land at the current canonical version without a bump. Your client should tolerate unknown fields in responses so these additions don't surprise it.

### Breaking changes (new version ships)

* Removing or renaming a response field
* Removing an endpoint, path, or parameter
* Changing a field's type or shape
* Tightening validation (new 400s for inputs that used to succeed)
* Changing status vocabulary (e.g. `completed` → `succeeded`)
* Changing default behavior of an existing parameter

### Additive changes (no version bump)

* New endpoint, path, or optional parameter
* New response field (existing fields unchanged)
* New optional request header
* New `code` within an existing `type` on error responses
* Loosened validation (fewer 400s)

## Version bump policy

We ship additive version bumps. When a new canonical version lands:

1. The new version becomes the **canonical** (newest) — routes emit it natively.
2. The previous default stays supported for a sunset window (typically 2–3 weeks for small shape changes; longer for substantial ones).
3. On the sunset date, the older version retires (pinning it returns 400), and the default advances to whichever supported version is next-oldest.

**Practical upshot:** if you pin explicitly, you upgrade on your schedule. If you stay unpinned, you'll track the current default and will see a shape change whenever the default advances — which we announce here with a calendar date well in advance.

## Recommendations

* **Pin** `Moda-Version` explicitly on every request in production. Don't rely on the default — the default advances on sunset dates.
* **Log the response's `Moda-Version` header** so you can tell at a glance which version your traffic is actually hitting.
* **When you see `400 unsupported_version`**, read the `supported` list out of the error `details` — that's the ground truth and updates automatically.
* **Subscribe to the changelog** to hear about new versions and sunset dates as they're announced.
