---
title: "Coda to Slack Canvas Migration: API Limits, Triage & What Breaks"
slug: coda-to-slack-canvas-migration-api-limits-triage-what-breaks
date: 2026-09-28
author: Abdul Aleem
categories: [Coda, Slack Canvas, Migration Guide]
excerpt: "Coda to Slack Canvas is the hardest migration fit. Canvas has no formulas, no automation, and tables cap at 300 cells. Here's what transfers, what breaks, and when to pick a different target."
tldr: Most Coda docs are formula-driven apps. Slack Canvas has zero computation and a 300-cell table limit. Migrate only narrative docs — application docs need a different destination.
canonical: https://clonepartner.com/blog/coda-to-slack-canvas-migration-api-limits-triage-what-breaks
---

# Coda to Slack Canvas Migration: API Limits, Triage & What Breaks


## Coda to Slack Canvas Migration: API Limits, Triage & What Breaks

**Coda to Slack Canvas is the worst-fit migration in the document platform space.** Coda is a formula-driven application builder — docs are constructed from typed columns, named formulas, buttons, automations, and cross-doc sync. Slack Canvas is a lightweight document surface with no computation engine, no cell formatting in tables, and a hard ceiling of 300 cells per table. Most of what makes a Coda doc valuable cannot exist in a canvas at all.

This guide covers what actually transfers, what gets silently destroyed, the API extraction surface on both sides, required token scopes, and the triage process you need before touching a single doc. The honest answer for many teams: a formula-heavy Coda workspace should not move to Slack Canvas.

## Why is Coda to Slack Canvas the hardest migration fit?

Coda and Slack Canvas occupy opposite ends of the document spectrum. **Coda is a computation platform disguised as a document editor.** A typical Coda doc contains relational tables with typed columns (text, number, date, person, lookup, formula), buttons that execute `ModifyRows()` calls, automations triggered by row changes, and cross-doc sync tables that pull live data from other docs. The value of most Coda docs is in the logic, not the prose.

