Launched:self-serve migrations intoSuperhuman Docs (Coda)
Try it now
01Agent-first
Runs where you already work
Plug it into Claude, ChatGPT or Cursor. Describe the move in plain English; the agent runs it.
02Engineer-led
Our production engine, unlocked
The pipeline our engineers use on managed enterprise migrations — the same code, now something you can drive yourself.
03Pricing
Try 10 pages free, then $1 a page
Credit-based, pay-as-you-go. No scoping call, no quote — sample it on your own docs before you spend anything.
04Sources
NotionSlabConfluenceSoonGoogle DocsSoon
Skip to content

Slack Canvas API Limits That Break Bulk Migrations

Hard limits on Slack Canvas content size, table cells, sharing batches, and enumeration break at bulk scale. Here is each ceiling and what to build around it.

Roopendra Talekar Roopendra Talekar · · 16 min read
Slack Canvas API Limits That Break Bulk Migrations
TALK TO AN ENGINEER

Planning a migration?

Get a free 30-min call with our engineers. We'll review your setup and map out a custom migration plan — no obligation.

Schedule a free call
  • 1,500+ migrations completed
  • Zero downtime guaranteed
  • Transparent, fixed pricing
  • Project success responsibility
  • Post-migration support included

Slack Canvas API Limits That Break Bulk Migrations

The Slack Canvas API works fine for one-off document creation. It breaks at bulk scale. Six hard limits — on content size, table cells, sharing batches, plan tiers, channel fanout, and object enumeration — are invisible when you create a handful of canvases but become architecture constraints when you migrate thousands of documents.

If you are moving Quip documents to Slack Canvases ahead of the Quip end-of-life, or consolidating knowledge bases into an Enterprise Grid org, every ceiling in this guide shapes your pipeline design.

Every Canvas API Limit That Matters at Volume

Limit Value Documented? API Method / Surface
Document content size 1 MiB (1,048,576 characters) per document_content ✅ Yes canvases.edit, canvases.create, conversations.canvases.create
Table cells 300 cells per table (any row × column combination) ✅ Yes Canvas markdown tables
Sharing batch size 20 channel_ids OR 20 user_ids per call (mutually exclusive) ✅ Yes canvases.access.set
Canvas-to-channel fan-out 1,000 channels per canvas Partial — admin docs only, not API reference Enterprise Grid
Standalone canvas creation Paid plans only ✅ Yes canvases.create
Canvas enumeration No canvases.list exists ✅ Yes files.list?types=canvas
Operations per edit call 1 operation per canvases.edit call ✅ Yes canvases.edit
Create rate limit Tier 2: 20+ requests/min ✅ Yes canvases.create, conversations.canvases.create
Edit rate limit Tier 3: 50+ requests/min ✅ Yes canvases.edit
Sharing rate limit Tier 3: 50+ requests/min ✅ Yes canvases.access.set
getContent rate limit Tier 3: 50+ requests/min ✅ Yes canvases.getContent
files.list rate limit Tier 3: 50+ requests/min ✅ Yes files.list

Required OAuth Scopes by Method

Missing scopes produce missing_scope errors that are easy to confuse with permission errors. Add all required scopes before starting a migration run.

Method Required Scope(s)
canvases.create canvases:write
conversations.canvases.create canvases:write
canvases.edit canvases:write
canvases.delete canvases:write
canvases.getContent canvases:read
canvases.access.set canvases:write
canvases.access.delete canvases:write
canvases.sections.lookup canvases:read
files.list files:read

On Enterprise Grid with org-level tokens, canvas methods operate across all workspaces in the org. Workspace-scoped tokens issued to individual workspace apps see only canvases within their workspace — the same canvas created with a workspace-scoped token is invisible to a different workspace-scoped token even within the same Grid org. If your migration script uses a workspace-scoped bot token and your canvases span multiple Grid workspaces, you need one token per workspace or an org-level token with canvases:read and canvases:write granted at the org level.

Each limit and scope is described below with what it forces you to build.

