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 to Notion Migration: API Limits & Data Mapping

Migrating Slack Canvas to Notion requires inventing hierarchy Canvas never had, re-hosting images, and navigating strict API limits on both sides.

Abdul Wahab Abdul Wahab · · 18 min read
Slack Canvas to Notion Migration: API Limits & Data Mapping
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 to Notion Migration: API Limits & Data Mapping

Migrating Slack Canvases to Notion is a restructuring project, not a copy. There is no export button, no native importer, and no structural overlap between the two platforms. Slack Canvas is flat — no folders, no hierarchy, no relational model. Notion is a block-based tree of nested pages and databases. As we cover in our Slack Canvas export guide, getting content out of Slack requires workarounds for missing API methods, and getting it into Notion means inventing the page structure that Canvas never had.

Notion's import menu supports Markdown and HTML files, but Slack Canvas is not a listed import source. Notion's Slack connection is not a Canvas importer — it lets Notion AI reference Slack conversations, not migrate Canvas content. On Slack's side, the documented listing path is files.list?types=canvas, and each body comes from canvases.getContent, which returns the full canvas as markdown by default or HTML if requested.

This guide covers the exact API constraints on both sides, the structural gaps you need to bridge manually, the per-table decisions that determine whether your migration produces something useful, and the architectural decisions required to build a reliable import pipeline.

How to list all Slack Canvases (there is no canvases.list)

There is no canvases.list method in the Slack API. The available canvas methods include canvases.create, canvases.edit, canvases.delete, canvases.sections.lookup, canvases.getContent, and canvases.access.*. None of them return an inventory of existing canvases.

To discover canvases programmatically, use the files.list endpoint with the types parameter set to canvas. Slack's own documentation states: "To programmatically look up a list of canvases, use the files.list method while filtering for the canvas type."

curl -s "https://slack.com/api/files.list?types=canvas&count=100" \
  -H "Authorization: Bearer xoxb-your-bot-token"

This returns file objects, not canvas-specific objects. Each result includes a file ID (starting with F), a title, the channel it was shared in, and the user who created it. Paginate through the full result set — files.list returns results in reverse chronological order with standard cursor pagination.

Coverage gap: cross-reference your canvas inventory against your channel list. After completing files.list pagination, pull conversations.list with your bot token and compare the two lists. Any channel where the bot has no membership will produce a silent gap — canvases from those channels will not appear in files.list, and your migration will look complete when it is not. The remediation is to invite the bot to all channels before starting the inventory pass, or to flag unjoined channels explicitly in your manifest so the gap is visible.

Warning

Bot tokens only see canvases in channels the bot has joined. A standard Slack bot token (xoxb-) with the files:read scope will only return canvases from channels where the bot is a member. To export private canvases attached to direct messages or user profiles, your script must authenticate using user tokens (xoxp-) with broader scope, which requires per-user OAuth and organization-wide administrative approval. Plan your access audit before you start the inventory.

Once you have the canvas IDs, use canvases.getContent to retrieve the body of each canvas. Your app needs the canvases:read scope for this call. The method returns the entire canvas as a single string — markdown by default, or HTML if requested — with no pagination. The rate limit on canvases.getContent is Tier 3, documented by Slack as a floor of 50+ requests per minute (Slack reserves the right to enforce lower limits under load, so treat 50 RPM as a minimum guarantee, not a ceiling). Pulling a few hundred canvases typically takes minutes, not hours.

canvases.sections.lookup and structural hints. The canvases.sections.lookup method lets you query sections within a canvas by element type (e.g., headings). This does not produce a navigable hierarchy, but it can help you identify canvases that function as indexes or tables of contents — useful input for your hierarchy-planning pass before import.

For a very small batch (five or ten canvases), you can export that markdown to .md files and use Notion's standard file import. What this manual path cannot do is infer a Notion page tree, split long canvases into subpages, re-host images, or reconstruct comments. That is why manual import works for a handful of canvases and fails at scale.

