---
title: "Confluence to Slack Canvas Migration: The Technical Guide"
slug: confluence-to-slack-canvas-migration-the-technical-guide
date: 2026-09-28
author: Nachi Raman
categories: [Confluence, Slack Canvas, Migration Guide]
excerpt: "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."
tldr: "Confluence to Slack Canvas is a lossy migration: page trees flatten to index plus standalone canvases, every macro becomes a link or deletion, headings collapse from 6 to 3 levels, and version history cannot be imported."
canonical: https://clonepartner.com/blog/confluence-to-slack-canvas-migration-the-technical-guide
---

# Confluence to Slack Canvas Migration: The Technical Guide


# 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](https://clonepartner.com/blog/blog/how-to-import-data-into-slack-canvas-api-limits-guide), 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](https://clonepartner.com/blog/blog/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](https://clonepartner.com/blog/blog/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](https://confluence.atlassian.com/doc/confluence-storage-format-790796544.html))

**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](https://api.slack.com/surfaces/canvases))

**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.

> [!WARNING]
> **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](https://clonepartner.com/blog/blog/slack-canvas-vs-confluence-architecture-limits-and-migration), 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](https://clonepartner.com/blog/blog/slack-canvas-api-channel-vs-standalone-for-document-migrations), 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](https://docs.slack.dev/reference/methods/conversations.canvases.create/))

### The index-canvas pattern

The practical architecture is a hub-and-spoke model:

1. **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](https://clonepartner.com/blog/blog/naming-indexing-10000-slack-canvases-after-migration).
2. **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.
3. **Access grants** connect standalone canvases to channels. You use `canvases.access.set` to grant `read` or `write` access. The `channel_ids` parameter accepts an array of up to **20 channel IDs per call**, and `channel_ids` and `user_ids` cannot be passed in the same request. ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.access.set/))
4. **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:

```ts
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.

> [!NOTE]
> **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](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-label/))

## 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](https://support.atlassian.com/confluence-cloud/docs/what-are-macros/))

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](https://support.atlassian.com/confluence-cloud/docs/insert-the-filter-by-label-macro/)) |

> [!CAUTION]
> **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:

```python
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 (`&nbsp;`, `&mdash;`, `&ldquo;`) and numeric character references (`&#160;`). 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:

1. **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.
2. **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](https://api.slack.com/surfaces/canvases))

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:

1. **Split the table** into multiple smaller tables in the canvas.
2. **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.
3. **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](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-comment/))

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.

> [!NOTE]
> **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](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-version/))

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:

1. **Pass One (Creation):** Create all standalone canvases in Slack. As each canvas is created, log the original `confluence_page_id` and the new `slack_canvas_id` into a persistent state file. A local SQLite database works well for this.
2. **Pass Two (Remapping):** Iterate through every newly created canvas, find all Confluence URL patterns and `<ac:link>` references, look up the corresponding `slack_canvas_id` in the mapping database, and update the markdown link to point to the new Slack Canvas URL. Apply changes via `canvases.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](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-attachment/))

The extraction workflow:

1. For each page, call `GET /wiki/api/v2/pages/{pageId}/attachments` to list all attachments.
2. Paginate through results (cursor-based).
3. For each attachment, download the binary via the v1 download endpoint: `GET /wiki/rest/api/content/{pageId}/child/attachment/{attachmentId}/download`.
4. Upload the file to Slack using `files.getUploadURLExternal` + `files.completeUploadExternal` (Slack has deprecated the legacy `files.upload` method).
5. 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.

> [!CAUTION]
> **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.

> [!TIP]
> **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:

```sql
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:

```python
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_id
```

### Handling 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 |

```python
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](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content---children-and-descendants/))

### 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

> [!TIP]
> **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`, or `owner` — 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](https://clonepartner.com/blog/blog/confluence-to-google-workspace-migration-the-technical-guide), [Confluence to Notion](https://clonepartner.com/blog/blog/confluence-to-notion-migration-limits-macros-broken-links), or [Confluence to SharePoint](https://clonepartner.com/blog/blog/confluence-to-sharepoint-migration-methods-limits-macro-mapping) 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](https://clonepartner.com/talk-to-us?duration=30&utm_source=blog&utm_medium=button&utm_campaign=demo_bookings&utm_content=cta_click&utm_term=demo_button_click) — we'll tell you honestly what will survive the move and what won't.

> Need help migrating Confluence to Slack Canvas? Our engineering team handles the XHTML-to-markdown conversion, macro triage, attachment transfers, and link remapping — so your team gets clean canvases without the manual grind.
>
> [Talk to us](https://clonepartner.com/talk-to-us?duration=30&utm_source=blog&utm_medium=button&utm_campaign=demo_bookings&utm_content=cta_click&utm_term=demo_button_click)

## 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.