What Does the 1 MiB Document Content Limit Mean?

The document_content object in any canvas write call accepts at most 1 MiB — 1,048,576 characters — of markdown. This limit applies per document_content object: when calling canvases.edit, it applies to each change in the changes array independently. Slack documents this in the Canvas surface docs and the canvases.edit reference.

For a single hand-written canvas, 1 MiB is generous. For a migration pulling documents out of Quip, Confluence, or Google Docs, it is the first wall you hit. A 40-page product spec with embedded tables, code blocks, and image references regularly exceeds 1 MiB after conversion to Slack's markdown dialect. Legacy documents with years of historical logs, deep nested lists, or base64-encoded inline images are almost guaranteed to exceed it.

Base64-encoded inline images are particularly dangerous. A single 100 KB PNG encoded as base64 expands to approximately 133 KB of text — and Slack Canvas does not render base64-encoded image data inline. The correct strategy is to upload each image separately using files.upload_v2, retrieve the resulting permalink, and inject the permalink as a standard markdown image reference ! [alt](permalink) into the canvas body. This reduces payload size and produces images that are actually visible in the rendered canvas.

Pre-flight measurement

Never discover a payload is too large by reading an API error. Before any API call, measure the byte length of the serialized document_content value. The limit is documented in characters, but if your source documents contain multi-byte Unicode characters (complex emojis, CJK text), a character-count check alone can yield false negatives on byte-level constraints. Measure both.

import json
 
MAX_CONTENT_BYTES = 1_048_576
 
def check_content_size(markdown_text: str) -> bool:
    payload = json.dumps({"type": "markdown", "markdown": markdown_text})
    return len(payload.encode("utf-8")) <= MAX_CONTENT_BYTES

Flag anything over roughly 900 KiB, leaving headroom for JSON wrapper overhead. This preserves your rate-limit budget for successful writes instead of burning Tier 3 calls on payloads that will be rejected.

How to split oversized documents

When a document exceeds the limit, split it across multiple canvases on semantic boundaries — not by raw character count.

  1. Parse the document AST. Do not split arbitrarily. Parse the source document into an abstract syntax tree so you can identify structural boundaries without breaking markdown formatting or table structures.
  2. Cut at heading boundaries. Traverse the AST and find the nearest top-level heading (H1, H2, or H3) before the size threshold. Keep tables, checklists, callouts, and code blocks intact within each chunk.
  3. Create sequential canvases. Title each part with a stable name — Architecture Spec — Part 1 of 3 — and store that part number in your manifest so re-runs do not create new fragments.
  4. Backfill cross-references in a second pass. In pass one, create every destination canvas and capture the returned IDs. In pass two, write content and inject previous/next links now that you know all destination IDs.

Blind 1 MiB slicing is how you end up with broken tables, orphaned list items, and references that point nowhere.

Warning

One operation per canvases.edit call. Slack currently supports only one operation per API call in the changes array. The supported operation types are insert_at_start, insert_at_end, insert_after, insert_before, and replace. You cannot batch multiple operations in a single request, so writing a split document means one API call per section. For first-write bulk imports, a single replace operation targeting the whole canvas is usually simpler than multiple section-relative insert_after calls.

How Many Cells Can a Slack Canvas Table Hold?

A canvas table supports a maximum of 300 cells per table, in any combination of rows and columns. A 10-column table gets 30 rows. A 3-column table gets 100 rows. A table with 15 columns and 25 rows (375 cells) will cause the API to reject the entire document_content payload. Slack documents this in the Canvas surface reference.

This is separate from the Block Kit data_table block used in messages, which has its own limits (20 columns, 100 data rows, 10,000 aggregate characters). Canvas tables and message tables are different surfaces with different constraints.

For migrations from Quip or Confluence, where users frequently treat document tables as lightweight spreadsheets, 300 cells is the constraint that bites most often.

Handling oversized tables