What format does the canvas body come back in?

Slack Canvas content exports as a supported markdown subset, not standard CommonMark. The canvases.getContent method returns a document_content object with type: "markdown". Slack's documentation describes this as the same format accepted by canvases.create and canvases.edit (the primary methods for importing data into Slack Canvas) — a bounded subset rather than a full document model. Supported elements include headings (h1 through h3), bold, italic, strikethrough, code blocks, bulleted and ordered lists, checklists, blockquotes, dividers, links, tables, and @mentions for users and channels.

What it does not include:

  • Headings below h3. There is no h4, h5, or h6.
  • Formulas or computed values. Canvas tables are static grids — no formula engine exists.
  • Embedded file content. Images and files appear as markdown image references (! [](url)), pointing to Slack-hosted URLs that require authentication.
  • Comments. These are not part of the canvas body at all.

The markdown content is capped at 1 MiB (1,048,576 characters) per canvas. Most operational canvases are well under this limit.

Encoding and Unicode considerations. Canvas markdown is UTF-8 encoded. Emoji, right-to-left text (Arabic, Hebrew), and mixed-script content will pass through correctly at the Slack read step but may cause issues downstream depending on which markdown parser and Notion API client you use. Test your parser against a canvas that contains emoji and non-Latin characters before running production volume.

HTML output can help with certain rendering edge cases, but it does not solve hierarchy, comments, or table semantics by itself. Start with markdown for the conversion pipeline.

Why canvas comments cannot be migrated with positional context

Slack Canvas comments are not stored inside the canvas. They surface as threaded channel messages. When someone comments on a canvas, Slack creates a thread in the channel where the canvas was shared. The comment text lives in that thread, not in the canvas data model. Slack's help documentation confirms this: "Comments on a canvas behave like messages in threads."

The Slack API provides no method that anchors a comment to a specific position within a canvas. You can retrieve threads via conversations.replies, and you can see that a thread is associated with a canvas file, but there is no coordinate system — no section ID, no character offset, no block reference — that ties the comment back to the paragraph or sentence it was placed on.

This means:

  • You can export the comment text by pulling threads from channels where canvases were shared.
  • You cannot reconstruct where in the document the comment was placed. The positional context is permanently lost at the API level.
  • The best you can do is append comments as a footnote section at the bottom of the corresponding Notion page, or as page-level comments in Notion. Either way, the inline anchoring is gone.

For most teams, dropping comments entirely is the pragmatic choice — out-of-context comments are rarely useful. But if your team relies heavily on inline canvas comments for decisions or approvals (e.g., "approved this section" or "change this number"), resolve and merge those comments into the body text before migration. Once they leave Slack, the context of where they were attached is unrecoverable.

Canvas body text that links to other canvases will break on import. Slack canvas-to-canvas links use the scheme slack://canvas/FXXXXXXXX or workspace-specific deep link URLs. When this content is migrated to Notion, these links become dead — Notion has no mechanism to resolve Slack deep links, and the destination canvas may not have been migrated yet or at all.

This is a silent data loss failure. The text will import correctly, the link will appear formatted as a hyperlink, and it will return an error when clicked.

Remediation options:

  1. Pre-process the canvas markdown before import: scan for slack://canvas/ patterns, check whether the target canvas ID exists in your migration manifest, and replace the link with the destination Notion page URL if available.
  2. Replace with placeholder text ([Canvas link — see [Page Name] in Notion]) for canvases not yet migrated or not in scope.
  3. Run a post-import link audit using Notion's search API to find pages containing slack:// and flag them for manual resolution.

Build this check into your conversion step — it is much harder to fix after 200 pages are in production.

Canvas is flat: inventing hierarchy for Notion's page tree

Slack Canvas has no folder concept. A canvas is either a standalone document or attached to a channel. There are no nested canvases, no directories, and no parent-child relationships between canvases. Every canvas exists at the same level.

