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

# Common Workflows

> Use the embed together with the Moda Public API for canvas selection, export, chat, uploads, and lifecycle management.

The embed is the editing surface. Your backend should still use the Moda Public API for account-level and workflow-level operations.

## Let users choose a canvas

Use the Public API to list or search canvases, then create an embed session for the selected canvas.

A canvas in either response is a `CanvasItem`. The title field is **`name`** (not `title`), and **neither endpoint returns a thumbnail** — render the picker from `name`/`category`/`updated_at`, or open the canvas to get a preview.

```ts title="CanvasItem" theme={null}
type CanvasItem = {
  id: string;            // prefixed `cvs_...`
  name: string;          // display name — the title field
  url: string;           // opens the canvas in the Moda editor
  category: string | null;   // 'slides' | 'social' | 'pdf' | 'diagram' | ... | null
  visibility: string | null; // 'team' | 'private'
  created_at: string | null; // ISO 8601
  updated_at: string | null;
  created_by: { id: string; name: string; email: string } | null;
};
```

The two endpoints use **different envelope keys** — list is cursor-paginated under `data`, search is a flat list under `canvases`:

```ts title="List canvases — { data, next_cursor }" theme={null}
async function listCanvases(cursor?: string) {
  const url = new URL('https://api.moda.app/v1/canvases');
  if (cursor) url.searchParams.set('cursor', cursor);
  url.searchParams.set('limit', '50'); // 1–100, defaults to 20

  const response = await fetch(url, {
    headers: {
      Authorization: `Bearer ${process.env.MODA_API_KEY}`,
      'Moda-Version': '2026-05-01',
    },
  });

  if (!response.ok) throw new Error('Failed to list canvases');
  return response.json() as Promise<{ data: CanvasItem[]; next_cursor: string | null }>;
}
```

```ts title="Search canvases — { canvases }" theme={null}
async function searchCanvases(query: string) {
  const url = new URL('https://api.moda.app/v1/canvases/search');
  url.searchParams.set('q', query);
  url.searchParams.set('limit', '20');

  const response = await fetch(url, {
    headers: {
      Authorization: `Bearer ${process.env.MODA_API_KEY}`,
      'Moda-Version': '2026-05-01',
    },
  });

  if (!response.ok) throw new Error('Failed to search canvases');
  return response.json() as Promise<{ canvases: CanvasItem[] }>; // note: `canvases`, not `data`; no cursor
}
```

Page through the full list with `next_cursor` until it comes back `null`:

```ts theme={null}
let cursor: string | undefined;
const all: CanvasItem[] = [];
do {
  const { data, next_cursor } = await listCanvases(cursor);
  all.push(...data);
  cursor = next_cursor ?? undefined;
} while (cursor);
```

## Build a canvas picker

Recommended flow:

1. User opens your "Choose design" screen.
2. Your backend calls `GET /v1/canvases` or `GET /v1/canvases/search`.
3. Your frontend shows the result list.
4. User selects a canvas.
5. Your backend creates an embed session for that canvas.
6. Your frontend loads the returned `embed_url`.

Do not create embed sessions for every canvas in the list. Create one only when the user opens a canvas.

## Restrict canvases in an internal tool

Your app should remain the policy decision point. Moda can pin an embed session to one canvas, one mode, and one set of allowed browser origins, but your backend should decide which internal users may see or edit each canvas before creating the session.

Recommended pattern:

1. Store the mapping between your internal users, teams, projects, or roles and the Moda canvas IDs they may access.
2. When a user opens your picker, call the Moda Public API from your backend and filter the results against that mapping before returning them to the browser.
3. When the browser asks to open a canvas, do not trust the submitted `canvas_id`. Re-check the current internal user against your access mapping on the backend.
4. Create the embed session only after that check passes.
5. Set `mode` from your app's authorization result. For example, reviewers get `view-no-export` or `view`; editors get `edit`.
6. Set `external_user.id` to your stable internal user ID for attribution and audit trails.
7. Revoke active sessions when the user signs out, changes role, or loses access to the underlying project.