Pre-flight, compute rows × columns for every table in every source document. Flag any table over 300 cells before your migration run starts. Then apply one of these strategies:

  • Chunk by row range. Split at row boundaries, repeating headers in each chunk. A 500-row × 4-column table (2,000 cells) becomes seven canvas tables of roughly 71 rows each (71 × 4 = 284 cells per chunk). Add a label above each chunk: "Rows 1–71", "Rows 72–142", etc. This preserves searchability within the canvas but degrades readability.
  • Convert to CSV and attach. For data-heavy tables, convert the source table to a CSV file, upload it with files.upload_v2, retrieve the permalink, and inject a markdown link in the canvas. This guarantees zero data loss and keeps the canvas itself readable.
  • Route to a different destination. A 200-column spreadsheet from Quip is not a canvas table. Attach it as a file, link to a Google Sheet, or acknowledge that this data type does not belong in a canvas.

Radical honesty matters here: a destination that works for narrative documents can still be a poor fit for large tabular data. "Different destination for tables over this shape" is often cleaner than forcing every source artifact into one surface.

How Does canvases.access.set Work at Scale?

canvases.access.set accepts a maximum of 20 channel_ids or 20 user_ids per call, and the two arrays are mutually exclusive — you cannot pass both in the same request. This is documented in the method reference, which lists Minimum: 1, Maximum: 20 for both parameters.

If you pass both channel_ids and user_ids, the API returns an invalid_parameters error. If you pass more than 20 IDs in either array, the request fails.

Batched sharing with partial-failure handling

If a canvas needs to be shared with 85 channels and 12 users, that is 5 calls for channels (20 + 20 + 20 + 20 + 5) and 1 call for users (12) — 6 total API calls, each of which can independently succeed or fail.

def share_canvas(client, canvas_id, channel_ids, user_ids, access_level="read"):
    failed = []
    for i in range(0, len(channel_ids), 20):
        batch = channel_ids[i:i+20]
        try:
            client.canvases_access_set(
                canvas_id=canvas_id,
                access_level=access_level,
                channel_ids=batch
            )
        except SlackApiError as e:
            failed.append({"type": "channels", "ids": batch, "error": str(e)})
    for i in range(0, len(user_ids), 20):
        batch = user_ids[i:i+20]
        try:
            client.canvases_access_set(
                canvas_id=canvas_id,
                access_level=access_level,
                user_ids=batch
            )
        except SlackApiError as e:
            failed.append({"type": "users", "ids": batch, "error": str(e)})
    return failed

Partial failure is the real problem. If batch 3 of 5 channel batches fails (rate limit, transient error, one invalid channel ID), the canvas is shared with 40 channels but not the remaining 45. Your script must track which batches succeeded and retry only the failures — without re-sharing already-shared batches, which is safe but wastes rate-limit budget.

Failures in canvases.access.set often occur because a user_id belongs to a deactivated account or a guest lacking workspace permissions. Your retry logic should catch the specific error code (user_not_found, cant_invite_self, no_permission), identify the offending ID by parsing the API error response, remove it from the batch, and re-send with the remaining IDs.

Info

DMs and MPDMs are excluded. Access levels can only be set for regular channels. Channel IDs for direct messages or multi-party DMs return an error. To share with individual users in DMs, use user_ids instead.

Choosing the right canvas type for your ACL model

Access for a channel canvas is tied to channel membership — canvases.access.set returns canvas_not_found when you try to change access on one. If a document belongs to a single channel, conversations.canvases.create is the simpler path.

If you need one document shared across many channels or with explicit user-level permissions, create a standalone canvas on a paid plan and fan out sharing from there.

Can a Canvas Be Shared in More Than 1,000 Channels?

On Enterprise Grid, each canvas can be shared in up to 1,000 channels. This limit appears in Enterprise Grid admin documentation but does not appear in the canvases.access.set API reference. It applies to both manual sharing and automated sharing through workflows.

Important caveat: This limit has not been confirmed in Slack's primary API reference documentation. Treat it as operational guidance to validate against your own tenant before building hard guardrails around it. The limit is consistent with Slack's general channel-fan-out architecture on Grid, but the exact value may differ across org configurations or change without appearing in the API changelog.