Notion is built around a deeply nested page tree. Pages live inside other pages. Databases live inside pages. The organizational hierarchy is the navigation. Dropping 200 flat canvases into a single Notion workspace without structure produces an unusable mess.

Notion nesting depth. Notion enforces a practical nesting limit: pages can be nested up to approximately 10 levels deep in the sidebar, but the API will accept deeper structures. At very deep nesting, sidebar navigation becomes unreliable and search relevance degrades. For large migrations, plan your hierarchy to stay within 4–5 levels of nesting. A channel → team → project → document structure (4 levels) is both navigable and well within platform limits.

Someone has to invent the hierarchy, and there is no automated way to do it correctly. The most common approaches:

  • Group by channel name. Create a top-level Notion page for each Slack channel, and nest that channel's canvases as sub-pages. This works when channels map to teams or projects, but produces odd groupings when a canvas was shared across multiple channels.
  • Group by topic or function. Manually categorize canvases into buckets (engineering, ops, product, onboarding) and build a Notion page tree from that taxonomy. Better navigation, but requires human judgment.
  • Treat index canvases as hubs. If a canvas mostly links to other canvases, it is an index page — promote it as a parent and nest its linked canvases underneath. Use canvases.sections.lookup to identify heading-heavy canvases that function as tables of contents.
  • Build a manifest first. Before importing, create a spreadsheet or Notion database that maps every canvas ID to its intended parent page, owner, and status (migrate, archive, skip). Use this as the manifest for your import script.
Tip

Start with a triage pass, not an import script. The biggest time sink in this migration is not the API work — it is deciding where each canvas should live in Notion. Budget 2–4 hours of editorial work per 100 canvases for a team lead or knowledge manager to build the target hierarchy. Put ambiguous or orphan canvases in a review queue instead of auto-placing them.

For channel-based routing in your migration script:

  1. Read the channels array in the Slack files.info response.
  2. Look up the channel name via conversations.info.
  3. Create a parent page in Notion named after the Slack channel.
  4. Set that parent page's ID as the target for the canvas migration.

Why canvas markdown makes poor Notion page outlines

Slack Canvas markdown stops at h3. Most operational canvases use one or two heading levels at most — a title (h1) and a few sections (h2). This produces a flat, shallow outline when imported into Notion.

Notion supports headings through h3 (heading_1, heading_2, heading_3 block types), so there is technical parity on heading depth. The problem is not a mismatch in heading levels — it is that canvas documents tend to be short and flat, while Notion pages benefit from deeper structure.

A canvas that was a single-page checklist in Slack often needs to be broken into multiple Notion pages, or restructured with toggles and callout blocks to create a scannable hierarchy. This is editorial work, not something a migration script can automate.

Practical guidance:

  • Short canvases (under 500 words): Import as-is. The shallow outline is fine for a brief doc.
  • Long canvases (1,000+ words with only h1/h2): Consider splitting into multiple Notion sub-pages, or manually adding h3 sections post-import.
  • Canvases with checklists or multi-section runbooks: Convert h2 sections into separate Notion pages nested under a parent, so each section gets its own page in the sidebar.

Should canvas tables become Notion table blocks or databases?

Slack Canvas tables are capped at 300 cells per table, with no formulas (a constraint we also highlight in our Notion to Slack Canvas migration guide). A 10-column table can have at most 30 rows. The cells support plain text, bold, italic, links, mentions, and checkboxes — but no computed values, no sorting, no filtering.

When importing into Notion, each table presents a choice between targets. Make this decision per table, not globally. A canvas table listing team contact info is fine as a simple table block. A canvas table tracking project status with dates and owners should become a Notion database so the team can filter and sort it.

Canvas table characteristic Recommended Notion target Why
Static reference data (e.g., server IPs, glossary) Table block No need for views or filtering
Tracking data with dates, owners, status Database Filtering, sorting, and views add value
Small table (< 20 rows) with no future updates Table block Overhead of a database is not justified
Table the team will continue editing Database Databases support collaboration features
Cells contain long prose or mixed lists Plain page sections Loses the table shape but often reads better