**Slack Canvas is a formatted document surface inside Slack.** It supports headings, text, checklists, callouts, images, embedded files, and simple markdown tables. There is no formula engine, no column types, no automation layer, and no relational model. Slack's own guidance positions canvases as places for project plans, meeting notes, onboarding docs, and resource hubs — very different from Coda's model. ([api.slack.com](https://api.slack.com/surfaces/canvases))

The [architectural gap](https://clonepartner.com/blog/blog/slack-canvas-vs-coda-architecture-limits-and-migration):

| Capability | Coda | Slack Canvas |
|---|---|---|
| Typed columns (number, date, person, lookup) | ✅ | ❌ |
| Formulas / calculated columns | ✅ | ❌ |
| Buttons / actions | ✅ | ❌ |
| Automations (row-change triggers) | ✅ | ❌ |
| Cross-doc sync | ✅ | ❌ |
| Conditional formatting | ✅ | ❌ |
| Table cell formatting | ✅ | ❌ |
| Tables > 300 cells | ✅ (unlimited on paid plans) | ❌ (hard cap) |
| Rich text pages | ✅ | ✅ |
| Checklists | ✅ | ✅ |
| Embedded images | ✅ | ✅ |

This is not a fidelity-loss migration — it is a category change. You are moving from an application platform to a note-taking surface.

## What does the 300-cell table limit mean in practice?

**A Slack Canvas table [caps at 300 total cells](https://clonepartner.com/blog/blog/quip-spreadsheets-to-slack-canvas-tables-the-300-cell-ceiling), in any combination of rows and columns.** This is the most severe constraint in the migration. In Coda, a table is a database view that can hold thousands of records. In Slack Canvas, a table is a simple markdown-style grid meant for quick visual alignment. There is no underlying spreadsheet engine.

Here is how common Coda table sizes map:

| Coda Table Size | Total Cells | Fits in Canvas? |
|---|---|---|
| 20 rows × 10 columns | 200 | ✅ |
| 25 rows × 12 columns | 300 | ✅ (at the limit) |
| 40 rows × 8 columns | 320 | ❌ |
| 50 rows × 6 columns | 300 | ✅ (at the limit) |
| 100 rows × 5 columns | 500 | ❌ |

Canvas tables carry no formulas and no cell-level formatting. Bold or colored cells in Coda tables arrive as plain text. Computed columns (e.g., a "Total" column using a `Sum()` formula) export their last-calculated values, but the formula itself is gone — permanently.

For tables that exceed 300 cells, you have three options — none of them great:

1. **Truncate**: Keep only the most relevant rows or columns. Lossy, but fits the canvas model.
2. **Split**: Break into multiple canvas tables with labels like "*Rows 1–30*" and "*Rows 31–60*". This destroys any ability to sort or filter across the full dataset.
3. **Link out**: Put the full dataset in Google Sheets, Airtable, or a CSV attachment and link from the canvas. This [split-destination approach](https://clonepartner.com/blog/blog/where-quip-spreadsheets-go-when-slack-canvas-cant-hold-them) is usually the right answer for data-heavy tables.

> [!WARNING]
> **Table splitting is lossy.** If you split a Coda table across multiple canvas tables to stay under 300 cells, you lose the ability to sort, filter, or reference across the full dataset. Canvas tables are static grids with no relational awareness.

## What transfers and what gets destroyed?

| Coda Feature | Transfers to Canvas? | What Happens |
|---|---|---|
| Rich-text page content | ✅ | HTML/Markdown converts well. Some formatting may simplify. |
| Headings, lists, checklists | ✅ | Direct mapping. |
| Embedded images | ⚠️ Partial | Image URLs from HTML export must be re-hosted; Coda URLs expire within 48–72 hours based on CDN TTL. |
| Small tables (≤300 cells) | ✅ | Static snapshot only. No formulas, no types. |
| Large tables (>300 cells) | ❌ | Must be split, truncated, or linked out. |
| Formula columns | ❌ | Last computed value transfers as static text. Formula logic is lost. |
| Pack-synced columns (Jira, Salesforce, GitHub) | ❌ | API returns the last-synced cell value as a string or null if sync is stale. Pack configuration and connection details are not accessible via the API. |
| Lookup / relation columns | ❌ | Exports as flat comma-separated text. Relational links are broken. |
| Buttons / controls | ❌ | Not exportable via API. No canvas equivalent. |
| Automations | ❌ | Not exportable via API. Must be rebuilt in Slack Workflow Builder (limited). |
| Cross-doc sync tables | ❌ | Configuration not accessible through the API. Live connections are severed. |
| Conditional formatting | ❌ | No canvas equivalent. |
| Scale/slider columns | ❌ | No canvas equivalent. Exports as a number. |
| Named formulas and controls | ❌ | Current values can be extracted, but interactivity is lost. |
| Page-level file attachments | ⚠️ Partial | Omitted from Markdown export; available as `img` tags in HTML export. URLs expire within 48–72 hours — re-host immediately. |
| Version history | ❌ | Not accessible via the standard Coda API. |
| Pack configurations | ❌ | Connection details for external integrations are not exportable. |

> [!NOTE]
> **Volatile formulas are an edge case.** Coda's API docs warn that formulas such as `Today()`, `Now()`, and `User()` can behave differently through the API than in a live doc. If you freeze outputs at migration time, record when and as which user you extracted them. ([coda.io](https://coda.io/developers/apis/v1))

### What does the API actually return for Pack-driven columns?

This is one of the most common sources of confusion during migration. When you call `GET /docs/{docId}/tables/{tableId}/rows` on a table that contains Pack-synced columns (e.g., a Jira issue status column or a Salesforce account field):

- **If the Pack last synced successfully**: The API returns the most recently computed cell value as a plain string or number, depending on the column type. There is no indication in the row payload that the value came from a Pack.
- **If the Pack sync is stale or the connection is broken**: The API may return `null` or the last cached value with no error flag. You cannot distinguish a stale sync from a current one from the row data alone.
- **Pack configuration itself**: The sync schedule, source table mapping, and OAuth credentials are not accessible through any API endpoint. These must be manually documented before migration.

**Practical consequence**: During a Coda extraction, treat every Pack-synced column as a static snapshot with an unknown staleness timestamp. Flag these columns in your migration audit and note the last-known sync time from the Coda UI before shutting down the doc.

## The triage step you cannot skip

**Before migrating anything, audit every Coda doc and classify it as either narrative or application.** This is the single most important step. Skipping it guarantees wasted effort and data loss.

### Narrative docs

A **narrative doc** is primarily prose — meeting notes, project briefs, onboarding guides, SOPs, runbooks, incident write-ups, and team hubs. It may contain a small reference table, but the doc's value is in the text. These are valid canvas candidates and map well to Slack's documented Canvas use cases.

### Application docs

An **application doc** is built around computation — tables with formula columns, buttons that trigger row modifications, automations that fire on schedule or row change, cross-doc syncs pulling live data. The doc is a tool, not a document. These should **not** go to Slack Canvas.

Signals that a doc is an application:

- **More than 3 formula columns** in any table
- **Any `ModifyRows()`, `AddRow()`, or `DeleteRows()` button** — imperative actions with no canvas equivalent
- **Any automation rule** (time-based or event-based triggers)
- **Cross-doc sync tables** or **Pack sync tables** (Jira, Salesforce, GitHub, etc.)
- **Controls** (sliders, date pickers) that filter table views or change what formulas return
- **Conditional formatting rules** that drive decision-making
- **Tables exceeding 300 cells** that cannot be meaningfully truncated
- **Lookup or relation columns** referencing other tables
- **More automation runs per month than page views** — the doc is a tool, not a reference
- **More formula columns than data columns** in the primary table

> [!CAUTION]
> **If more than 30% of a workspace's docs are application docs, Slack Canvas is the wrong migration target for that workspace.** Consider [Notion](https://clonepartner.com/blog/blog/coda-to-notion-migration), [Confluence](https://clonepartner.com/blog/blog/coda-to-confluence-migration-methods-api-limits-data-mapping), or [SharePoint](https://clonepartner.com/blog/blog/coda-to-sharepoint-migration-methods-api-limits-data-mapping) instead — platforms that support databases, formulas, or structured data.

For docs that fall in between — some narrative content with a few tables — extract the prose into a canvas and move the table data to a Slack List or a connected spreadsheet (Google Sheets, Airtable) linked from the canvas.

### Handling Coda subpage hierarchies

Coda supports nested page hierarchies of arbitrary depth. Slack Canvas has no native folder or parent-child page concept. Before migration, apply this decision tree by depth:

- **1 level deep (top-level pages with subpages)**: Create one canvas per top-level page. Create sibling canvases for each subpage. Create an index canvas for the channel that links to all sibling canvases.
- **2 levels deep**: Create an index canvas per top-level folder. Flatten all descendants into sibling canvases under that index. Use a consistent naming prefix (e.g., `"Onboarding > Week 1 > Day 1"`) to preserve hierarchy in canvas titles.
- **3+ levels deep**: Flatten entirely. Deep nesting in Coda is almost always an indicator of application structure (cross-linked tables, sub-docs acting as data stores). Re-evaluate whether this branch of the doc tree is actually a narrative candidate.

The index canvas structure — a primary canvas per channel containing hyperlinks to all sub-canvases — is the canonical way to represent hierarchy in Slack Canvas. Build this index last, after all sub-canvases have been created and their Slack Canvas URLs are known.

## Required API token scopes

**Failing to configure correct token scopes is the most common cause of silent migration failures.** Both the Coda extraction and Slack ingestion sides require specific permissions.

### Coda API token scopes

Coda uses a single API token model with granular doc-level permissions. The token must have:

- **Read access to all target docs**: Tokens are scoped per doc or per workspace. For workspace-wide migration, create the token under a workspace admin account with access to all docs.
- **No write scopes required** for extraction — extraction is read-only.

Coda tokens do not use OAuth scopes in the Slack/Google sense — the token inherits the permissions of the creating user. If the creating user cannot see a doc, the token cannot read it. For shared docs owned by other users, you need either a workspace admin token or explicit share access.

### Slack bot token scopes (OAuth)

For `canvases.create` and `canvases.edit`, the Slack bot token requires:

| Scope | Purpose |
|---|---|
| `canvases:write` | Create and edit canvases |
| `canvases:read` | Read canvas content via `canvases.getContent` |
| `channels:read` | Resolve channel IDs for channel-attached canvases |
| `users:read` | Resolve Coda user references to Slack user IDs for `<@UXXXXXXXX>` mentions |
| `files:write` | Re-host images as Slack file uploads (if using Slack as the image host) |

Install the Slack app to your workspace with these scopes before running the migration pipeline. Missing `canvases:write` causes `canvases.create` to return a `missing_scope` error with no content written — easy to misread as a rate limit failure.

## Coda API extraction surface

**Coda's REST API (v1) provides page content export, table schema reads, and row data reads, but there is no bulk workspace export endpoint.** Every doc must be processed individually. Enterprise admins have an Admin API doc export endpoint, but it still does not recreate formulas, buttons, automations, or sync behavior.

### Page content export

Page bodies are extracted via an asynchronous two-step process:

1. `POST /docs/{docId}/pages/{pageIdOrName}/export` with `{"outputFormat": "html"}` or `{"outputFormat": "markdown"}` — this queues the export and returns a request ID.
2. `GET /docs/{docId}/pages/{pageIdOrName}/export/{requestId}` — poll until `status` is `"complete"`, then download via the `downloadLink`.

The async design exists because Coda pages can be large. Small pages may return content immediately; large pages require polling.

```bash
curl -X POST 'https://coda.io/apis/v1/docs/DOC_ID/pages/PAGE_ID/export' \
  -H 'Authorization: Bearer CODA_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"outputFormat":"markdown"}'
```

> [!NOTE]
> **Do not confuse `content` with `export`.** `GET /docs/{docId}/pages/{pageId}/content` returns paginated content elements (plain text items). `POST /docs/{docId}/pages/{pageId}/export` returns the full page body as Markdown or HTML. Use `export` for migration. Markdown export omits page-level file attachments entirely — use the HTML export to get image URLs via `<img>` `src` attributes, then re-host the files before the URLs expire (within 48–72 hours of export based on Coda's CDN TTL).

### Table and row extraction

Table schemas (columns, types) are read via `GET /docs/{docId}/tables/{tableId}/columns`. This returns column names, types, and formula definitions — but the schema endpoint is read-only. You can see that a column is calculated and inspect its formula, but the formula is metadata you can archive, not something you can replay in Canvas.

Row data is read via `GET /docs/{docId}/tables/{tableId}/rows`. Rows paginate at a maximum of **500 per request**. For a table with 2,000 rows, that means 4 sequential requests chained by `pageToken` cursors. The safe implementation pattern is to code to `nextPageToken`, not a fixed page size — Coda's documentation notes that page sizes may vary by endpoint.

### Rate limits

Coda's API rate limits are per-user, scoped by operation category:

| Operation | Rate Limit |
|---|---|
| Reading data | 100 requests per 6 seconds |
| Writing data (POST/PUT/PATCH) | 10 requests per 6 seconds |
| Writing doc content | 5 requests per 10 seconds |
| Listing docs | 4 requests per 6 seconds |

If you are working from older material that references "100 requests per 10 seconds," update your throttling — the current documented limit is 100 per 6 seconds. ([coda.io](https://coda.io/developers/apis/v1))

For a workspace with 200 docs averaging 3 pages and 2 tables each, you need roughly 200 doc reads + 600 page exports + 400 table schema reads + 400+ row reads = **1,600+ requests minimum**. At the read rate limit, that is about 96 seconds of pure request time — but async page export polling adds significant wall-clock time.

### What the API cannot give you

- **Automation configurations**: Rules, triggers, schedules — none exposed through the API.
- **Button definitions**: The `ModifyRows()` or `RunActions()` logic inside buttons is not accessible.
- **Cross-doc sync configurations**: Which docs are synced, which tables, which columns — not available.
- **Pack configurations**: Connection details for external integrations (Jira, Salesforce, etc.) are not exportable. The API returns the last-synced cell values only.
- **Conditional formatting rules**: Not exposed through any endpoint.
- **Version history**: Previous document versions are not accessible via the standard API.
- **Pack sync staleness**: There is no API field indicating when a Pack column was last successfully refreshed.

Docs larger than 125 MB may lose API access entirely.

## Slack Canvas API: write surface and constraints

**Slack's Canvas API accepts markdown-formatted content and creates or edits canvases programmatically.** Slack's documented import/export tooling does not include a native Coda-to-Canvas importer — this migration is API-led. ([slack.com](https://slack.com/help/articles/204897248-Guide-to-Slack-import-and-export-tools))

### Permissions model

Canvas visibility and edit permissions are controlled via `canvases.setAccess` (or at creation time by attaching to a channel). The access model has three levels:

- **Channel-attached canvases**: Inherit the channel's membership. Anyone who can see the channel can view the canvas; edit access is configurable separately.
- **Standalone canvases**: Access must be granted explicitly via `canvases.setAccess` with a `user_ids` or `channel_ids` array and an `access_level` of `"read"` or `"write"`.
- **DM canvases**: Scoped to the DM participants automatically.

During migration, if the source Coda doc had restricted access (visible only to specific users or groups), you must replicate that access model via `canvases.setAccess` after creation. There is no way to pre-configure access before the canvas exists, so the window between `canvases.create` and `canvases.setAccess` represents a brief period of default visibility — relevant for sensitive docs.

### canvases.create

`POST /api/canvases.create` accepts a `title` and a `document_content` object with `type: "markdown"`. Markdown is currently the only supported content type. The canvas can be attached to a channel via `channel_id` or created as a standalone document.

Rate limit: **Tier 2 (20+ requests per minute)**.

### canvases.edit

`POST /api/canvases.edit` takes a `canvas_id` and a `changes` array. Each change specifies an `operation` (append, prepend, replace, insert_after, insert_before, delete) and a `document_content` block. **Only one operation per API call is currently supported**, so building up a complex canvas requires multiple sequential calls.

Rate limit: **Tier 3 (50+ requests per minute)**.

**Practical call budget**: A typical 5,000-word narrative doc with 3 sections, 2 tables, and a checklist requires approximately 6–10 `canvases.edit` calls to populate section by section after the initial `canvases.create` call (one call per major content block appended). A 10,000-word doc with heavy structure (many headings, multiple tables, embedded images inserted as links) requires 15–20 calls. At the Tier 3 rate limit of 50+ requests per minute, a 60-doc narrative migration requires roughly 600–1,200 `canvases.edit` calls — approximately 12–24 minutes of sequential API time, excluding Coda extraction.

### Content limits

- **Markdown content**: 1 MiB (1,048,576 characters) per `document_content` object
- **Canvas tables**: 300 cells per table, in any row/column combination
- **Table cell content**: Supports bold, italic, strikethrough, code, links, checkboxes, mentions, and lists — but no formulas, no conditional formatting, no data types
- **Block Kit**: Not supported in canvases

### Common canvases.edit failure modes

Slack's Canvas API does not always return descriptive errors for malformed markdown. Common silent failures include:

- **HTML tags surviving html2text conversion**: Tags like `<div>`, `<span>`, or `<table>` passed as markdown content cause the block to be silently dropped or rendered as literal text. Strip all HTML before pushing to Canvas.
- **Deeply nested lists**: Markdown lists nested 3+ levels deep are inconsistently rendered. Flatten to 2 levels maximum.
- **Unsupported markdown extensions**: Coda's HTML export uses non-standard table attributes (e.g., `colspan`, `rowspan`) that have no markdown equivalent. These cause table blocks to fail silently. Pre-process all tables to simple pipe-table format.
- **Empty document_content blocks**: Passing an empty `markdown` string causes `canvases.edit` to return a 200 status with no visible error but makes no change. Validate that content is non-empty before each call.

> [!TIP]
> **Batch your canvas content.** Since `canvases.edit` only supports one operation per call, create the canvas with as much initial content as possible via `canvases.create`, then use `canvases.edit` only for content that must be appended after the fact. This minimizes API calls. For post-load validation, `canvases.getContent` returns the full canvas as markdown, which you can diff against your transformed source.

Slack notes that channel and DM canvases began converting to canvases in tabs on **April 9, 2025**, so older channel-canvas terminology in docs or runbooks may be stale.

## Engineering the migration pipeline

For docs that pass triage as narrative content, build a pipeline that handles extraction, transformation, and loading in distinct phases. Relying on ad-hoc scripts will result in dropped payloads and malformed canvases.

### Phase 1: Discovery and indexing

Do not start by downloading content. Start by mapping the workspace. Use `GET /docs` to list all documents. For each document, use `GET /docs/{docId}/pages` to build a hierarchical map of the page structure.

Store this index in a local staging database (PostgreSQL or SQLite). This index is mandatory for translating Coda's internal page links into Slack Canvas URLs later in the process. At this stage, also note which columns in each table are Pack-synced — identifiable by their `type: "formula"` or `isFormula: true` flag in the column schema — so you can flag them as potentially stale during transformation.

```python
import requests
import time

CODA_TOKEN = "your-coda-api-token"
HEADERS = {"Authorization": f"Bearer {CODA_TOKEN}"}
BASE = "https://coda.io/apis/v1"

def list_all_docs():
    docs = []
    url = f"{BASE}/docs"
    while url:
        resp = requests.get(url, headers=HEADERS).json()
        docs.extend(resp.get("items", []))
        url = resp.get("nextPageLink")
        time.sleep(1.5)  # respect 4 req/6s for listing docs
    return docs
```

### Phase 2: Rate-limited extraction

For each narrative page, queue an async export and poll until complete. **Re-host images immediately after extraction** — Coda CDN URLs expire within 48–72 hours, and a multi-day migration pipeline will encounter broken images if re-hosting is deferred to Phase 3.

```python
def export_page_html(doc_id, page_id):
    resp = requests.post(
        f"{BASE}/docs/{doc_id}/pages/{page_id}/export",
        headers={**HEADERS, "Content-Type": "application/json"},
        json={"outputFormat": "html"}
    ).json()
    export_id = resp["id"]

    while True:
        status = requests.get(
            f"{BASE}/docs/{doc_id}/pages/{page_id}/export/{export_id}",
            headers=HEADERS
        ).json()
        if status["status"] == "complete":
            return requests.get(status["downloadLink"]).text
        time.sleep(2)
```

Extract table row data in parallel, paginating at 500 rows per request:

```python
def get_table_rows(doc_id, table_id):
    rows = []
    url = f"{BASE}/docs/{doc_id}/tables/{table_id}/rows?limit=500"
    while url:
        resp = requests.get(url, headers=HEADERS).json()
        rows.extend(resp.get("items", []))
        url = resp.get("nextPageLink")
        time.sleep(0.06)  # respect 100 req/6s read limit
    return rows
```

### Phase 3: Transformation and chunking

This is where the heaviest engineering work occurs. Parse the extracted content and transform it for Slack:

1. **Strip unsupported elements.** Remove all interactive controls, buttons, and form elements. They render as broken text in Slack.
2. **Chunk tables.** Count the cells in every extracted table. If `rows × columns > 300`, split into multiple markdown tables with clear boundary labels, or link out to a spreadsheet.
3. **Convert HTML to markdown.** Use a library like `markdownify` or `html2text` to convert the HTML export into Slack-compatible markdown. Embed small tables as markdown pipe tables. After conversion, strip any residual HTML tags — unsupported tags passed to Canvas are silently dropped or rendered literally.
4. **Flatten nested lists.** Truncate list nesting to 2 levels maximum. Slack Canvas renders 3+ levels inconsistently.
5. **Pre-process tables to simple pipe format.** Coda's HTML export uses `colspan` and `rowspan` attributes that have no markdown equivalent. These cause Canvas table blocks to fail silently. Convert all tables to flat pipe-table format before ingestion.
6. **Re-host images.** Coda image URLs expire within 48–72 hours. Download every image referenced in the HTML export and re-host to a permanent location (S3, Slack files via `files.upload`, or your own CDN) before pushing to Canvas.
7. **Resolve internal links.** Scan the content for Coda internal links. Cross-reference with your staging database index and replace them with placeholder tags. Once the Slack Canvases are generated and you have their new URLs, run a second pass to update these links.
8. **Archive formulas.** For each formula-driven field, store the formula string in a sidecar file for audit. Canvas will only show the rendered output.
9. **Flag Pack-synced values.** For columns identified as Pack-sourced during Phase 1, annotate the extracted value with a staleness warning in the canvas (e.g., a callout block: *"Value last synced from Jira as of [extraction timestamp]. Verify before use."*).

### Phase 4: Pushing to Slack Canvas

Convert the transformed markdown into Slack canvases:

```python
SLACK_TOKEN = "xoxb-your-slack-bot-token"

def create_canvas(title, markdown_content, channel_id=None):
    payload = {
        "title": title,
        "document_content": {
            "type": "markdown",
            "markdown": markdown_content
        }
    }
    if channel_id:
        payload["channel_id"] = channel_id

    resp = requests.post(
        "https://slack.com/api/canvases.create",
        headers={
            "Authorization": f"Bearer {SLACK_TOKEN}",
            "Content-Type": "application/json"
        },
        json=payload
    )
    return resp.json()
```

Validate that `markdown_content` is non-empty before each call — passing an empty string returns HTTP 200 with no error but writes nothing. For large docs requiring multiple `canvases.edit` calls, check the response body for `"ok": false` after each call, not just HTTP status.

Slack Canvas lacks a hierarchical folder structure comparable to Coda's nested pages. Apply the subpage flattening rules from the triage section: create a primary "Index" canvas per channel that contains hyperlinks to all sub-canvases. Build the index last, after all sub-canvas URLs are known.

After creating each canvas, set access permissions via `canvases.setAccess` if the source doc had restricted visibility. Do not leave this step for cleanup — the window between creation and access configuration is a real exposure for sensitive docs.

### Phase 5: Validation and cleanup

Read each finished canvas back with `canvases.getContent` and diff it against your transformed source. Check for:

- Formatting issues and broken markdown
- Missing or expired image URLs (re-host immediately if found)
- Truncated or malformed tables (silent failures from unsupported HTML attributes)
- Broken mentions (map Coda user references to Slack User IDs like `<@U12345678>` using the `users:read` scope)
- Section order and permissions
- Pack-synced value annotations are present and timestamped

Document which application docs were excluded from the migration and their planned alternative destination.

## What about Slack Lists?

**Slack Lists** are a separate structured-data surface inside Slack, distinct from canvases. They support rows with assignees, due dates, status fields, and custom fields — displayed as a table or kanban board. But they have their own hard limits: no formulas, no calculated fields, no charts, no cross-list relationships, and performance degrades past a few hundred rows.

For Coda tables that are simple task trackers or request queues (no formulas, fewer than 200 rows), a Slack List may be a better landing zone than a canvas table. The data stays inside Slack, and you get basic filtering and views. But Lists are not a spreadsheet replacement — they are a lightweight tracker.

## When to choose a different destination

Be direct with stakeholders: **if the Coda workspace is primarily application docs, Slack Canvas is not the right destination.** The migration will strip out the features that make the docs useful, and the team will immediately start rebuilding the logic elsewhere.

Specific signals that you should choose a different target:

- **The workspace has more automation runs per month than page views.** The docs are tools, not documents.
- **Tables have more formula columns than data columns.** The value is in the computation.
- **Cross-doc sync is actively used.** There is a live data graph that Canvas cannot replicate.
- **Teams use buttons daily.** The interactive layer is load-bearing.
- **Any table exceeds 1,000 rows.** Even with splitting, canvas tables cannot handle operational data at this scale.
- **Pack-synced columns are mission-critical.** The API returns static snapshots; live sync cannot be replicated in Canvas.

Better destinations for application-heavy Coda workspaces:

- **[Notion](https://clonepartner.com/blog/blog/coda-to-notion-migration)**: Supports databases, relations, rollups, and Formula 2.0. Closest structural fit for Coda tables, though automations still need rebuilding.
- **[Confluence](https://clonepartner.com/blog/blog/coda-to-confluence-migration-methods-api-limits-data-mapping)**: Good for teams already in the Atlassian ecosystem. Tables are static HTML, but macros and Jira integration can replace some automation.
- **[SharePoint](https://clonepartner.com/blog/blog/coda-to-sharepoint-migration-methods-api-limits-data-mapping)**: Strong choice for Microsoft-heavy orgs. SharePoint lists handle structured data; Power Automate replaces Coda automations.
- **Google Sheets + Google Docs**: Split the workspace — sheets for computation, docs for narrative. Pragmatic, but doubles the surface area.

## Migration timeline and effort estimate

For a workspace of 100 Coda docs where 60% are narrative and 40% are applications:

- **Triage and classification**: 1–2 days (manual review, cannot be fully automated)
- **Script development** (extraction + conversion + canvas creation): 3–5 days
- **Narrative doc migration** (~60 docs): 1–2 days of execution time
- **Application doc re-platforming** (~40 docs): Separate project entirely. Expect 2–4 weeks depending on target platform and complexity.
- **Validation and cleanup**: 2–3 days

Total for the canvas-eligible portion: **roughly one week** of engineering time. The application docs are the real cost center.

## The practical path forward

Coda to Slack Canvas works for one narrow use case: moving narrative, text-heavy docs into a Slack-native surface where the team already lives. For this use case, the API pipeline is straightforward and the result is genuinely useful — your team gets lightweight reference docs attached to the channels where they work.

For anything involving computation, relational data, Pack-synced columns, or interactive workflows, Canvas is the wrong destination. The honest recommendation: split the workspace. Move narrative content to Canvas. Move application docs to a platform that can actually host them — [Notion](https://clonepartner.com/blog/blog/coda-to-notion-migration), [Confluence](https://clonepartner.com/blog/blog/coda-to-confluence-migration-methods-api-limits-data-mapping), [SharePoint](https://clonepartner.com/blog/blog/coda-to-sharepoint-migration-methods-api-limits-data-mapping), or even a Google Sheets + Docs combination.

Be ruthless during the triage phase. Apply the subpage hierarchy rules before you write a single line of migration code. Leave the applications behind, flatten the tables, and accept that calculated values become static text. Configure Slack token scopes before running the pipeline — a missing `canvases:write` scope produces a `missing_scope` error that is easy to misread as a rate limit failure. Re-host Coda images immediately after extraction, not at the end — the 48–72 hour CDN TTL does not wait for a multi-day migration to complete.

Respect the 300-cell limit, annotate Pack-synced values with staleness warnings, engineer a rate-limited extraction pipeline, and you can move your narrative knowledge base into Slack without data corruption.

> Need to migrate a complex Coda workspace — or split it across multiple destinations? ClonePartner engineers custom data pipelines that handle strict API limits, relational flattening, and zero-downtime transfers. Book a 30-minute call to walk through your workspace.
>
> [Talk to us](https://clonepartner.com/talk-to-us?duration=30&utm_source=blog&utm_medium=button&utm_campaign=demo_bookings&utm_content=cta_click&utm_term=demo_button_click)

## Frequently asked questions

### Can you migrate Coda formulas to Slack Canvas?

No. Slack Canvas has no formula engine. Calculated columns export their last computed values as static text, but the underlying formulas are permanently lost. Buttons, automations, and cross-doc sync configurations are not exportable through the Coda API at all.

### What is the Slack Canvas table size limit?

Canvas tables cap at 300 total cells per table, in any combination of rows and columns. A Coda table with 40 rows and 8 columns (320 cells) already exceeds this limit and must be split, truncated, or linked out to a spreadsheet.

### How do you export Coda page content via the API?

Use the asynchronous page export: POST /docs/{docId}/pages/{pageId}/export with outputFormat set to 'html' or 'markdown'. Poll the returned export ID until the status is 'complete', then download the content via the downloadLink. There is no bulk workspace export endpoint.

### What Coda API rate limits apply during migration?

Coda allows 100 read requests per 6 seconds, 10 write requests per 6 seconds, 5 doc-content writes per 10 seconds, and 4 list-docs requests per 6 seconds. The Slack Canvas API allows 20+ canvases.create calls per minute (Tier 2) and 50+ canvases.edit calls per minute (Tier 3).

### Should a formula-heavy Coda workspace migrate to Slack Canvas?

No. If the workspace relies heavily on formulas, automations, buttons, or cross-doc sync, Slack Canvas cannot host that functionality. Consider Notion, Confluence, or SharePoint instead. Only narrative, text-heavy docs are good Canvas candidates.
