How to Import Data into Slack Canvas: API Limits & Guide
Slack Canvas has no import button. Here's the exact API sequence, Markdown constraints, and operational patterns for migrating documents into Canvas at scale.
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
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, 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.
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.
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)
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)
| 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 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.
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)
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)
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)
What Markdown does Slack Canvas actually accept?
Slack canvases accept a proprietary subset of Markdown — 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)
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:
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.
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)
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)
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).
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 NoneImportant 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_textis a substring match, not an exact match — anchor to unique strings to avoid ambiguous resultssection_typesavailable 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:
- Get an upload URL by calling
files.getUploadURLExternalwith the filename and file size - POST the file bytes to the returned upload URL
- Complete the upload by calling
files.completeUploadExternal— Slack discards the upload if you skip this step - Retrieve the permalink from the upload response or via
files.info - Reference the permalink in your Markdown:
! [diagram](https://your-workspace.slack.com/files/...)
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"]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)
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)
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 contentinsert_at_end— append contentinsert_after— insert after a specific section (requiressection_id)insert_before— insert before a specific section (requiressection_id)replace— replace a specific section (requiressection_id), or replace the entire canvas ifsection_idis omitteddelete— remove a specific section (requiressection_id)
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)
For a fresh import, the cleanest pattern is:
- Create the canvas with the first chunk of content (up to 1 MiB) via
canvases.createorconversations.canvases.create - If the document exceeds 1 MiB, split at logical boundaries (heading breaks, paragraph boundaries) and append each chunk via
canvases.editwithinsert_at_end
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_idcanvases.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:
{
"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)
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_idsper call — batch into groups of 20 for larger audiences - Maximum 20
user_idsper call — same batching requirement - Cannot pass both
channel_idsanduser_idsin the same call — they are mutually exclusive parameters - Access levels:
read(view only),write(view + edit),owner(transfer ownership —user_idsonly, 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)
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)
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, 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:
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 NoneRecord 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:
- Checkpoint-based dedup: Before creation, check your checkpoint table for the source document ID
- Channel canvas recovery: On re-runs, recover the existing channel canvas ID from
channel.properties.canvasviaconversations.info— this works even if your local checkpoint state is lost - Title-based matching as fallback: Query
files.list?types=canvasand match by title — but titles are not unique and are mutable viacanvases.edit, so treat this as a fallback, not a primary key - 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:
GET https://slack.com/api/files.list?types=canvas&count=100&page=1This 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_hashdoes 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 |
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:
- Pre-flight: Check your checkpoint table — skip if this source document was already migrated. Check the
stagecolumn to resume mid-document if the previous run failed partway through. - 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.
- Upload images: For each embedded image, upload via
files.getUploadURLExternal→ POST bytes →files.completeUploadExternal, then retrieve the permalink. Recordsource_asset_id → Slack file_id → permalinkin your asset manifest. - Substitute image URLs: Replace source image references with Slack permalinks in the Markdown.
- Split if needed: If Markdown exceeds 1 MiB when encoded as UTF-8, chunk at heading boundaries.
- Create the canvas: Call
canvases.create(standalone) orconversations.canvases.create(channel) with the first chunk. - Record checkpoint: Persist
source_id → canvas_idwithstage = 'canvas_created'immediately. - Append overflow: If there are additional chunks, call
canvases.editwithinsert_at_endfor each. Update stage to'body_written'when complete. - Grant access: For standalone canvases, call
canvases.access.setwith batches of up to 20 IDs per call. Update stage to'access_granted'. - Verify: Call
canvases.getContentto 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.