Notion table blocks are plain page content: a table parent with table_row children made of rich-text cells. table_width is fixed at create time, and a new table must be created with at least one row whose cell count matches that width. Databases are different: rows are pages with schema-conformant properties, and Notion's database model supports views, filtering, sorting, and relations.

For tables that become databases, you need to decide on property types (text, select, date, person, URL) during the mapping phase. Canvas cells are untyped text — this mapping cannot be inferred automatically.

A useful per-table decision checklist: Will people filter or group this later? Does each row have a stable identity? Are status, owner, or date columns real fields or just display text? Will this table keep growing after cutover? If the answer is mostly no, stay with a table block. If mostly yes, build a database.

If you script every Markdown table to become a Notion database, you will pollute the workspace with hundreds of useless, unlinked databases that were only meant to be simple visual grids. Conversely, if you force everything into simple table blocks, you lose the ability to add properties, tags, and filters later.

Re-hosting Slack images: why hotlinking fails

Slack-hosted image URLs require authentication and will not render in Notion. When you export a canvas, any embedded images point to Slack's CDN (files.slack.com or a workspace-specific subdomain). These URLs use url_private and url_private_download fields that require a bearer token. Pasting Slack image URLs into Notion image blocks will produce broken images for anyone not authenticated to your Slack workspace.

Notion's API handles images in two ways:

  • external: You provide an HTTPS URL, and Notion displays the image from that URL. The URL must be publicly reachable and SSL-enabled — Slack-authenticated URLs do not qualify.
  • file: Notion hosts the file. You upload via the File Upload API, which gives you a file_upload object. Direct upload supports files up to 20 MB.

Notion-hosted file URLs are temporary signed S3 links. The expiry window is 1 hour from the time you fetch the URL via the API (the response includes an expiry_time field in ISO 8601 format). Once a file is attached to a block, it becomes a permanent workspace asset — but any URL you retrieve through the API later is only valid for 60 minutes from the time of that specific API response, not from the time of original upload. Unattached uploads are archived after 1 hour.

The correct migration approach:

  1. Download each image from Slack using an authenticated request with your bot or user token.
  2. Upload to Notion via the File Upload API, which gives you a file_upload object.
  3. Attach the uploaded file to an image block within 1 hour of upload.
  4. Alternatively, re-host on your own CDN (AWS S3, Cloudflare R2, etc.) and use Notion's external file type, which accepts any HTTPS URL and never expires.

CDN re-hosting cost estimate. For a migration of 500 images averaging 500 KB each, total storage is roughly 250 MB. At AWS S3 standard pricing (~$0.023/GB/month) this costs under $0.01/month ongoing, with egress costs negligible for a one-time migration load. Cloudflare R2 eliminates egress fees entirely at $0.015/GB/month storage. External CDN hosting is both cheaper and simpler than managing the Notion upload expiry window at scale.

Warning

Do not batch-download all images and then batch-upload later. Notion's upload-then-attach window is 1 hour from upload time. If your migration script downloads 500 images first and then starts uploading, the earliest uploads may expire before they are attached. Process images inline: download from Slack, upload to Notion (or your CDN), attach to block, then move to the next canvas.

Option 4 (external hosting) is often simpler for large migrations. Upload images to your own bucket, generate permanent public URLs, and reference them as external file objects in Notion. This avoids the 1-hour expiry race entirely.

For more on handling media constraints across platforms, see How to Migrate Images, Attachments & Embeds Without Broken Links.

Notion API constraints that shape the import pipeline

The Notion API imposes several hard limits that directly affect how you build the import script. The bottleneck is usually Notion writes, not Slack reads.

Rate limiting

Notion enforces rate limits per integration token. Business and Enterprise workspaces get 600 requests per minute (average of 10 requests per second); other plans get 180 requests per minute (average of 3 per second). Notion also enforces a separate shared per-workspace budget, so a migration can be throttled even when one connection is below its own limit. Reads and writes share the same budget. (developers.notion.com)