For most migrations this is not the binding constraint — few documents need distribution to 1,000+ channels. But if you are migrating company-wide templates or policy documents, you may hit it. The workaround is to set org-wide default access via canvas sharing settings rather than per-channel sharing.

Why Does canvases.create Fail on Free Plans?

Standalone canvases — canvases not attached to a channel — are only available on paid Slack plans (Pro, Business+, Enterprise Grid). Slack's help center states this directly: channel and DM canvases are available on all plans, while standalone canvases require a paid plan. The API returns free_teams_cannot_create_standalone_canvases on free workspaces.

For a migration into a paid workspace, this is a non-issue. But if you are building a migration tool that targets multiple customer workspaces, your code must detect the plan tier before choosing between canvases.create (standalone) and conversations.canvases.create (channel-attached). Call team.info and check the plan field before your first create attempt.

Warning

Channel canvases are being converted to tab canvases. As of April 2025, Slack began converting existing channel and DM canvases into canvases surfaced as channel tabs, as part of a broader canvas UI consolidation. The conversations.canvases.create method continues to work but returns channel_canvas_already_exists if the channel already has one. If your migration targets channels that already have canvases, call conversations.info first and check channel.properties.canvas to retrieve the existing canvas ID before attempting creation.

How Do You List All Canvases Without canvases.list?

There is no canvases.list method. The Canvas API surface has canvases.create, canvases.edit, canvases.delete, canvases.getContent, canvases.access.set, canvases.access.delete, and canvases.sections.lookup — but no list or search endpoint. Slack's documentation points to the workaround: call files.list with types=canvas.

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

files.list is a Tier 3 method (50+ requests/min) and paginates with count and page parameters, defaulting to 100 files per page. A workspace with 5,000 canvases requires 50 paginated calls to enumerate all of them — at Tier 3, that is approximately 60 seconds of serial pagination.

This matters for reconciliation — verifying that every source document was successfully migrated. After a migration run, page through files.list?types=canvas, build a set of canvas IDs, and diff against your source manifest. Any source document missing from the set was either skipped, failed, or created under a different title.

The workaround has a cost: files.list returns all canvases across the workspace — including user-created drafts and channel canvases unrelated to your migration — so relying on it for exact counts is unreliable. Maintain a strict mapping of source_document_id to slack_file_id in your migration database, and inject a unique identifier into the canvas title if you need to filter results.

For Enterprise Grid orgs with files spread across workspaces, pass the team_id parameter to scope the listing to each workspace individually. With an org-level token, omitting team_id returns canvases from all workspaces, which is rarely what you want during a targeted migration run.

Using canvases.getContent for verification

canvases.getContent retrieves the full canvas body as a single markdown string — no pagination, no cursor. It is a Tier 3 method (50+ requests/min). The response returns the canvas content in the same markdown format accepted by canvases.edit, which makes it suitable for content hash verification: serialize the content, hash it, and compare against a hash computed from your source document after applying the same markdown transformation.

canvases.getContent does not impose a separate response size limit — it returns whatever the canvas contains, up to the 1 MiB write limit. However, very large responses (canvases near the 1 MiB ceiling) can cause HTTP timeout issues in some SDK versions. Set an explicit request timeout of at least 30 seconds for getContent calls on large canvases.

Using canvases.sections.lookup for targeted operations

canvases.sections.lookup (scope: canvases:read) allows you to find specific sections within a canvas by searching for contained text or by section type. In the context of bulk migrations, it is most useful for post-migration patching: if you need to update a specific section across many already-created canvases — for example, replacing a placeholder link with the final destination URL after a second-pass backfill — canvases.sections.lookup returns the section ID needed to target an insert_after or replace operation in a subsequent canvases.edit call. Without it, updating a specific section requires replacing the entire canvas body.

Rate Limits and Throughput Math

Slack rate limits are evaluated per method, per workspace, per app. The canvas-relevant tiers:

Method Tier Guaranteed minimum
canvases.create Tier 2 20 req/min
conversations.canvases.create Tier 2 20 req/min
canvases.edit Tier 3 50 req/min
canvases.access.set Tier 3 50 req/min
canvases.getContent Tier 3 50 req/min
canvases.sections.lookup Tier 3 50 req/min
files.list Tier 3 50 req/min

The create methods are the bottleneck. At Tier 2, you can create roughly 20 canvases per minute per workspace. A 2,000-document migration takes approximately 100 minutes of create calls alone — before any content edits or sharing. If each canvas also needs content written via canvases.edit and permissions set via canvases.access.set, total wall-clock time extends to several hours.

The "20+ per minute" language means Slack guarantees at least 20 and tolerates occasional bursts above that. Do not plan around burst capacity. Design for the documented minimum.

Handling ratelimited responses: When any Canvas API method returns HTTP 429, the response includes a Retry-After header specifying the number of seconds to wait before retrying. Typical values observed in practice are 1–60 seconds, with Tier 2 methods (creates) producing longer back-off windows than Tier 3 methods under sustained load. Always read and honor the Retry-After value rather than using a fixed sleep interval — Slack adjusts the window dynamically based on recent traffic from your app.

Recommended backoff strategy:

  1. On first ratelimited, wait exactly Retry-After seconds.
  2. On second consecutive ratelimited for the same method, wait Retry-After × 1.5.
  3. After three consecutive failures on the same batch item, log and defer to a retry queue — do not block the entire pipeline.

Before starting a run, estimate total API calls: creates + edits + sharing batches + reconciliation pages. Divide by rate limits to get expected wall-clock time. A 2,000-canvas migration with one edit per canvas and average 3 sharing batches per canvas (60 channel targets ÷ 20) produces: 2,000 creates ÷ 20 rpm = 100 min, 2,000 edits ÷ 50 rpm = 40 min, 6,000 sharing calls ÷ 50 rpm = 120 min, plus reconciliation pages. Minimum wall-clock time: ~4.5 hours under ideal conditions.

Idempotency and Checkpointing

Preventing duplicate canvases

canvases.create has no built-in idempotency key. If your script creates a canvas, the network drops the response, and you retry, you get a second canvas with the same title and content. Slack does not deduplicate by title.

The fix is a local idempotency map:

  1. Before creating, check your local store (database, JSON file, Redis) for a mapping of source_document_id → canvas_id.
  2. If the mapping exists, skip creation and proceed to edit or share.
  3. If not, create the canvas, capture the returned canvas_id, and write the mapping immediately.
  4. If the create call fails with an ambiguous error (timeout, 500), query files.list?types=canvas filtered by your bot user (user parameter) and a narrow ts_from/ts_to timestamp window to check whether the canvas was actually created before retrying.

For conversations.canvases.create, Slack provides a natural idempotency guard: calling it on a channel that already has a canvas returns channel_canvas_already_exists, and you can find the existing canvas ID in channel.properties.canvas from conversations.info. This makes channel canvases inherently safer to retry than standalone canvases.

Danger

Do not blindly retry canvases.create after internal_error or fatal_error. Slack documents that some aspect of the operation may have succeeded before the error was raised. Reconcile first using files.list, then resume from your manifest. Always honor Retry-After on ratelimited responses before queuing a retry.

Checkpointing long runs

A multi-thousand-canvas migration takes hours. Network errors, rate-limit pauses, and transient Slack outages are expected. Without checkpointing, a failure at canvas #1,847 means restarting from scratch — or duplicating 1,847 canvases.

Each phase — create, edit, share channels, share users — must be checkpointed separately. On restart, the script reads the checkpoint, skips completed items, and resumes pending ones.

{
  "source_id": "quip_abc123",
  "status": "shared",
  "canvas_id": "F07XXXXXX",
  "sharing_completed": ["C001", "C002"],
  "sharing_pending": ["C003"],
  "error": null
}