```ts title="Backend authorization sketch" theme={null}
async function openCanvasForUser(userId: string, requestedCanvasId: string) {
  const grant = await db.canvasAccess.findFirst({
    where: { userId, modaCanvasId: requestedCanvasId },
  });

  if (!grant) {
    throw new Response('Not found', { status: 404 });
  }

  const mode = grant.canEdit ? 'edit' : grant.canExport ? 'view' : 'view-no-export';

  return createModaEmbedSession({
    canvas_id: requestedCanvasId,
    mode,
    external_user: {
      id: userId,
      email: grant.email,
      name: grant.displayName,
    },
    allowed_origins: ['https://internal.example.com'],
    ui: {
      toolbar: grant.canEdit ? 'secondary' : 'hidden',
      chat: grant.canUseAgent ? 'external' : 'hidden',
    },
    expires_in_seconds: 1800,
  });
}
```

For larger workspaces, avoid relying only on client-side filtering. Keep a server-side allowlist, project membership table, or policy check that runs both when listing canvases and when minting the embed session.

## Save and autosave

Edit-mode embeds autosave after user edits. You can still call manual save before an important transition:

```ts theme={null}
client.post('save');
```

Listen for `canvas:saved` before closing a modal or advancing your workflow:

```ts theme={null}
client.on('canvas:saved', (payload: any) => {
  markStepComplete(payload.version);
});
```

## Use your own chat UI

Create sessions with:

```json theme={null}
{
  "ui": {
    "chat": "external",
    "toolbar": "secondary"
  }
}
```

Then send messages from your UI:

```ts theme={null}
client.post('chat.send', { message: userMessage });
```

Render agent output from events:

```ts theme={null}
client.on('chat:message', (payload: any) => {
  appendChatBubble(payload.sender, payload.text);
});

client.on('chat:progress', (payload: any) => {
  appendStreamingProgress(payload);
});

client.on('chat:canvas_update', () => {
  showToast('Canvas updated');
});
```

Use `chat.stop` to cancel the active agent turn.

## Insert user-uploaded images

If the user picks an image in your app, send the `File` to the iframe. The iframe uploads it through the embed image endpoint and inserts a real Moda image node.

Embed uploads currently accept PNG, JPEG, GIF, and WebP files up to 25 MB. If you need SVG or larger asset handling, upload through your own workflow and pass a browser-renderable HTTPS URL to `insertImage`.

```ts theme={null}
const file = input.files?.[0];
if (file) {
  client.post('insertImage', {
    file,
    filename: file.name,
    mimeType: file.type,
    width: 500,
  });
}
```

The user can also paste images directly into the iframe. The embed handles screenshots and image files from the clipboard.

## Insert server-generated images

If your backend generates an image, prefer one of these:

| Option                                                | When to use                                                              |
| ----------------------------------------------------- | ------------------------------------------------------------------------ |
| Pass a temporary HTTPS URL to `insertImage`           | The URL is reachable by the browser and stable long enough for rendering |
| Download the image in your frontend and pass a `Blob` | You want the embed upload endpoint to persist the asset with the canvas  |
| Use the Public API upload flow first                  | You need a broader server-side asset workflow outside the embed          |

Example with a URL:

```ts theme={null}
client.post('insertImage', {
  url: generatedImageUrl,
  x: 100,
  y: 100,
  width: 600,
});
```

## Export from the iframe

Use iframe export when the user needs the file immediately in the browser.

```ts theme={null}
client.post('export', { format: 'png', page: 1 });
```

`export:completed` includes a `dataUrl` — the whole file inline as base64, with no size cap. This is fine for a single image, but a multi-page PDF/PPTX can be many megabytes in memory and is capped by a \~120s completion timeout.

Use Public API export when your backend needs a durable export URL, background polling, caching, a server-side workflow, **or whenever the document is large or multi-page**:

```http theme={null}
POST /v1/canvases/{canvas_id}/export?format=pdf&wait=true
Authorization: Bearer moda_live_...
Moda-Version: 2026-05-01
```