Use 3 requests per second as the safe cross-plan default. Sustained throughput above your plan's limit triggers HTTP 429 responses with a Retry-After header. Your pipeline must include exponential backoff that reads and honors this header value — do not use a fixed retry interval.

For a migration of 200 canvases, each requiring a page creation call plus sequential block-append calls, expect the import phase to take 15–30 minutes at sustained throughput with backoff.

Block append limit: 100 blocks per request

The blocks.children.append endpoint accepts a maximum of 100 block children per request. A long canvas that converts to 250 Notion blocks requires at least three sequential append calls (100 + 100 + 50). These must be sequential — Notion appends blocks in order, and parallel writes to the same page produce unpredictable block ordering.

The overall limits are 1,000 blocks per page creation request and 500 KB per request body. (developers.notion.com)

Rich text limit: 2,000 characters per element

Each rich text object in the Notion API has a hard limit of 2,000 characters in the text.content field. A single long paragraph from a canvas that exceeds this must be split into multiple rich text objects within the same block, or broken into separate blocks at the nearest sentence boundary. If you do not handle this in your conversion logic, the API will return a 400 validation error. (developers.notion.com)

Two levels of nesting per request

Notion allows up to two levels of nested children in a single API request. If a canvas has a bulleted list inside a toggle inside another toggle, you will need to create the outer toggle first, then append children in a follow-up request.

The 503 failure mode

A Notion write can return HTTP 503 even when the change was actually saved. Notion's docs explicitly state to read retry_guidance in the response body before replaying a write. The response structure when this occurs is:

{
  "object": "error",
  "status": 503,
  "code": "service_unavailable",
  "message": "...",
  "retry_guidance": "do_not_retry"
}

When retry_guidance is "do_not_retry", the operation completed — retrying will duplicate blocks. When retry_guidance is absent or "retry", the operation did not complete and can be retried safely. Block-append responses on success include the committed child block IDs in the response body; persist these to your durable state store so you can verify what was written. Your importer needs idempotency keys, durable state, and post-write verification instead of blind retries. (developers.notion.com)

Step-by-step migration pipeline

Step 1: Inventory and triage

Pull all canvas IDs using files.list?types=canvas, paginating through the full result set. For each canvas, record the file ID, title, channel(s) it was shared in, creator, creation date, and whether the document has comments, embedded files, or canvas deep links that matter. After pagination completes, cross-reference against conversations.list to identify channels the bot could not access — flag these as coverage gaps in your manifest, not silent omissions.

Export this inventory to a spreadsheet or staging database. Have a team lead assign each canvas a target location in the Notion page tree, or mark it as "skip" or "archive." This editorial pass is the bottleneck — if the source inventory is wrong, every downstream decision is wrong.

Step 2: Design the Notion page tree

Create the parent pages first. Use channel names, team boundaries, and index canvases to decide where content belongs. Plan your hierarchy to stay within 4–5 nesting levels to preserve sidebar navigability. Anything ambiguous goes into a review queue, not straight into production.

Step 3: Export canvas content

For each canvas ID, call canvases.getContent to retrieve the markdown body. Store the raw markdown alongside the file metadata. Use canvases.sections.lookup to identify structurally significant canvases (index pages, heavily-sectioned documents) that may need special handling.

If you need comments, pull threaded replies from the channels where each canvas was shared using conversations.replies. Treat body, comments, and media as separate datasets with different fidelity limits — do not pretend one source object contains all the migration data.

Step 4: Convert markdown to Notion blocks

Parse the canvas markdown through an Abstract Syntax Tree (AST) parser — do not use regex. For Node.js, use remark with remark-parse; this produces a MDAST tree you can traverse to generate Notion block objects. For Python, mistletoe or markdown-it-py are solid alternatives. An AST parser correctly handles nested lists (which Notion requires as children of the parent list item block) and separates table nodes from paragraph nodes without edge-case failures.