Because canvases.edit supports only one operation per call, even writing a single canvas can require multiple API calls — for example: one replace for the body, one insert_at_end for an index block, one additional replace in a second pass to inject backfilled links. Each of those operations needs its own checkpoint entry — they are separate calls with separate failure modes, not a single atomic patch.

This is the same resilience pattern described in our migration checklist.

Pre-Flight Checks That Save Your Migration

Pre-flight measurement catches problems before you burn rate-limit budget on doomed API calls. Run these checks against your full source document set before making any API calls:

  1. Verify OAuth scopes. Call auth.test and confirm canvases:read, canvases:write, and files:read are present. On Enterprise Grid, confirm whether you are using a workspace-scoped or org-level token and whether your canvas targets fall within that token's scope.
  2. Size every document. Serialize to Slack markdown, measure UTF-8 byte length. Flag anything over 900 KiB. Identify and replace all base64-encoded inline images with files.upload_v2 permalinks before measuring final payload size.
  3. Count every table's cells. Rows × columns. Flag anything over 300. Apply chunking or CSV-attachment strategy before the migration run.
  4. Count sharing targets per canvas. If any canvas needs more than 1,000 channels, flag it for org-level sharing instead of per-channel batching.
  5. Verify plan tier. Call team.info and check the plan field. Confirm the target workspace is on a paid plan before attempting standalone canvas creation.
  6. Choose the create method. Channel canvas (conversations.canvases.create) vs. standalone (canvases.create), based on the document's ACL requirements and whether the channel already has a canvas.
  7. Estimate total API calls and wall-clock time. Sum creates + edits + sharing batches + reconciliation pages. Divide by rate tier minimums to get a lower-bound time estimate. Add a 30% buffer for rate-limit pauses.
  8. Validate Enterprise Grid token scope. If migrating across multiple Grid workspaces, confirm you have either an org-level token or one workspace-scoped token per target workspace.

Turn every Slack limit into a deterministic pre-flight rule. Discover limits through pure functions against your source data, not through failed API writes.

Migrations Above a Certain Scale Require Specific Engineering Components

Migrations under 50 documents with simple text content are manageable with a direct scripting approach. Once you cross into hundreds or thousands of documents — with tables, images, complex sharing permissions, and Enterprise Grid scoping — the engineering required includes:

  • AST-aware document splitter that respects heading boundaries and table integrity
  • Image extraction and re-upload pipeline using files.upload_v2 before canvas creation
  • Pre-flight validation suite covering all eight checks listed above
  • Batched sharing coordinator with per-batch success tracking and error-code-aware retry
  • Idempotency store mapping source document IDs to canvas IDs with per-phase status
  • Checkpoint-resumable runner that can restart mid-migration without duplication
  • Reconciliation pass using files.list diff and canvases.getContent hash verification

Each of these components addresses a specific API limit or failure mode documented above. Omitting any one of them introduces a failure class that only manifests at scale.

Frequently Asked Questions

What is the maximum size of a Slack canvas document_content?
Each document_content object in canvases.create or canvases.edit accepts at most 1 MiB (1,048,576 characters) of markdown. Documents exceeding this must be split across multiple canvases at heading boundaries.
How many cells can a Slack canvas table have?
A canvas table supports a maximum of 300 cells in any combination of rows and columns. A 10-column table gets 30 rows; a 3-column table gets 100 rows. Tables exceeding 300 cells must be chunked, converted to CSV, or routed to a different destination.
Is there a canvases.list API method in Slack?
No. Slack does not provide a canvases.list method. To enumerate canvases programmatically, use files.list with types=canvas, as Slack's own documentation recommends.
Can you create standalone canvases on Slack's free plan?
No. Standalone canvases via canvases.create require a paid Slack plan (Pro, Business+, or Enterprise Grid). Free plans can only create channel-attached canvases using conversations.canvases.create.
How many channels can a single Slack canvas be shared with?
On Enterprise Grid, a canvas can be shared in up to 1,000 channels according to admin documentation. The canvases.access.set API also limits each call to 20 channel_ids or 20 user_ids (not both), requiring batched sharing for wider distribution.

More from our Blog