---
title: "How to Import Data into Slack Canvas: API Limits & Guide"
slug: how-to-import-data-into-slack-canvas-api-limits-guide
date: 2026-09-29
author: Abdul Aleem
categories: [Slack Canvas, Migration Guide, Quip]
excerpt: "Slack Canvas has no import button. Here's the exact API sequence, Markdown constraints, and operational patterns for migrating documents into Canvas at scale."
tldr: "Importing into Slack Canvas requires custom scripts against six API methods with a 1 MiB content limit, 300-cell table cap, no bulk endpoint, and no canvases.list for reconciliation."
canonical: https://clonepartner.com/blog/how-to-import-data-into-slack-canvas-api-limits-guide
---

# How to Import Data into Slack Canvas: API Limits & Guide


# How to Import Data into Slack Canvas: API Limits & Guide

There is no import button for Slack Canvas. No bulk endpoint, no CSV upload, no migration connector. If you need to move documents from Quip, [Confluence](https://clonepartner.com/blog/blog/slack-canvas-vs-confluence-architecture-limits-and-migration), Notion, or any other source into Slack canvases, you are writing custom scripts against Slack's Canvas API — a small surface of about six methods, each with its own constraints.

This guide covers the full sequence from creation to reconciliation: which canvas type to create, how to convert content into Slack's restricted Markdown subset, how to upload images, how to write the body under the 1 MiB limit, how to grant access, and how to build a pipeline that survives at scale.

> [!NOTE]
> **What is a Slack Canvas?** A Slack Canvas is a persistent, collaborative document surface within Slack that supports basic rich text, checklists, and file attachments. It can exist as a standalone document or be attached directly to a Slack channel.

> [!NOTE]
> **Scopes you need:** `canvases:write` to create, edit, and share canvases; `canvases:read` to read content back or look up section IDs; `files:write` to upload images; and `files:read` to fetch image permalinks or reconcile canvases through `files.list`. ([docs.slack.dev](https://docs.slack.dev/reference/scopes/canvases.write))

## Channel canvas vs. standalone canvas: which do you create?

**A channel canvas** is created with `conversations.canvases.create` and is bound to a single channel. Each channel can have exactly one channel canvas — calling the method again returns a `channel_canvas_already_exists` error. Access is inherited from channel membership, so no explicit sharing is needed.

**A standalone canvas** is created with `canvases.create` and exists independently. It is owned by the acting user or bot. Standalone canvases are only available on paid Slack plans (Pro, Business+, Enterprise Grid). On free workspaces, `canvases.create` requires a `channel_id` parameter, effectively making it a channel-tabbed canvas rather than a true standalone document. ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.create))

| Attribute | Channel Canvas | Standalone Canvas |
|---|---|---|
| **API method** | `conversations.canvases.create` | `canvases.create` |
| **Plan requirement** | All plans | Paid plans only |
| **Limit per channel** | One | Unlimited (tabbed or standalone) |
| **Access model** | Inherited from channel | Private by default; must share explicitly |
| **Ownership** | Channel-scoped | Acting user/bot |
| **Rate limit** | Tier 2: 20+/min | Tier 2: 20+/min |

For migrations, [the decision between channel and standalone canvases](https://clonepartner.com/blog/blog/slack-canvas-api-channel-vs-standalone-for-document-migrations) maps to your source structure. If each source document corresponds to an existing channel (like a team wiki page per project channel), channel canvases are the natural fit. If you are importing a standalone knowledge base — hundreds of docs that don't map to channels — you need standalone canvases on a paid plan, plus explicit access grants afterward.

> [!WARNING]
> **Terminology drift:** Slack's help center says channel and DM canvases began converting to "canvases in tabs" on April 9, 2025, but the developer docs still publish `conversations.canvases.create` as the channel-bound API. In code, treat that method as the "one canvas attached to this channel" path even if admins use different terminology. ([slack.com](https://slack.com/help/articles/21290478840979-Feature-change-notice--Channel-canvases))

### How to check if a channel canvas already exists

Before creating a channel canvas, call `conversations.info` for the target channel. The canvas ID lives at `channel.properties.canvas` in the response. If it is populated, use `canvases.edit` to update the existing canvas instead of trying to create a new one. This makes channel canvases a clean target for idempotent "one destination per channel" imports. ([docs.slack.dev](https://docs.slack.dev/reference/methods/conversations.canvases.create/))

### Ownership matters for standalone canvases

If you create standalone canvases with a bot token, the bot owns them. Slack says only the current owner can transfer ownership, and only users — not bots or channels — can hold owner status. This means if you want a human to own the canvas post-migration, you must transfer ownership in the same session where the human user is authenticated. Decide up front whether the bot stays owner for future syncs or you transfer ownership to a human after the initial import. ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.access.set/))

## What Markdown does Slack Canvas actually accept?

**Slack canvases accept a [proprietary subset of Markdown](https://clonepartner.com/blog/blog/slack-canvas-markdown-what-renders-what-fails-what-drops)** — not the full CommonMark or GFM spec. The `document_content` object takes exactly two properties: `type` (always `"markdown"`) and `markdown` (your content string). Block Kit is explicitly not supported in canvases. ([docs.slack.dev](https://docs.slack.dev/surfaces/canvases/))

Supported formatting elements:

- **Headings:** h1, h2, h3 only — h4 through h6 will be rejected or silently flattened
- **Text styling:** bold, italic, strikethrough, code span
- **Structure:** paragraphs, hard line breaks, bulleted lists, ordered lists, checklists, blockquotes
- **Code:** fenced code blocks
- **Dividers:** horizontal rules
- **Links:** inline links, link references
- **Tables:** pipe-delimited Markdown tables (with a **300-cell cap** per table)
- **Embeds:** canvas unfurls, message unfurls, website unfurls, profile unfurls, file unfurls
- **Other:** emojis (standard and custom), `@` mentions for users and channels

**What will break during conversion:**

- **Heading levels 4–6** — flatten to h3 or convert to bold text (`**Heading**`)
- **Raw HTML tags** — `<br>`, `<span>`, `<div>` must be stripped or converted to plain Markdown
- **Block Kit JSON** — will not render; a common mistake for teams used to building Slack messages
- **Nested blockquotes** — Slack may reject deeply nested structures
- **Complex nested lists** — behavior is inconsistent beyond two levels of nesting; flatten to two levels max
- **Embedded media as binary** — images must be referenced by URL, not embedded inline

### Content conversion: from rich formats to Slack Canvas Markdown

If you are converting from Quip HTML, Confluence ADF, or Notion block JSON, you need a conversion layer that emits only the supported subset. Here is a minimal Python function that covers the most common transformations:

```python
import re

def convert_to_slack_canvas_markdown(source_html: str) -> str:
    """
    Convert source HTML (e.g., from Quip or Confluence) to Slack Canvas-compatible Markdown.
    Handles the most common breaking cases: heading levels, raw HTML, nested lists.
    """
    import html2text
    h = html2text.HTML2Text()
    h.ignore_links = False
    h.body_width = 0  # Don't wrap lines
    markdown = h.handle(source_html)

    # Flatten h4-h6 to h3
    markdown = re.sub(r'^#{4,6}\s+', '### ', markdown, flags=re.MULTILINE)

    # Strip remaining raw HTML tags (e.g., <br>, <span>, <div>)
    markdown = re.sub(r'<[^>]+>', '', markdown)

    # Flatten nested lists beyond 2 levels (3+ spaces of indent → 2 levels)
    def flatten_deep_nesting(m):
        indent = m.group(1)
        bullet = m.group(2)
        text = m.group(3)
        # Cap indent at 4 spaces (2 levels of 2-space indent)
        capped_indent = indent[:4]
        return f"{capped_indent}{bullet} {text}"
    markdown = re.sub(r'^( {4,})([-*+]|\d+\.)\s+(.+)', flatten_deep_nesting, markdown, flags=re.MULTILINE)

    return markdown.strip()
```

For Notion block JSON, the conversion is more granular since Notion's API returns typed block objects rather than HTML. Map block types as follows:

| Notion Block Type | Slack Canvas Equivalent |
|---|---|
| `heading_1` | `# Heading` |
| `heading_2` | `## Heading` |
| `heading_3` | `### Heading` |
| `heading_4` / `heading_5` / `heading_6` | `### Heading` (flatten) |
| `bulleted_list_item` | `- item` |
| `numbered_list_item` | `1. item` |
| `to_do` | `- [ ] item` or `- [x] item` |
| `code` | `` ``` `` fenced block with language |
| `quote` | `> text` |
| `divider` | `---` |
| `table` | Pipe-delimited; validate ≤ 300 cells before emitting |
| `image` | Upload via Slack Files API; emit `! [alt](permalink)` |
| `callout` | Convert to blockquote or bold text — no native equivalent |
| `embed` | Emit URL on its own line for unfurl; or descriptive link |

Confluence ADF (Atlassian Document Format) is a JSON schema similar to Notion's. ADF `heading` nodes with `level` 4–6 must be remapped to level 3. ADF `table` nodes require cell-counting before conversion — iterate all rows and columns, multiply, and split if the product exceeds 300 before emitting Markdown.

> [!NOTE]
> **Mentions and channel links** use Slack-specific syntax, not generic `@name` or `#channel` text. You need resolved Slack IDs at import time. Channel links render as an unclickable "private channel" label for viewers who lack access to the linked channel — easy to miss in migrations with cross-channel references. ([docs.slack.dev](https://docs.slack.dev/surfaces/canvases/))

### Why tables must stay under 300 cells

**Canvas tables have a hard limit of 300 cells per table** — any combination of rows and columns whose product is 300 or fewer. A 10-column table maxes out at 30 rows (including the header). A 3-column table gets 100 rows.

**The API rejects the entire `document_content` payload if any table exceeds this limit — it does not silently truncate.** You receive a 400-level error for the whole canvas write, not just the offending table. This means a single oversized table blocks the entire document from importing.

If your source data has tables exceeding 300 cells:

- **Split into multiple tables** with continuation headers
- **Truncate with a link** to the full dataset elsewhere
- **Convert to a list format** where the table structure is not essential

Tables can contain more than plain text (links, checkboxes, lists, mentions, text styles), so the right fix for a huge source table is usually to split it rather than strip it down. ([docs.slack.dev](https://docs.slack.dev/surfaces/canvases/))

## What is a section_id and how does it work?

Several Canvas API operations — `insert_after`, `insert_before`, `replace`, `delete` — require a `section_id`. A section is a discrete content block within a canvas: a heading, a paragraph, a list, a table. Slack assigns each section a unique opaque ID when content is written.

**You cannot predict section IDs in advance.** You must retrieve them by calling `canvases.sections.lookup`, which accepts a `canvas_id` and a `criteria` object. The criteria can match by `contains_text` (substring match) or by `section_types` (filter by block type such as `h1`, `h2`, `any_header`, `paragraph`).

```python
def get_section_id_by_heading(canvas_id: str, heading_text: str) -> str | None:
    """Look up a section ID by matching heading text."""
    resp = requests.post(
        "https://slack.com/api/canvases.sections.lookup",
        headers={"Authorization": f"Bearer {SLACK_TOKEN}"},
        json={
            "canvas_id": canvas_id,
            "criteria": {
                "contains_text": heading_text,
                "section_types": ["h1", "h2", "h3"]
            }
        }
    )
    sections = resp.json().get("sections", [])
    return sections[0]["id"] if sections else None
```

**Important behaviors:**

- Section IDs are stable across edits — a heading's ID does not change when you edit text below it
- If you delete a section and re-add it, it gets a new ID
- `contains_text` is a substring match, not an exact match — anchor to unique strings to avoid ambiguous results
- `section_types` available include: `h1`, `h2`, `h3`, `any_header`, `paragraph`, `ordered_list`, `bullet_list`, `checklist`, `table`, `code`, `quote`, `divider`

For delta-update workflows (syncing changed source documents into existing canvases), the pattern is: call `canvases.sections.lookup` to find the section to replace, then call `canvases.edit` with `operation: "replace"` and the `section_id`. For full rewrites, use `operation: "replace"` with no `section_id` to replace the entire canvas content in one call.

## How do images work in a Slack Canvas import?

**You cannot embed binary image data in the `document_content` Markdown payload.** Images must be referenced by URL using standard Markdown syntax: `! [alt text](URL)`. That URL must be either a publicly accessible URL or a Slack-hosted permalink.

If you use public URLs (linking to images on your old knowledge base or an S3 bucket), the image will render — but you introduce link rot risk. When the source system is decommissioned, those images break.

The correct image import workflow uses Slack's current upload API:

1. **Get an upload URL** by calling `files.getUploadURLExternal` with the filename and file size
2. **POST the file bytes** to the returned upload URL
3. **Complete the upload** by calling `files.completeUploadExternal` — Slack discards the upload if you skip this step
4. **Retrieve the permalink** from the upload response or via `files.info`
5. **Reference the permalink** in your Markdown: `! [diagram](https://your-workspace.slack.com/files/...)`

```python
def upload_image_to_slack(file_path: str, filename: str) -> str:
    """Upload an image and return its Slack permalink."""
    import os
    file_size = os.path.getsize(file_path)

    # Step 1: Get upload URL
    resp = requests.post(
        "https://slack.com/api/files.getUploadURLExternal",
        headers={"Authorization": f"Bearer {SLACK_TOKEN}"},
        data={"filename": filename, "length": file_size}
    )
    upload_url = resp.json()["upload_url"]
    file_id = resp.json()["file_id"]

    # Step 2: POST file bytes
    with open(file_path, "rb") as f:
        requests.post(upload_url, data=f,
                      headers={"Content-Type": "application/octet-stream"})

    # Step 3: Complete the upload
    requests.post(
        "https://slack.com/api/files.completeUploadExternal",
        headers={"Authorization": f"Bearer {SLACK_TOKEN}"},
        json={"files": [{"id": file_id, "title": filename}]}
    )

    # Step 4: Get permalink
    info = requests.get(
        "https://slack.com/api/files.info",
        headers={"Authorization": f"Bearer {SLACK_TOKEN}"},
        params={"file": file_id}
    ).json()
    return info["file"]["permalink"]
```

> [!WARNING]
> **The old `files.upload` method is gone.** Slack deprecated it, cut off new-app access on May 16, 2024, and fully sunset the endpoint on November 12, 2025. Use the `getUploadURLExternal` → `completeUploadExternal` flow shown above. ([docs.slack.dev](https://docs.slack.dev/changelog/2024-04-a-better-way-to-upload-files-is-here-to-stay/))

> [!TIP]
> **Batch image uploads before canvas creation.** Upload all images for a document first, collect the permalinks, then substitute them into your Markdown before creating the canvas. This avoids partial canvases with broken image references. In production, keep an asset manifest of `source_asset_id → Slack file_id → permalink` so you can deduplicate repeated images and retry failures without re-uploading.

Slack documents file-upload restriction errors such as disabled uploads, image-only policies, and size limits, so some tenants may force you to keep external URLs instead of copying binaries into Slack. ([docs.slack.dev](https://docs.slack.dev/reference/methods/files.getUploadURLExternal/))

## How to write content with `canvases.edit`

**`canvases.edit`** is the method for writing content to an existing canvas. It accepts a `changes` array where each element specifies an `operation` and a `document_content` payload. The Markdown content of each change is limited to **1 MiB (1,048,576 characters)**.

Available operations:

- `insert_at_start` — prepend content
- `insert_at_end` — append content
- `insert_after` — insert after a specific section (requires `section_id`)
- `insert_before` — insert before a specific section (requires `section_id`)
- `replace` — replace a specific section (requires `section_id`), or replace the entire canvas if `section_id` is omitted
- `delete` — remove a specific section (requires `section_id`)

> [!WARNING]
> **One operation per call.** Despite accepting a `changes` array, `canvases.edit` currently supports only one operation per API call. Passing multiple operations produces unexpected behavior. Structure your pipeline to issue one edit at a time. ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.edit/))

For a fresh import, the cleanest pattern is:

1. Create the canvas with the first chunk of content (up to 1 MiB) via `canvases.create` or `conversations.canvases.create`
2. If the document exceeds 1 MiB, split at logical boundaries (heading breaks, paragraph boundaries) and append each chunk via `canvases.edit` with `insert_at_end`

```python
import requests

SLACK_TOKEN = "xoxb-your-token"
MAX_CHUNK = 1_000_000  # Stay under 1 MiB with margin

def split_markdown_at_boundaries(markdown: str, max_size: int) -> list[str]:
    """Split Markdown at heading boundaries to stay under max_size bytes."""
    if len(markdown.encode("utf-8")) <= max_size:
        return [markdown]
    chunks = []
    current = []
    current_size = 0
    for line in markdown.split("\n"):
        line_size = len((line + "\n").encode("utf-8"))
        is_heading = line.startswith("#")
        if is_heading and current_size + line_size > max_size and current:
            chunks.append("\n".join(current))
            current = []
            current_size = 0
        current.append(line)
        current_size += line_size
    if current:
        chunks.append("\n".join(current))
    return chunks

def create_canvas(title: str, markdown: str, channel_id: str = None) -> str:
    """Create a canvas and append overflow chunks. Returns canvas_id."""
    chunks = split_markdown_at_boundaries(markdown, MAX_CHUNK)
    payload = {
        "title": title,
        "document_content": {"type": "markdown", "markdown": chunks[0]}
    }
    if channel_id:
        payload["channel_id"] = channel_id

    resp = requests.post(
        "https://slack.com/api/canvases.create",
        headers={"Authorization": f"Bearer {SLACK_TOKEN}"},
        json=payload
    )
    canvas_id = resp.json()["canvas_id"]

    for chunk in chunks[1:]:
        requests.post(
            "https://slack.com/api/canvases.edit",
            headers={"Authorization": f"Bearer {SLACK_TOKEN}"},
            json={
                "canvas_id": canvas_id,
                "changes": [{
                    "operation": "insert_at_end",
                    "document_content": {"type": "markdown", "markdown": chunk}
                }]
            }
        )
    return canvas_id
```

`canvases.getContent` returns the current canvas as a Markdown string in the same shape that `canvases.create` and `canvases.edit` accept. Use it for post-import verification by reading back the canvas and diffing against your source Markdown. A minimal response looks like:

```json
{
  "canvas_id": "F0123ABCDEF",
  "content": {
    "type": "markdown",
    "markdown": "# Document Title\n\nFirst paragraph...\n\n## Section Two\n\n..."
  }
}
```

This makes content auditing straightforward: hash the source Markdown after conversion, hash the returned `content.markdown` after stripping any Slack-injected formatting, compare. ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.edit/))

## How to grant access after import

**Channel canvases do not need explicit sharing** — access is tied to channel membership. For standalone canvases, every canvas starts private to the creating app or user.

**`canvases.access.set`** controls who can view or edit a standalone canvas. Key constraints:

- **Maximum 20 `channel_ids` per call** — batch into groups of 20 for larger audiences
- **Maximum 20 `user_ids` per call** — same batching requirement
- **Cannot pass both `channel_ids` and `user_ids` in the same call** — they are mutually exclusive parameters
- **Access levels:** `read` (view only), `write` (view + edit), `owner` (transfer ownership — `user_ids` only, and the recipient must be a human user, not a bot)
- **DMs and MPDMs require `user_ids`** — channel IDs for DMs/MPDMs will fail
- **Rate limit:** Tier 3 (50+/min)

You can also pass a `channel_id` when calling `canvases.create` to have the new standalone canvas automatically added as a channel tab with write permissions — useful when the destination channel is known at creation time. ([docs.slack.dev](https://docs.slack.dev/reference/methods/conversations.canvases.create/))

```python
def share_canvas(canvas_id: str, channel_ids: list[str], access: str = "read"):
    """Share a canvas with channels in batches of 20."""
    for i in range(0, len(channel_ids), 20):
        batch = channel_ids[i:i + 20]
        requests.post(
            "https://slack.com/api/canvases.access.set",
            headers={"Authorization": f"Bearer {SLACK_TOKEN}"},
            json={
                "canvas_id": canvas_id,
                "access_level": access,
                "channel_ids": batch
            }
        )
```

For enterprise migrations, the best practice is to map source folders to Slack channels and grant canvas access to the `channel_id` rather than individual users. As team membership changes, canvas access follows Slack's native channel membership logic. ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.access.set/))

## Running the import at scale: checkpointing, idempotency, and reconciliation

The Canvas API has no transactional guarantees. A migration script that creates 500 canvases will inevitably hit [API limits](https://clonepartner.com/blog/blog/slack-canvas-api-limits-that-break-bulk-migrations), transient errors, or network timeouts. Without operational safeguards, a re-run creates duplicates.

### Failure modes and recovery actions

| Error / Condition | Likely Cause | Recovery Action |
|---|---|---|
| `channel_canvas_already_exists` | Creating a channel canvas when one exists | Read canvas ID from `channel.properties.canvas`, switch to `canvases.edit` |
| `invalid_section_id` | Section was deleted or ID is stale | Re-run `canvases.sections.lookup` to refresh IDs |
| `too_many_cells` (or 400 on edit) | Table exceeds 300-cell limit | Split table before retry; entire payload is rejected |
| Payload > 1 MiB | Document or chunk too large | Re-split at a smaller boundary; retry with smaller chunk |
| HTTP 429 with `Retry-After` | Rate limit hit | Sleep exactly `Retry-After` seconds; do not retry sooner |
| `file_upload_disabled` | Workspace policy blocks uploads | Fall back to external image URLs; log for manual review |
| `not_allowed_token_type` | Bot token used where user token required | Switch to user token; ownership transfers always require user auth |
| Canvas not found in checkpoint | Partial run created canvas before checkpoint write | Query `conversations.info` (channel canvases) or `files.list?types=canvas` to recover ID |

### Checkpointing

Persist the mapping of `source_doc_id → canvas_id` after every successful creation. A simple SQLite table works:

```python
import sqlite3

def init_checkpoint_db(db_path: str = "migration.db"):
    conn = sqlite3.connect(db_path)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS canvas_map (
            source_id TEXT PRIMARY KEY,
            canvas_id TEXT NOT NULL,
            created_at TEXT DEFAULT CURRENT_TIMESTAMP,
            stage TEXT DEFAULT 'created',
            content_hash TEXT,
            error TEXT
        )
    """)
    conn.commit()
    return conn

def is_already_migrated(conn, source_id: str) -> bool:
    row = conn.execute(
        "SELECT canvas_id FROM canvas_map WHERE source_id = ?",
        (source_id,)
    ).fetchone()
    return row is not None
```

Record the mapping immediately after creation — before any access grants or edits. A crash during the sharing step should not cause the next run to re-create the canvas.

For robust pipelines, persist stage-by-stage checkpoints: `transformed`, `assets_uploaded`, `canvas_created`, `body_written`, `access_granted`, `verified`. This lets you resume from the exact failure point rather than from the beginning of each document. The `content_hash` column stores a hash of the converted Markdown so you can detect source documents that changed between runs and re-sync selectively.

### Idempotency

Slack's Canvas API does not support native idempotency keys. You must implement idempotency yourself:

1. **Checkpoint-based dedup:** Before creation, check your checkpoint table for the source document ID
2. **Channel canvas recovery:** On re-runs, recover the existing channel canvas ID from `channel.properties.canvas` via `conversations.info` — this works even if your local checkpoint state is lost
3. **Title-based matching as fallback:** Query `files.list?types=canvas` and match by title — but titles are not unique and are mutable via `canvases.edit`, so treat this as a fallback, not a primary key
4. **Source ID in content:** Embed the source document ID as a hidden marker (e.g., in a comment or the first line of content) for reconciliation during audits

### Reconciliation via `files.list`

There is no `canvases.list` endpoint. The only way to enumerate canvases programmatically is `files.list` with the `types=canvas` filter:

```bash
GET https://slack.com/api/files.list?types=canvas&count=100&page=1
```

This returns canvas files with standard file metadata. You can filter by `user` (the bot that created them) or `channel` to narrow results. Paginate through all results and cross-reference against your checkpoint table to identify:

- **Orphans:** Canvases in Slack that are not in your checkpoint (created by a partial run)
- **Missing:** Source documents with no corresponding canvas
- **Stale:** Canvases where the stored `content_hash` does not match the current source

`files.list` runs at Tier 3 (50+ requests per minute) with a default of 100 results per page. For workspaces with thousands of canvases, full enumeration takes minutes, not hours.

## Rate limits for Canvas API methods

Design your worker pool around Slack's published method tiers. All rate limits are evaluated per method, per workspace, per app. Parallel workers share the same budget — use a centralized rate limiter, not per-worker sleeps.

Slack publishes tiers as minimums (e.g., "Tier 2 allows 20+ requests per minute"), meaning the actual limit may be higher but is not guaranteed. For capacity planning, treat the published minimums as your ceiling and budget accordingly. At Tier 2 creation rates with no image uploads, expect roughly **20 canvases per minute** as a conservative throughput floor. Image-heavy documents will be slower due to upload round-trips.

| Method | Tier | Min requests/min | Migration use |
|---|---|---|---|
| `canvases.create` | Tier 2 | 20+ | Creating standalone canvases |
| `conversations.canvases.create` | Tier 2 | 20+ | Creating channel canvases |
| `canvases.edit` | Tier 3 | 50+ | Writing/appending content |
| `canvases.access.set` | Tier 3 | 50+ | Sharing canvases |
| `canvases.sections.lookup` | Tier 3 | 50+ | Finding section IDs for targeted edits |
| `canvases.delete` | Tier 2 | 20+ | Cleanup of failed imports |
| `canvases.getContent` | Tier 3 | 50+ | Post-import verification |
| `files.list` | Tier 3 | 50+ | Reconciliation (`types=canvas`) |
| `files.getUploadURLExternal` | Tier 4 | 100+ | Image upload step 1 |
| `files.completeUploadExternal` | Tier 4 | 100+ | Image upload step 2 |

> [!CAUTION]
> **A 429 response includes a `Retry-After` header.** Always respect it exactly. Sleep for the specified number of seconds before retrying — do not use exponential backoff alone, because Slack's `Retry-After` value is authoritative. Ignoring it and retrying sooner prolongs throttling.

## The full import sequence

Here is the complete sequence for importing one document into Slack Canvas:

1. **Pre-flight:** Check your checkpoint table — skip if this source document was already migrated. Check the `stage` column to resume mid-document if the previous run failed partway through.
2. **Convert content:** Transform source format to Slack's Markdown subset (h1–h3 only, no Block Kit, tables under 300 cells, no raw HTML). Store a hash of the converted Markdown for later reconciliation.
3. **Upload images:** For each embedded image, upload via `files.getUploadURLExternal` → POST bytes → `files.completeUploadExternal`, then retrieve the permalink. Record `source_asset_id → Slack file_id → permalink` in your asset manifest.
4. **Substitute image URLs:** Replace source image references with Slack permalinks in the Markdown.
5. **Split if needed:** If Markdown exceeds 1 MiB when encoded as UTF-8, chunk at heading boundaries.
6. **Create the canvas:** Call `canvases.create` (standalone) or `conversations.canvases.create` (channel) with the first chunk.
7. **Record checkpoint:** Persist `source_id → canvas_id` with `stage = 'canvas_created'` immediately.
8. **Append overflow:** If there are additional chunks, call `canvases.edit` with `insert_at_end` for each. Update stage to `'body_written'` when complete.
9. **Grant access:** For standalone canvases, call `canvases.access.set` with batches of up to 20 IDs per call. Update stage to `'access_granted'`.
10. **Verify:** Call `canvases.getContent` to read back the canvas Markdown, hash it, and compare against the stored content hash. Log any mismatches for manual review. Update stage to `'verified'`.

## API surface summary

The Canvas API as of 2025 consists of six primary methods for content management and two supporting flows:

| Method | Purpose |
|---|---|
| `canvases.create` | Create a standalone canvas |
| `conversations.canvases.create` | Create or retrieve a channel-bound canvas |
| `canvases.edit` | Write, append, replace, or delete content (one operation per call) |
| `canvases.getContent` | Read canvas content as Markdown |
| `canvases.sections.lookup` | Resolve section IDs by text or type |
| `canvases.access.set` | Grant or modify access for users or channels |
| `canvases.delete` | Delete a canvas |
| `files.getUploadURLExternal` + `files.completeUploadExternal` | Two-step image upload flow (replaces deprecated `files.upload`) |

There is no `canvases.list`, no `canvases.search`, and no bulk-create endpoint. Enumeration requires `files.list?types=canvas`. Every bulk import is a scripted pipeline; no native Slack tooling or third-party connector handles this as of mid-2025.

## Frequently asked questions

### Is there a bulk import tool for Slack Canvas?

No. Slack provides no import button, bulk endpoint, or migration connector for canvases. Every import requires custom scripts using Canvas API methods like canvases.create, canvases.edit, and canvases.access.set. No third-party tool handles this either.

### What is the size limit for Slack Canvas content?

Each document_content payload is limited to 1 MiB (1,048,576 characters) of Markdown. For documents exceeding this limit, create the canvas with the first chunk and append additional chunks using canvases.edit with the insert_at_end operation.

### Can you create standalone canvases on a free Slack plan?

No. Standalone canvases are only available on paid Slack plans (Pro, Business+, Enterprise Grid). Free workspaces must provide a channel_id when calling canvases.create, which produces a channel-tabbed canvas rather than a true standalone document.

### How do you list all canvases in a Slack workspace via API?

There is no canvases.list method. Use files.list with the types=canvas filter parameter. This returns canvas files with standard metadata and supports pagination, user, and channel filtering.

### What Markdown formatting does Slack Canvas support?

Slack canvases support headings h1–h3, bold, italic, strikethrough, code blocks, code spans, bulleted and ordered lists, checklists, blockquotes, tables (max 300 cells), horizontal rules, inline links, emojis, and @mentions. Block Kit, raw HTML, and headings h4–h6 are not supported.
