Confluence to Slack Canvas Migration: The Technical Guide
Migrating Confluence to Slack Canvas means flattening page trees, losing macros, and hitting hard limits. This technical guide covers the architecture mismatch and what survives the move.
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
Confluence to Slack Canvas Migration: The Technical Guide
Migrating Confluence to Slack Canvas is a lossy, structural conversion — not a content copy. Confluence stores pages as a tree of XHTML documents inside spaces, with ancestor hierarchies, labels, templates, content properties, and macro-rich bodies. A Slack Canvas is a single flat markdown document with no child pages, no folder structure, and no macro system. Every migration from Confluence to Canvas requires choosing what to preserve, what to flatten, and what to deliberately discard.
There is no native import path, no Slack-provided migration tool, and no plugin that bridges the two. As with any custom import into Slack Canvas, executing this migration requires building a custom pipeline that reads Atlassian Storage Format (XHTML) via the Confluence REST API, converts it to Slack-compatible markdown, and provisions standalone canvases via the Slack Web API.
This guide covers the architectural mismatch, the structural mapping from spaces to channels, the macro translation problem, heading-level collapse, table limits, inline comment loss, attachment handling, cross-page link remapping, the hard API constraints on both sides, and the failure-handling patterns required to run a migration at scale without losing progress mid-run. If you need to export your Confluence data first, see How to Export All Data from Confluence: Methods, Limits & Tools. For a reference on what happens to Confluence macros when they leave the Atlassian ecosystem, see Confluence Macro Mapping Reference: Migrating Dynamic Content.
Architecture Clash: Confluence Spaces vs. Slack Canvases
Confluence organizes content in Spaces, each containing an arbitrarily deep tree of Pages with parent-child (ancestor) relationships. Pages hold bodies in Confluence Storage Format — an XHTML-based markup that includes custom XML elements in the ac: and ri: namespaces for macros, structured data, resource identifiers, and layouts. Atlassian's own documentation describes the format as "XHTML-based" but notes it is technically XML because it includes custom elements for macros that do not comply with the XHTML specification. (confluence.atlassian.com)
Slack Canvas is a flat markdown document surface built into Slack. A canvas accepts content exclusively through a document_content object with "type": "markdown". The supported elements are headings h1 through h3, bold, italic, strikethrough, bulleted and ordered lists, checklists, code blocks, blockquotes, dividers, inline links, and markdown tables. There is no page hierarchy, no child-canvas concept, no folder system, and no macro runtime. Slack caps the markdown payload at 1 MiB per create or edit operation, and canvases.edit allows only one change per call. (api.slack.com)
Applies to Confluence Cloud only. The Confluence REST API v2 — which this guide uses throughout — is a Cloud-only API. If you are migrating from Confluence Server or Data Center, you must use the v1 API (/wiki/rest/api/content), which has different endpoint paths, different pagination mechanics (offset-based rather than cursor-based), and different rate limit behavior. Every endpoint reference in this guide assumes Cloud.
Block Kit is not supported in canvases. Slack's own documentation states this explicitly. You cannot embed interactive components, app-specific blocks, or structured data payloads into a canvas. Every Confluence macro that you might hope to translate into a Block Kit widget is a dead end.
The data model gap is fundamental. As detailed in our comparison of Slack Canvas and Confluence architectures, Confluence gives you a wiki with computed content (macros that execute at render time), structured metadata (labels, content properties, page restrictions), and deep nesting. Canvas gives you a static markdown document with a size cap. That is the trade.
Required Slack OAuth Scopes
Before writing any migration code, your Slack app must be granted these OAuth scopes:
| Scope | Required for |
|---|---|
canvases:write |
Creating and editing canvases (canvases.create, canvases.edit, conversations.canvases.create) |
canvases:read |
Reading canvas content (canvases.sections.lookup) |
files:write |
Uploading attachments (files.getUploadURLExternal, files.completeUploadExternal) |
files:read |
Reading uploaded file metadata |
channels:read |
Resolving channel IDs for access grants |
groups:read |
Same, for private channels |
Missing scopes produce missing_scope errors that surface only at runtime, after you have already built the pipeline. Configure the Slack app manifest before starting any implementation work.
For Confluence API access, generate an Atlassian API token at id.atlassian.com/manage-profile/security/api-tokens and pass it as a Base64-encoded email:token pair in the Authorization: Basic header on every request. This applies to both page reads and attachment downloads — the attachment download endpoint in particular will silently return 0-byte files or a 403 if you omit the Authorization header.
How Does a Confluence Space Map to Slack Channels?
A Confluence Space maps most naturally to a Slack channel, with the space's page tree flattened into a combination of one channel canvas (the tab canvas) and multiple standalone canvases shared into that channel.
The critical constraint: a Slack channel holds exactly one channel canvas. As explained in our guide on channel vs. standalone canvases, calling conversations.canvases.create when a channel canvas already exists returns channel_canvas_already_exists. A 400-page Confluence space cannot become 400 channel canvases — there is physically one slot. (docs.slack.dev)
The index-canvas pattern
The practical architecture is a hub-and-spoke model:
- One channel canvas per channel acts as the index page — a table of contents linking to standalone canvases. This replaces the Confluence space homepage and page tree, providing a necessary structure for indexing and discovering canvases after migration.
- Standalone canvases hold the actual migrated page content. Each former Confluence page becomes one standalone canvas, created via
canvases.create(Tier 2 rate limit: 20+ calls per minute). A standalone canvas is an independent document in Slack not bound to a specific channel's default canvas slot — it is completely orphaned until you explicitly grant access and link it. - Access grants connect standalone canvases to channels. You use
canvases.access.setto grantreadorwriteaccess. Thechannel_idsparameter accepts an array of up to 20 channel IDs per call, andchannel_idsanduser_idscannot be passed in the same request. (docs.slack.dev) - Share the canvas link in the channel after granting access so members can find it.
For a space with 400 pages, the migration creates 1 channel canvas (the index) + 400 standalone canvases, then grants access to each standalone canvas for the target channel. At Tier 3 rate limits (50+ calls per minute) on canvases.access.set, granting access for 400 canvases takes roughly 8 minutes if you serialize calls. At 20 creates per minute (Tier 2), creating 50,000 canvases takes approximately 42 hours of continuous API runtime before attachment uploads or link remapping.
If a Confluence page was shared across more than 20 channels or user groups, your script must batch the canvases.access.set requests:
for (const batch of chunk(channelIds, 20)) {
await slack.canvases.access.set({
canvas_id,
access_level: 'write',
channel_ids: batch
});
}The 20-channel ceiling is an API limit, not an implementation suggestion.
Paid plan required. Standalone canvases are only available on paid Slack plans. On free Slack, you are limited to one canvas tab per channel — migrating anything beyond a single page is impossible without upgrading.
Mapping nested page trees
Confluence pages nest arbitrarily deep. Canvas has no nesting. Here is how the structures map:
| Confluence structure | Canvas mapping | Trade-off |
|---|---|---|
| Space homepage | Channel canvas (index) | Loses dynamic children macro output |
| Top-level pages | Standalone canvases linked from index | Flat, no hierarchy visible |
| Child pages (nested) | Standalone canvases with breadcrumb links | Manual breadcrumbs; no automatic tree |
| Labels / content properties | None | Lost entirely — no metadata layer in Canvas |
| Page templates | None | Canvas has templates, but no programmatic import path |
| Blog posts | None | Canvas has no blog concept |
You can simulate hierarchy in the index canvas using nested markdown lists of links, but there is no enforced parent-child relationship. Users lose the ability to browse a tree — they search or click a link. Treat Confluence labels, templates, and content properties as metadata that either gets materialized into the body text, stored in your migration database, or deliberately dropped. (developer.atlassian.com)
The Macro Problem: What Has No Canvas Equivalent
Confluence page bodies use Atlassian Storage Format XHTML. Macros are represented as <ac:structured-macro> XML elements with parameters, bodies, and resource identifiers. Canvas has no macro system, no plugin architecture, and no way to execute dynamic content. Block Kit — Slack's structured UI framework — is explicitly unsupported inside canvases. (support.atlassian.com)
Every macro in your Confluence space falls into one of three buckets on the Canvas side: link, static snapshot, or deletion.
| Macro type | Canvas outcome | Notes |
|---|---|---|
Jira Issue macro (jira) |
Link to the Jira issue URL | No live issue data. Slack unfurls the link if the Slack-Jira integration is active, but the inline table view is lost. |
Jira Issues List (jira-issues) |
Link to saved JQL filter in Jira | No embedded table |
| draw.io / Gliffy diagrams | Static PNG/SVG snapshot or link | Export to PNG, upload to Slack, embed. Editing capability is permanently destroyed. |
| Table Filter / Chart macros | Static snapshot or deletion | No dynamic filtering in Canvas |
| Confluence Whiteboards | Link to the whiteboard URL | Separate Atlassian product |
| Confluence Databases | Link to the database or CSV export | No structured data in Canvas |
| Code Block macro | Fenced code block (```) |
Good fidelity; language tags supported |
| Info / Warning / Note panels | Blockquote | Loses colored styling |
| Table of Contents macro | Manual markdown links | No auto-generated TOC |
| Expand / Collapse macro | Flat content (no collapse) | Canvas has no collapsible sections |
| Include Page macro | Inline the content or link | Transclusion does not exist |
| Page Properties / Report | Deletion | No structured property system |
| Children Display macro | Markdown list of links | No dynamic child listing |
| Filter by Label macro | Static index links | Uses CQL in Confluence; Canvas has no CQL interpreter (support.atlassian.com) |
Do not promise macro fidelity. The migration pipeline must aggressively strip <ac:structured-macro> tags. The honest choices for every macro are: convert to a link, freeze as a static snapshot, or delete. Teams must audit their Confluence spaces, tag every macro instance, and decide per-type before writing import code.
Parsing ac:structured-macro elements
The XHTML-to-markdown converter must handle macros explicitly rather than treating them as unknown XML nodes. The ac:name attribute on the <ac:structured-macro> element identifies the macro type. A dispatch-table approach routes each macro type to its handler:
from lxml import etree
MACRO_HANDLERS = {
"code": handle_code_macro,
"jira": handle_jira_link_macro,
"jira-issues": handle_jira_issues_macro,
"info": handle_panel_macro,
"warning": handle_panel_macro,
"note": handle_panel_macro,
"tip": handle_panel_macro,
"expand": handle_expand_macro,
"toc": handle_toc_macro,
"children": handle_children_macro,
"include": handle_include_macro,
"page-properties": handle_deletion,
"filter-by-label": handle_deletion,
}
def convert_macro(element: etree._Element, context: MigrationContext) -> str:
macro_name = element.get("{http://atlassian.com/content}name", "")
handler = MACRO_HANDLERS.get(macro_name, handle_unknown_macro)
return handler(element, context)
def handle_code_macro(element: etree._Element, context: MigrationContext) -> str:
lang_param = element.find(
".//{http://atlassian.com/content}parameter"
"[@{http://atlassian.com/content}name='language']"
)
lang = lang_param.text if lang_param is not None else ""
body = element.find(".//{http://atlassian.com/content}plain-text-body")
code = body.text if body is not None else ""
return f"```{lang}\n{code}\n```"
def handle_panel_macro(element: etree._Element, context: MigrationContext) -> str:
body = element.find(".//{http://atlassian.com/content}rich-text-body")
inner = convert_element(body, context) if body is not None else ""
return f"> {inner}"
def handle_unknown_macro(element: etree._Element, context: MigrationContext) -> str:
macro_name = element.get("{http://atlassian.com/content}name", "unknown")
context.log_unhandled_macro(macro_name)
return f"[Unsupported macro: {macro_name}]"Use lxml rather than Python's built-in xml.etree.ElementTree — lxml handles the mixed content (text nodes interleaved with element nodes) in Confluence storage format more reliably. BeautifulSoup with the lxml-xml parser is a valid alternative for teams less comfortable with XPath.
Character encoding note: Confluence storage format uses named XML entities ( , —, “) and numeric character references ( ). Parse with a full XML parser — do not use regex or string replacement on raw XHTML. A naive regex-based approach will break on entities, CDATA sections, and namespaced attributes, producing corrupted markdown that silently renders incorrectly in Canvas.
How Does Heading-Level Collapse Work?
Confluence Storage Format supports heading levels <h1> through <h6>. Slack Canvas markdown supports h1, h2, and h3 only. Heading levels h4 through h6 have no direct equivalent.
During conversion, you have two options:
- Collapse to h3: Map h4, h5, h6 all to
###. This preserves the visual distinction between body text and sub-headings but flattens three levels into one. - Convert to bold or italic text: Map h4+ to emphasized text on its own line. This is closer to what the original heading looked like at small sizes but loses semantic heading structure entirely, which affects Canvas's own section navigation.
A common convention in practice:
h1 → # (h1) — Major section heading; anchors Canvas navigation
h2 → ## (h2) — Subsection
h3 → ### (h3) — Sub-subsection
h4 → **Bold line** — Visual break without semantic heading; no Canvas nav anchor
h5 → > **Bold** — Indented bold inside a blockquote; signals deeper nesting visually
h6 → *Italic line* — Lowest-level label; used sparingly in practice
The rationale for h5 → > **Bold** rather than plain bold: blockquote indentation provides a visual nesting cue that distinguishes h5 from h4 when both appear on the same page. Without it, h4 and h5 become identical in appearance and editors lose the original document's intent. This is a team convention that must be documented — post-migration editors will not know why some bold lines are indented unless you tell them.
This is a convention, not a standard. For pages that use all six heading levels (deep technical specifications, lengthy regulatory documents), expect the Canvas version to read significantly flatter. There is no workaround that preserves the original hierarchy, because Canvas's heading system has exactly three levels by design.
The 300-Cell Table Ceiling
Slack Canvas tables support standard markdown pipe-and-dash syntax, but each table is hard-capped at 300 cells — any combination of rows and columns whose product does not exceed 300. For example, 30 rows × 10 columns fills the limit exactly. 60 rows × 5 columns also works. (api.slack.com)
Confluence tables have no practical cell limit. A table with 10 columns and 35 rows (350 cells) is standard for engineering release notes or API parameter documentation. If you attempt to push a table exceeding 300 cells into a Slack Canvas via the API, the payload will be rejected or silently truncated.
Your migration script must parse the <table>, <tr>, and <td> tags in the Confluence storage format and calculate the cell count before generating markdown. If (rows × columns) > 300, you have three options:
- Split the table into multiple smaller tables in the canvas.
- Convert to CSV: Extract the table data, generate a CSV file, upload it to Slack via
files.getUploadURLExternal+files.completeUploadExternal, and insert a markdown link in the canvas where the table used to be. - Truncate: Migrate only the first 300 cells and add a note pointing to the full data.
Tables inside canvases support basic formatting within cells (bold, italic, links, checkboxes, mentions), but not nested tables, merged cells, or column spans — all of which Confluence supports via its storage format. Merged cells (<td colspan>, <td rowspan>) must be unmerged before conversion; the simplest approach is to repeat the cell content into each affected cell and flag the result for manual review.
Why Do Inline Comments and Version History Fail to Migrate?
Inline comments
Inline comments cannot be migrated to Canvas. Not partially, not approximately — they cannot be placed programmatically.
Confluence inline comments are anchored to specific text selections within the page body. They are stored with properties including selection metadata such as textSelection, textSelectionMatchCount, textSelectionMatchIndex, and returned markers like inlineMarkerRef and inlineOriginalSelection. These anchors are tied to the specific structure of the XHTML body. (developer.atlassian.com)
The moment you convert the page body from Confluence Storage Format to markdown, every anchor breaks. The text may survive, but the byte offsets, marker references, and selection metadata become meaningless in a different document format. Even within Confluence itself, replacing a page's entire body via the API orphans inline comments — the UI uses a different update mechanism that preserves anchors.
Slack Canvas does support comments natively, but there is no API to create comments at specific text positions. Canvas comments are created through the UI only. The best preservation strategy is to export inline comments from Confluence (via GET /wiki/api/v2/pages/{id}/inline-comments) and append them as a footnote section at the bottom of the converted canvas, with the original selection text quoted for context.
Resolved comments are still accessible via the v1 Confluence API but may return 404 on the v2 endpoint for some pages — test both endpoints during your extraction phase.
Version history
Version history cannot be imported. Confluence tracks every page edit with full diffs and exposes version endpoints for historical states. Slack Canvas has its own version history, but the public API surface is limited to create, edit, getContent, and access management — there is no endpoint to backdate canvas creation or inject historical revisions. Every canvas generated by your migration script shows as "Created today" by the API user or bot. The migrated canvas starts at version 1. (developer.atlassian.com)
If compliance requires maintaining version history, export the Confluence spaces to static PDF or HTML archives and store them in cold storage (AWS S3, Google Drive, or similar) before decommissioning Atlassian. This is the only path for audit or legal traceability.
How Do You Remap Cross-Page Links?
Every internal link needs remapping. Confluence links internal pages using page IDs and <ac:link> elements with ri:content-title and ri:space-key resource identifiers (e.g., /wiki/spaces/ENG/pages/123456789/Architecture). These IDs are specific to the Confluence instance and are not portable. When Page A links to Page B in Confluence, that link will 404 once Confluence is decommissioned.
The remapping process is a two-pass operation — you cannot resolve links on the first pass because the target canvases may not exist yet:
- Pass One (Creation): Create all standalone canvases in Slack. As each canvas is created, log the original
confluence_page_idand the newslack_canvas_idinto a persistent state file. A local SQLite database works well for this. - Pass Two (Remapping): Iterate through every newly created canvas, find all Confluence URL patterns and
<ac:link>references, look up the correspondingslack_canvas_idin the mapping database, and update the markdown link to point to the new Slack Canvas URL. Apply changes viacanvases.edit.
At Tier 2 rate limits (20+ edits per minute), updating 400 canvases takes at minimum 20 minutes. Budget for this second pass in your migration timeline.
Links to pages in other Confluence spaces become plain URLs pointing to Confluence (if it will remain accessible) or dead links (if Confluence is being decommissioned). There is no cross-workspace canvas linking concept in Slack.
How Do You Handle Confluence Attachments?
Confluence has no bulk attachment download endpoint. You cannot download a zip of a space's assets. The v2 REST API provides GET /wiki/api/v2/pages/{id}/attachments which returns attachment metadata (filename, media type, download link) with a default limit of 50 results per page. The actual file download still requires the v1 endpoint — the v2 API does not fully replace v1 for binary downloads. (developer.atlassian.com)
The extraction workflow:
- For each page, call
GET /wiki/api/v2/pages/{pageId}/attachmentsto list all attachments. - Paginate through results (cursor-based).
- For each attachment, download the binary via the v1 download endpoint:
GET /wiki/rest/api/content/{pageId}/child/attachment/{attachmentId}/download. - Upload the file to Slack using
files.getUploadURLExternal+files.completeUploadExternal(Slack has deprecated the legacyfiles.uploadmethod). - Replace the
<ri:attachment>XML tag in the converted markdown with the new Slack file URL.
Attachment upload throughput: Slack's files.getUploadURLExternal is a Tier 3 method (50+ calls per minute). Each attachment requires two API calls (get URL + complete upload) plus one HTTP PUT to the upload URL. For a space with 2,000 attachments, the API call budget alone is 4,000 calls at Tier 3 — roughly 80 minutes of serialized upload time, before accounting for actual transfer time for large files. Confluence Cloud's standard rate limit for attachment downloads is approximately 100 requests per minute. For large migrations, attachment upload is typically the bottleneck, not canvas creation.
For a space with thousands of attachments, each attachment is a separate HTTP round-trip on both sides. Run attachment migration concurrently with canvas creation where possible — they use different API endpoint families and do not compete for the same rate limit bucket.
Authentication for Attachments: Downloading attachments via the Confluence REST API requires passing a valid Authorization header with an Atlassian API token. If you attempt to download attachment URLs without the Authorization header, you will receive 0-byte files or 403 Forbidden errors. This failure mode is silent — the file will exist locally with 0 bytes, and you will only discover the error when you try to open the uploaded Slack file.
Prioritize attachments ruthlessly. Not every Confluence attachment is worth migrating. Old PDF exports, superseded screenshots, and draft files clutter canvases just as much as they cluttered Confluence. Run an attachment audit by file type and last-modified date before starting bulk downloads.
Failure Handling and Idempotency
A migration of 400+ canvases running for hours will fail partway through. A pipeline that cannot be safely restarted requires re-running from scratch — which means duplicate canvases, orphaned access grants, or partially updated content. Build idempotency in from the start.
State database schema
Use a local SQLite database (or any persistent key-value store) to track migration progress:
CREATE TABLE pages (
confluence_page_id TEXT PRIMARY KEY,
confluence_title TEXT,
slack_canvas_id TEXT, -- NULL until canvas created
canvas_created_at DATETIME,
access_granted BOOLEAN DEFAULT FALSE,
links_remapped BOOLEAN DEFAULT FALSE,
attachments_done BOOLEAN DEFAULT FALSE,
error TEXT -- last error if any
);
CREATE TABLE attachments (
confluence_attach_id TEXT PRIMARY KEY,
confluence_page_id TEXT,
filename TEXT,
slack_file_id TEXT, -- NULL until uploaded
uploaded_at DATETIME,
error TEXT
);Resumable pipeline logic
Before each operation, check whether it has already been completed:
def create_canvas_for_page(page: ConfluencePage, db: Database, slack: SlackClient):
row = db.get_page(page.id)
# Already done — skip
if row and row.slack_canvas_id:
return row.slack_canvas_id
markdown = convert_page_to_markdown(page)
response = slack.canvases_create(document_content={"type": "markdown", "markdown": markdown})
canvas_id = response["canvas_id"]
db.upsert_page(
confluence_page_id=page.id,
slack_canvas_id=canvas_id,
canvas_created_at=datetime.utcnow()
)
return canvas_idHandling API errors
Slack's canvas API returns structured error codes. Handle the most common ones explicitly:
| Error code | Cause | Recovery |
|---|---|---|
channel_canvas_already_exists |
Calling conversations.canvases.create on a channel that already has a canvas |
Read the existing canvas ID from the channel metadata; skip creation |
ratelimited |
Exceeded rate limit | Honor the Retry-After header; back off and retry |
invalid_arguments |
Malformed markdown payload | Log the offending page ID and content; skip and continue |
canvas_not_found |
Canvas ID invalid during edit or access grant | Check if canvas was deleted; re-create if needed |
too_many_requests |
Burst threshold exceeded | Implement exponential backoff with jitter |
import time, random
def with_retry(fn, max_retries=5):
for attempt in range(max_retries):
try:
return fn()
except SlackApiError as e:
if e.response["error"] == "ratelimited":
retry_after = int(e.response.headers.get("Retry-After", 60))
jitter = random.uniform(0, retry_after * 0.1)
time.sleep(retry_after + jitter)
else:
raise
raise RuntimeError(f"Failed after {max_retries} retries")Always log the confluence_page_id alongside every error so you can audit which pages failed without re-running the entire extraction phase.
Rate Limits and API Constraints
| API method | Rate tier | Throughput | Notes |
|---|---|---|---|
canvases.create |
Tier 2 | 20+ per minute | Returns canvas_id |
conversations.canvases.create |
Tier 2 | 20+ per minute | One per channel; errors on duplicate |
canvases.edit |
Tier 2 | 20+ per minute | 1 MiB markdown limit per change |
canvases.access.set |
Tier 3 | 50+ per minute | Max 20 channel_ids per call; channel_ids OR user_ids, not both |
canvases.delete |
Tier 2 | 20+ per minute | Irreversible |
files.getUploadURLExternal |
Tier 3 | 50+ per minute | Two-call upload; PUT to returned URL is not rate-limited |
files.completeUploadExternal |
Tier 3 | 50+ per minute | Must be called after PUT to URL |
Confluence GET /pages/{id} (v2) |
Varies | ~100/min (standard) | Body format param required; Cloud only |
| Confluence attachments (v2 list) | Varies | 50 results per page | Download via v1 endpoint |
| Confluence attachment download (v1) | Varies | ~100/min (standard) | Requires Authorization header |
Throughput note: Tier 2 guarantees "20+ per minute" and Tier 3 guarantees "50+ per minute" as minimums. Slack's actual burst capacity is typically higher in off-peak hours, but you cannot rely on burst rates for pipeline planning. Size your timeline estimates to the guaranteed minimums and treat faster throughput as upside.
For a 400-page space, the minimum API time for canvas creation and access grants alone is roughly 28 minutes — before accounting for content transformation, attachment uploads, or link remapping. For spaces with 50,000+ pages, canvas creation alone takes approximately 42 hours at the Tier 2 minimum. Factor in attachment uploads and link remapping, and large migrations are measured in days of continuous API runtime.
Step-by-Step Migration Pipeline
Step 1: Audit the source space
Inventory pages, macros, attachments, and inline comments. Classify macros by type using the Confluence Content Properties API and GET /wiki/api/v2/pages with body-format=storage. Count total pages to estimate API runtime. (developer.atlassian.com)
Step 2: Write a loss policy
For every macro or non-text content type, choose one outcome: convert to markdown, replace with link, freeze as snapshot, or delete. Get stakeholder sign-off before writing code. Teams get into trouble when they script first and name the losses later.
Step 3: Extract page content and metadata
Use the Confluence REST API v2 to pull page bodies in storage format (GET /wiki/api/v2/pages/{id}?body-format=storage), page ancestors (for tree reconstruction), labels, and content properties. Extract inline comments and attachments through their own endpoints — the body does not contain everything you need. Write each page's raw storage format to disk before transforming it; this gives you a reproducible source of truth if the transformation step needs to be re-run.
Step 4: Transform storage format to markdown
Parse the XHTML storage format using lxml or an equivalent XML parser. Convert standard HTML elements (<p>, <h1>–<h6>, <ul>, <ol>, <table>, <code>, <a>) to their markdown equivalents. Collapse h4–h6 per the convention you defined in Step 2. Route <ac:structured-macro> elements through the macro dispatch table. Strip <ac:link> elements and store the page-ID references as placeholder tokens for the remapping pass. Resolve XML entities and character references. Split pages that would exceed Slack's 1 MiB markdown limit into multiple canvases, linked sequentially.
Step 5: Create canvases in Slack
Initialize the state database. For each target channel, call conversations.canvases.create to create the index (channel) canvas. Then call canvases.create for each standalone canvas, passing the transformed markdown as document_content. Before each call, check the state database to skip already-created canvases. Collect the returned canvas_id values and record the Confluence page ID → Canvas ID mapping in the state database.
Step 6: Grant access and share
Call canvases.access.set for each standalone canvas, passing the target channel_ids (batched in groups of 20) with the desired access_level. Mark access_granted = TRUE in the state database. Post the canvas link in the channel so members can discover it.
Step 7: Remap internal links
Do a second pass over every canvas body. Replace Confluence page ID references with Slack Canvas URLs using the mapping table in the state database. Call canvases.edit to apply the updated content. Mark links_remapped = TRUE in the state database.
Step 8: Migrate attachments
Check the state database to skip already-uploaded attachments. Download each attachment from Confluence with proper authentication. Upload to Slack via files.getUploadURLExternal + files.completeUploadExternal. Record the slack_file_id in the state database. Update canvas content to reference the Slack-hosted files via a final canvases.edit call.
Step 9: Validate
Spot-check a representative sample of canvases covering all macro types, table sizes, heading depths, and attachment formats present in the source space. Specific validation criteria:
- Heading structure renders correctly and h4+ content is distinguishable from body text
- Tables with 250–300 cells render without truncation
- Tables that were split display all data across the split documents
- All internal canvas links resolve to valid canvas URLs (not Confluence URLs)
- Attachments display inline rather than as broken image references
- Code blocks preserve language tags and whitespace
- No raw
<ac:XML tags appear in rendered canvas content
Keep Confluence read-only during the final delta window. If the source keeps changing while you are remapping links and uploading attachments, your QA results will be stale before cutover.
What Cannot Be Migrated at All?
Some Confluence data has no destination in Slack Canvas. Be explicit with stakeholders:
- Version history: Canvas starts at version 1. All prior edits stay in Confluence or are lost if the instance is decommissioned.
- Inline comments: Cannot be placed programmatically in Canvas. Preserve as footnotes or archive separately.
- Labels and content properties: No metadata layer in Canvas. Workflows and reports driven by labels will break.
- Page restrictions / granular permissions: Canvas access is per-canvas with
read,write, orowner— no per-section or complex ACL model. - Blog posts: Confluence blog posts are a separate content type; Canvas has no blog concept.
- Templates and blueprints: Confluence templates with variable fields have no programmatic import into Canvas templates.
- Macros: Every dynamic macro becomes a link, snapshot, or deletion. No exceptions.
- Large tables: Tables exceeding 300 cells are rejected and must be converted to external files.
- Deep heading nesting: H4–H6 headings flatten to H3 or bold text.
- Merged table cells: colspan and rowspan must be unmerged before conversion.
- Confluence Server / Data Center API compatibility: The v2 API used throughout this guide is Cloud-only.
When Is Slack Canvas the Wrong Target?
Canvas is not a wiki replacement. If your Confluence space relies heavily on:
- Dynamic macros (Jira dashboards, database reports, chart macros) — Canvas cannot replicate the live-data experience.
- Deep page trees with 4+ nesting levels — the flat Canvas model will frustrate users accustomed to browsing a hierarchy.
- Granular per-page permissions — Canvas access is simpler and coarser.
- Large tables with hundreds of rows — the 300-cell limit is a hard wall.
- Version history for compliance — Canvas starts fresh with no historical version import.
In these cases, consider Confluence to Google Workspace, Confluence to Notion, or Confluence to SharePoint instead.
Canvas works best as a landing-page layer for teams already living in Slack — quick-reference runbooks, onboarding checklists, meeting notes, and lightweight SOPs. Migrating an entire enterprise wiki into Canvas usually means migrating a curated subset, not the whole space. A hybrid model often works better: keep Confluence read-only for archival and historical material, move only active runbooks and project hubs into Canvas, and link the two deliberately.
Making the Call
A Confluence-to-Canvas migration is a deliberate trade: you give up macro fidelity, deep hierarchy, version history, inline comments, and metadata in exchange for content that lives where your team already works. The migration is technically tractable — parse XHTML, emit markdown, call APIs — but requires an idempotent, resumable pipeline to survive multi-hour runs, a macro loss policy agreed in advance, and a two-pass link remapping strategy to avoid dead references.
The content decisions are harder than the code. Audit before you automate, agree on what gets cut, document every mapping convention, and validate against explicit criteria before cutover. Six months later, nobody should be wondering where the Jira dashboard went — they should be able to read the loss policy document you wrote in Step 2.
If you're looking at a large space with complex macros, thousands of attachments, and a tight timeline, that is exactly the kind of migration we handle at ClonePartner. We've run 1,500+ data migrations and know where the edge cases hide. Talk to us about your Confluence-to-Canvas migration — we'll tell you honestly what will survive the move and what won't.
Frequently Asked Questions
- Can you migrate Confluence pages to Slack Canvas?
- Yes, but there is no native migration path. You must extract Confluence page bodies via the REST API, transform XHTML storage format to markdown, and create canvases via the Slack Canvas API. Every macro becomes a link, a static image, or gets removed — there is no macro fidelity in Canvas.
- How many canvases can a Slack channel have?
- A Slack channel can have exactly one channel canvas (the tab canvas created via conversations.canvases.create). Additional content must be created as standalone canvases and shared into the channel using canvases.access.set. Standalone canvases require a paid Slack plan.
- What is the table size limit in Slack Canvas?
- Slack Canvas tables are capped at 300 cells per table — any combination of rows and columns that multiplies to 300 or fewer. Confluence tables exceeding this limit must be split, converted to CSV attachments, or truncated.
- Can Confluence version history and inline comments be imported into Slack Canvas?
- No. There is no API to import historical versions into a Slack Canvas — the migrated canvas starts at version 1. Inline comments also break because their anchors depend on source-body selection metadata that becomes meaningless after conversion to markdown. Canvas comments can only be created through the UI.
- Does Slack Canvas support heading levels h4 through h6?
- No. Slack Canvas markdown supports h1, h2, and h3 only. Confluence pages using h4 through h6 must collapse those levels to h3 or convert them to bold text during migration.