## Refresh long-running sessions

Sessions can last up to 3600 seconds. For long editing sessions:

1. Track `expires_at` from session creation.
2. Set a timer to refresh from your backend before expiry (for example, 60s ahead).
3. Send the new token to the iframe with `session.refresh`; the iframe replies `session:refreshed { expiresAt }`.

```ts theme={null}
function scheduleRefresh(expiresAt: string) {
  const refreshAheadMs = 60_000;
  const delay = Math.max(0, new Date(expiresAt).getTime() - Date.now() - refreshAheadMs);

  return setTimeout(async () => {
    const refreshed = await fetch('/api/moda-embed/refresh', {
      method: 'POST',
      body: JSON.stringify({ sessionId }),
    }).then((response) => response.json());

    client.post('session.refresh', {
      token: new URL(refreshed.embed_url).searchParams.get('token'),
      expiresAt: refreshed.expires_at,
      browserToken: refreshed.browser_token,
      browserTokenExpiresAt: refreshed.browser_token_expires_at,
    });

    scheduleRefresh(refreshed.expires_at); // chain the next refresh off the new expiry
  }, delay);
}

client.on('session:refreshed', ({ expiresAt }: { expiresAt: string }) => {
  console.log('Session refreshed until', expiresAt);
});
```

<Tip>
  Instead of computing your own timer, you can drive the refresh off the iframe's proactive
  `session:refresh_requested` event, which fires ahead of expiry (by default 60 seconds
  before the browser token lapses):

  ```ts theme={null}
  client.on('session:refresh_requested', async () => {
    const refreshed = await fetch('/api/moda-embed/refresh', {
      method: 'POST',
      body: JSON.stringify({ sessionId }),
    }).then((response) => response.json());

    client.post('session.refresh', {
      token: new URL(refreshed.embed_url).searchParams.get('token'),
      expiresAt: refreshed.expires_at,
      browserToken: refreshed.browser_token,
      browserTokenExpiresAt: refreshed.browser_token_expires_at,
    });
  });

  // If a refresh never lands, react to expiry:
  client.on('session:expired', () => promptReauthentication());
  ```
</Tip>

## Revoke on logout or close

Call revoke when access should end:

```ts theme={null}
await fetch('/api/moda-embed/revoke', {
  method: 'POST',
  body: JSON.stringify({ sessionId }),
});
```

Your backend should call:

```http theme={null}
DELETE /v1/embed/sessions/{session_id}
Authorization: Bearer moda_live_...
Moda-Version: 2026-05-01
```

## Should you build an API wrapper?

Most teams do best with two small clients:

| Client            | Location | Purpose                                 |
| ----------------- | -------- | --------------------------------------- |
| `ModaEmbedClient` | Browser  | Iframe commands and events only         |
| `ModaApiClient`   | Backend  | Thin wrapper around the Moda Public API |

Avoid a browser SDK that exposes general Moda API calls. It would either leak your API key or require your backend to proxy every possible operation.

A backend wrapper is useful if your app uses several Public API endpoints:

```ts theme={null}
class ModaApiClient {
  constructor(private apiKey: string) {}

  private request(path: string, init: RequestInit = {}) {
    return fetch(`https://api.moda.app/v1${path}`, {
      ...init,
      headers: {
        Authorization: `Bearer ${this.apiKey}`,
        'Moda-Version': '2026-05-01',
        'Content-Type': 'application/json',
        ...init.headers,
      },
    });
  }

  listCanvases() {
    return this.request('/canvases');
  }

  createEmbedSession(body: unknown) {
    return this.request('/embed/sessions', {
      method: 'POST',
      body: JSON.stringify(body),
    });
  }
}
```

Keep the wrapper thin so you can call new Public API endpoints without waiting for a browser SDK release.

## Related Public API docs

* [API Overview](/api-reference)
* [Authentication](/api-reference/authentication)
* [Usage Limits](/api-reference/usage-limits)
* [Large File Uploads](/api-reference/large-file-uploads)