During the AST traversal, also scan for slack://canvas/ deep link patterns in link nodes. Replace with the target Notion URL from your manifest where available, or with a clearly labeled placeholder for canvases not in scope.

The block type mapping:

  • # Heading → heading_1 block
  • ## Heading → heading_2 block
  • ### Heading → heading_3 block
  • Paragraphs → paragraph block (enforce 2,000-character splits at sentence boundaries)
  • Bulleted lists → bulleted_list_item blocks
  • Numbered lists → numbered_list_item blocks
  • Checklists → to_do blocks
  • Code blocks → code block
  • Blockquotes → quote block
  • Dividers → divider block
  • Images → image block (after re-hosting; do not pass Slack-authenticated URLs)
  • Tables → table block or database (per-table decision from your manifest)

For tables that become databases, create the database with typed properties first, then insert rows as pages.

Step 5: Create pages and append blocks

For each canvas, create a Notion page under the assigned parent. Include the first batch of up to 100 blocks in the page creation request. Append remaining blocks in sequential batches of 100 via blocks.children.append.

Throttle to 3 requests per second (conservative cross-plan default). Implement exponential backoff on 429 responses, reading the Retry-After header value. Persist source canvas IDs alongside destination Notion page IDs, and record committed child block IDs from each successful append response for post-write verification.

Step 6: Validate and QA

Spot-check a sample of migrated pages against the original canvases. Verify:

  • All text content is present and correctly formatted
  • Images render (not broken links)
  • Tables have the correct row/column counts
  • Checklists preserve checked/unchecked state
  • Notion page tree matches the planned hierarchy
  • No duplicate blocks from 503-then-retry scenarios
  • Comments are appended where expected
  • Tables that should have become databases were converted correctly
  • No slack://canvas/ links remain unresolved
  • Emoji and non-Latin characters rendered correctly

Do not stop at visual spot checks. The real migration defects are lost comment context, broken canvas deep links, flattened headings, missing media, and tables stuck in the wrong format.

What this migration actually takes

This is a restructuring project. You are not copying a document from one app to another — you are taking flat, lightly structured content from a messaging platform and turning it into an organized knowledge base.

The API mechanics are tractable. The hard part is the editorial work: deciding where things go, which tables deserve to become databases, which canvas links need to be rewired, and which canvases should be merged, split, or archived.

Teams that treat this as a 1:1 copy end up with a Notion workspace that is just as disorganized as their Slack canvases were — but harder to navigate because the channel-based context is gone, and all the canvas deep links are dead.

If you have hundreds of canvases and want to get this done in days instead of weeks, ClonePartner can handle the full pipeline — inventory, triage support, conversion scripting, image re-hosting, dead link resolution, and QA — so your team can focus on the editorial decisions that only they can make.

Frequently Asked Questions

How do I list all Slack Canvases via the API?
There is no canvases.list method. Use files.list with types=canvas to discover all canvases. Bot tokens only return canvases from channels the bot has joined — user tokens with broader scope are needed for DMs and private canvases.
Can I migrate Slack Canvas comments to Notion with their position?
No. Canvas comments are stored as threaded channel messages, not inside the canvas data model. The Slack API provides no section ID, character offset, or block reference to anchor comments to specific text. Positional context is unrecoverable.
Should Slack Canvas tables become Notion table blocks or databases?
Decide per table. Static reference tables (glossaries, contact lists) work as simple Notion table blocks. Tracking data with dates, owners, or status should become databases for filtering, sorting, and views.
Do images need re-hosting when migrating from Slack Canvas to Notion?
Yes. Slack-hosted images require authentication and will not render in Notion. Download images from Slack and either upload via Notion's File Upload API (attach within 1 hour) or re-host on your own CDN and use Notion's external file type.
What Notion API limits matter most for canvas imports?
Rate limits (3 requests/second on free/Plus plans, 10/second on Business/Enterprise), 100-block maximum per append request, 2,000-character rich text cap, and two levels of nesting per request. Long canvases need chunked, sequential imports.

More from our Blog