---
title: "Guru to Slack Canvas Migration: API Limits & Trust State"
slug: guru-to-slack-canvas-migration-api-limits-trust-state
date: 2026-09-28
author: Abdul Wahab
categories: [Guru, Slack Canvas, Migration Guide]
excerpt: "Guru to Slack Canvas migration guide covering trust state loss, API extraction, HTML-to-Markdown conversion, image re-hosting, and structural mapping."
tldr: "Slack Canvas has no verification workflow, no trust indicator, and no card expiry — migrating from Guru means losing trust state. Build manual review conventions or question whether Canvas is the right destination."
canonical: https://clonepartner.com/blog/guru-to-slack-canvas-migration-api-limits-trust-state
---

# Guru to Slack Canvas Migration: API Limits & Trust State


# Guru to Slack Canvas Migration: API Limits & Trust State

Migrating from Guru to Slack Canvas is not a format conversion — it is a trust-model downgrade. Every Guru Card carries a verifier, a verification interval, a last-verified date, and a visible trust indicator that tells readers whether information has been reviewed recently. Slack Canvas has no verification workflow, no card expiry, and no staleness signal of any kind. If your team's primary reason for using Guru is its verification lifecycle, Canvas is the wrong destination — and this guide will help you understand exactly why, what workarounds exist, and how to run the migration if you decide to proceed anyway.

There is no native migration path between these platforms. You must extract Guru's HTML payload, convert it to a restricted Markdown subset, re-host all assets on Slack's infrastructure, and pipe results into Slack's API while navigating [strict structural limits](https://clonepartner.com/blog/blog/how-to-import-data-into-slack-canvas-api-limits-guide) — one channel canvas per channel, 1 MiB per `document_content` object, headings capped at h3, 300 cells per table.

This guide covers what trust state is and why Canvas cannot hold it, the structural mapping from Guru's hierarchy to Slack's channel model, extraction constraints of the Guru API, the content-conversion pipeline from HTML to Canvas Markdown, image re-hosting with concrete performance estimates, permission mapping, and the honest trade-offs of abandoning Guru's trust engine.

## What Is Trust State and Why Can't Canvas Hold It?

**Trust state** is the combination of verification status, assigned verifier, verification interval, last-verified date, and trust indicator that Guru attaches to every Card. At the API level, a Guru card object includes:

- `verificationState` — `TRUSTED`, `NEEDS_VERIFICATION`, or `STALE`
- `verificationInterval` — frequency in days; passing `null` defaults to 30 days
- `verifier` — individual or group responsible for re-verification
- `lastVerified` and `lastVerifiedBy` — timestamp and user identity

Guru's verification workflow notifies the assigned verifier via Slack, email, or the web app when a Card is due for review. Verification requires one click if the content is still accurate, or an update-and-re-verify cycle if it is not. ([help.getguru.com](https://help.getguru.com/docs/verifying-and-unverifying-cards))

Slack Canvas has none of this. There is no metadata layer on a canvas, no scheduled review, no owner-as-verifier concept, no trust badge, and no API field that could store any of it. A canvas is a Markdown document with an author, comments, permissions, and a last-edited timestamp. That is the entire data model.

### What Replacing Verification Actually Costs

If you migrate to Canvas and still need content freshness signals, three workarounds exist — none as effective as what you are leaving:

1. **Review convention written into each canvas.** Add a "Last reviewed" line and owner name at the top of every canvas. This requires human discipline and provides no automated alerting. It is easily ignored and invisible to Slack search automation.
2. **Recurring Slack reminders or Workflow Builder triggers.** Set a scheduled reminder in each channel that pings a designated reviewer on a cadence. This approximates Guru's interval-based notifications but has no connection to the canvas itself — there is no "verified" state to flip, no audit record that review happened.
3. **Accept that content goes unverified.** For reference material that changes rarely (office addresses, brand guidelines), this may be acceptable. For fast-moving content (pricing, competitive intel, compliance policies), it is a risk your team needs to consciously accept and document.

> [!WARNING]
> Teams whose primary Guru value is verification — where trust scores, verifier assignments, and scheduled review cycles drive day-to-day operations — should seriously question whether Canvas is the right destination. Platforms like [Notion](https://clonepartner.com/blog/blog/guru-to-notion-migration-the-ctos-technical-guide) or [Discourse](https://clonepartner.com/blog/blog/guru-to-discourse-migration-a-technical-guide) expose metadata fields that can approximate verification. Canvas does not.

A practical pattern that works for teams who proceed: make the first lines of every migrated canvas a review contract. If those lines are blank six months later, you have your answer about whether the content is still governed.

```md
**Former verifier:** Revenue Ops
**Former verification interval:** 90 days
**Last verified in Guru:** 2026-08-14
**Former verification state:** TRUSTED
**Tags:** pricing, renewal
**Migration note:** This canvas is not automatically re-verified in Slack.
```

That header block is not a substitute for native trust state. It is honest labeling. It also preserves the `verificationState` value as a human-readable record, which matters if you ever need to audit which content arrived verified versus stale.

## Should You Migrate Guru into Slack Canvas?

The answer depends on why your team uses Guru.

- **Canvas works well** for lightweight runbooks, project briefs, meeting notes, and channel landing pages — content that lives close to conversations and does not require formal governance.
- **Canvas is a poor fit** for regulated knowledge, support macros, policy docs, and anything where people act differently based on whether the content is verified.
- **Consider a hybrid** when Guru remains the verified source of truth and Canvas becomes the access layer — linking out to Guru cards rather than duplicating content.

If your Guru instance devolved into a static wiki where verification was routinely ignored and your goal is simply to put documentation closer to where your team chats, Canvas is effective. If the green Guru checkmark changes behavior in your org — support uses it before replying, sales uses it before sending collateral, ops uses it before executing a process — moving into Canvas is a governance downgrade unless you replace that behavior with explicit process.

## How Does Guru's Hierarchy Map to Slack's Channel-and-Canvas Model?

**Guru** organizes content using a **Collection → Folder → Card** structure. Guru previously used Board Groups, Boards, and Sections, but has since migrated all teams to a foldered structure — those legacy elements are now folders. Folders nest up to three levels deep within Collections. Legacy terminology still appears in collection exports and Guru Query Language, where field names like `boards` and `boardCount` still operate on folders.

**Slack Canvas** is a document surface inside Slack, not a knowledge base. Content lives in two forms:

- **Channel canvas**: a single canvas pinned to a channel, created via `conversations.canvases.create`. One channel, one channel canvas — that is a hard constraint. A second call to `conversations.canvases.create` for the same channel returns `channel_canvas_already_exists`. ([api.slack.com](https://api.slack.com/methods/conversations.canvases.create))
- **Standalone canvas**: created via `canvases.create`, optionally shared to a channel via the `channel_id` parameter. ([api.slack.com](https://api.slack.com/methods/canvases.create))

Since April 2025, Slack has been converting channel and DM canvases toward canvases in tabs, and conversations can hold up to 15 tabs for canvases, lists, workflows, messages, and files.

### Why You Cannot Map Each Folder to a Channel Canvas

The naive mapping — one Guru folder per Slack channel, with its cards combined into the channel canvas — breaks immediately. A channel holds exactly one channel canvas. If a folder contains 15 cards, you cannot create 15 channel canvases.

| Guru structure | Slack target | Trade-off |
|---|---|---|
| Collection | Slack channel | One channel canvas as index; cards become standalone canvases |
| Top-level folder | Slack channel | Works if folder count is manageable; cards become standalone canvases |
| Card | Standalone canvas | Preserves 1:1 mapping; loses positional hierarchy |
| Card | Section within one large canvas | Keeps content together but unwieldy for large folders; risks 1 MiB limit |

The cleanest pattern for most teams: **one Slack channel per Collection**, with the channel canvas serving as an index page linking to standalone canvases (one per card). Folders become heading sections within the index canvas. For deeply nested structures, consider sub-channels in a channel group.

> [!CAUTION]
> **API constraint:** `conversations.canvases.create` creates one channel canvas. A second call fails with `channel_canvas_already_exists`. For idempotent migrations, check `channel.properties.canvas` from `conversations.info` before creating anything. Do not loop `conversations.canvases.create` over boards and hope the second call succeeds — it will not.

### How `shareStatus` Maps to Canvas Permissions

Guru's `shareStatus` field controls card visibility and has the following values:

- `TEAM` — visible to all members of the Guru team
- `COLLECTION` — visible only to members of the parent Collection
- `PRIVATE` — visible only to the card owner (draft state)

Slack Canvas access is controlled per canvas via `canvases.access.set`, which accepts a list of principals (users, user groups, or channels) and an `access_level` of `read` or `write`. ([api.slack.com](https://api.slack.com/methods/canvases.access.set))

The mapping is approximate:

| Guru `shareStatus` | Canvas equivalent | Notes |
|---|---|---|
| `TEAM` | No access restriction, canvas shared to team channel | Default for open org canvases |
| `COLLECTION` | `canvases.access.set` with Collection member user group | Requires a Slack user group mirroring Collection membership |
| `PRIVATE` | Canvas created but not shared to any channel | Visible only to the creating user until access is granted |

If your org uses Guru Collection-level access control as a security boundary — not just an organizational tool — you must pre-provision corresponding Slack user groups and call `canvases.access.set` for each canvas. Skipping this step after migrating `COLLECTION`-scoped cards means previously restricted content becomes visible to your entire workspace. Audit `shareStatus` before migration begins, not after.

Custom fields and tags — which have no field model in Slack Canvas — must be flattened into prose or index rows at the top of standalone canvases.

## How Do You Extract Cards from the Guru API?

### Endpoints and Pagination

The primary extraction endpoint is `GET /api/v1/search/query`. ([developer.getguru.com](https://developer.getguru.com/docs/gurus-api)) It returns card objects including title, HTML content, verification metadata, tags, and folder membership. Guru also provides `GET /api/v1/cards/` for direct card listing and `GET /api/v1/search/cardmgr` for Card Manager-style queries.

Key constraints:

- **Page size**: `maxResults` caps at **50** per request. Responses with additional results include a `Link` header (`rel="next-page"`) with a cursor token for the next page. When a response has no `Link` header, you have retrieved all results.
- **Rate limits**: Guru does not publicly document specific numeric rate limits for its REST API. In practice, sustained bursts trigger HTTP 429 responses. Build in exponential backoff and honor any `Retry-After` header your tenant emits. Self-throttle, parallelize conservatively, and respond to what the API tells you — do not hardcode a ceiling.
- **Content format**: card content is returned as HTML in the `content` field. You cannot request Markdown directly from the API.
- **Authentication**: HTTP Basic Auth using a User token (read/write across the account) or a Collection token (read-only, scoped to one collection). A User token is required for full card access across collections.

Guru also offers collection exports that produce a ZIP containing cards, folders, resources, and `collection.yaml`. The ZIP structure is:

```
collection-export.zip
├── collection.yaml          # Collection metadata: id, name, color, collectionType
├── cards/
│   └── {cardId}.html        # Card HTML content; filename is the card's GUID
├── folders/                 # Folder metadata as JSON files
│   └── {folderId}.json
└── resources/               # Embedded image files referenced in card HTML
    └── {fileId}.{ext}
```

`collection.yaml` contains collection-level metadata (id, name, slug, color, collectionType) but does **not** include per-card verification metadata — `verificationState`, `verificationInterval`, `lastVerified`, and `lastVerifiedBy` are only available through the REST API. If verification history matters, you must extract it via API regardless of whether you also use the ZIP export.

Collection exports do **not** include archived cards. Handle archived content extraction explicitly through the API if it matters for your migration.

```python
import requests
import time
import re
import base64

def encode_credentials(email, api_token):
    credentials = f"{email}:{api_token}"
    return base64.b64encode(credentials.encode()).decode()

def get_guru_cards(api_token, user_email):
    headers = {
        "Accept": "application/json",
        "Authorization": f"Basic {encode_credentials(user_email, api_token)}"
    }
    cards = []
    url = "https://api.getguru.com/api/v1/search/query?maxResults=50"
    
    while url:
        response = requests.get(url, headers=headers)
        
        if response.status_code == 429:
            retry_after = int(response.headers.get("Retry-After", 5))
            time.sleep(retry_after)
            continue
            
        data = response.json()
        cards.extend(data)
        
        # Follow cursor-based pagination via Link header
        link_header = response.headers.get("Link", "")
        next_match = re.search(r'<([^>]+)>;\s*rel="next-page"', link_header)
        url = next_match.group(1) if next_match else None
        
    return cards
```

### What a Card Object Contains

A card response includes the fields you need for migration and the verification fields you cannot port:

```json
{
  "preferredPhrase": "What is our refund policy?",
  "content": "<p>Our refund policy allows...</p>",
  "verificationState": "TRUSTED",
  "verificationInterval": 90,
  "lastVerified": "2026-06-15T14:30:00.000+0000",
  "lastVerifiedBy": { "email": "jane@company.com" },
  "verifier": { "email": "jane@company.com" },
  "tags": [{ "value": "billing" }],
  "shareStatus": "TEAM",
  "collection": { "name": "Support" }
}
```

You can sort by `verificationState`, `verificationInterval`, `lastVerified`, and other fields using the `sortField` parameter. This is useful for pre-migration auditing — run a pass to identify stale cards before migrating content nobody has looked at in a year.

### Guru Query Language for Pre-Migration Auditing

Guru Query Language supports filtering by verification state, interval, folder membership (through the legacy `boards` field), date, tags, and creator. Use it to inventory risk before migration:

```text
verificationState != trusted
verInterval = 30
boards CONTAINS ("folder-id")
creatorId = "user@company.com"
```

### Card Versions and Verification History Are Read-Only

Guru exposes a Historical Card Versions endpoint (`/cards/{cardId}/versions`), but this data is read-only and cannot be written to Canvas. Canvas has no version history API, no audit log for content changes, and no mechanism to record prior review dates. If your compliance posture requires an audit trail of who verified what and when, export verification history to a spreadsheet or database before migration — Slack Canvas will not hold it.

## How Do You Convert Guru HTML to Canvas Markdown?

Slack Canvas accepts a `document_content` object with `type: "markdown"`. The Markdown content is limited to **1 MiB (1,048,576 characters)** per `document_content` object. ([api.slack.com](https://api.slack.com/reference/canvas)) Most Guru cards are well under this limit, but if you merge multiple cards into a single canvas, monitor the total.

### Supported Formatting

Canvas Markdown supports:

- **Headings**: h1, h2, h3 only — h4, h5, h6 are silently dropped by the parser
- **Text**: bold, italic, strikethrough, inline code
- **Lists**: bulleted, ordered, checklists
- **Blocks**: code blocks, blockquotes, callouts, dividers
- **Tables**: pipe-delimited Markdown tables with a **300-cell limit** per table
- **Links**: inline links and channel/user mentions
- **Embeds**: file unfurls, canvas unfurls, message unfurls
- **Not supported**: Block Kit JSON, iframes, custom HTML elements, inline CSS, h4–h6

Guru cards commonly use h4–h6 headings, nested tables, iframes (for embedded videos or forms), and `<span>` tags with inline CSS for color-coded content. None of these survive the conversion without explicit handling.

### Conversion Library Comparison

A generic HTML-to-Markdown library produces output that Slack's Canvas parser will reject or misrender. The three most common libraries and their failure modes against Canvas:

| Library | Behavior on h4–h6 | Behavior on nested lists | Behavior on unsupported HTML | Canvas compatibility |
|---|---|---|---|---|
| `html2text` (Python) | Outputs `####` which Canvas drops silently | Correct indent but fails in blockquote context | Strips tags, leaves text content | Needs post-processing pass for heading levels |
| `markdownify` (Python) | Outputs `####` which Canvas drops silently | Handles most nesting correctly | Strips tags cleanly | Needs heading downgrade pass; better default choice |
| `pandoc` (CLI) | Configurable via Lua filter | Best nested list handling | Most complete stripping | Best output quality; requires Lua filter for heading cap |

**Recommendation**: use `markdownify` for Python-based pipelines with an explicit post-processing pass to downgrade h4–h6 to h3 and validate table cell counts. Use `pandoc` with a Lua filter if your card corpus has complex nested structures. Do not use `html2text` directly — it produces `####`-prefixed headings that Canvas silently ignores, creating invisible content loss.

**Known Canvas parser rejection cases** (errors you will encounter in bulk migration):

- `invalid_markdown` — returned when the Markdown contains structures the parser cannot handle, such as a list directly inside a blockquote
- `document_content_too_large` — returned when `document_content` exceeds 1 MiB
- `invalid_table` — returned when a table exceeds 300 cells (10 columns × 31 rows = 310 cells triggers this)
- `channel_canvas_already_exists` — returned by `conversations.canvases.create` when the channel already has a canvas

Log these errors per card during migration and handle them as a separate remediation pass rather than stopping the migration.

### Conversion Pipeline

```python
from bs4 import BeautifulSoup
import markdownify

def guru_html_to_canvas_markdown(html: str) -> tuple[str, list[str]]:
    soup = BeautifulSoup(html, 'html.parser')
    
    # Downgrade h4-h6 to h3 (Canvas silently drops unsupported heading levels)
    for tag in soup.find_all(['h4', 'h5', 'h6']):
        tag.name = 'h3'
    
    # Extract Guru-hosted images for separate processing
    images = []
    for img in soup.find_all('img'):
        src = img.get('src', '')
        if 'content.api.getguru.com' in src:
            images.append(src)
        img.decompose()  # Remove placeholder; re-insert Slack URL after upload
    
    # Strip iframes and inline-styled spans
    for tag in soup.find_all(['iframe']):
        tag.decompose()
    for tag in soup.find_all(style=True):
        del tag['style']
    
    # Convert to Markdown
    markdown_text = markdownify.markdownify(str(soup), heading_style="ATX")
    
    # Validate table cell counts
    markdown_text = split_oversized_tables(markdown_text, max_cells=300)
    
    return markdown_text, images


def split_oversized_tables(markdown: str, max_cells: int) -> str:
    """Split any Markdown table exceeding max_cells into multiple tables."""
    # Implementation: parse pipe-delimited rows, count cols × rows,
    # split at row boundary before limit, prepend header to continuation table
    # ... implementation omitted for brevity ...
    return markdown
```

### What a Valid Canvas API Request Looks Like

This is the complete structure of a `canvases.create` request body with `document_content`:

```json
{
  "title": "Refund Policy",
  "document_content": {
    "type": "markdown",
    "markdown": "## Overview\n\nOur refund policy allows customers to request a refund within 30 days of purchase.\n\n**Former verifier:** Revenue Ops\n**Former verification interval:** 90 days\n**Last verified in Guru:** 2026-06-15\n\n---\n\n## Eligibility\n\n- Purchase within the last 30 days\n- Original payment method still active\n"
  }
}
```

The `channel_id` parameter is optional for standalone canvases on paid plans and required on the free plan. To create a channel canvas instead of a standalone canvas, use `conversations.canvases.create` with `channel_id` in the request body and `document_content` as shown above.

## How Do You Migrate Images and Attachments?

Images in Guru cards are hosted at URLs like `https://content.api.getguru.com/files/view/{fileId}`. These URLs require Guru authentication to access — once your Guru subscription lapses or the token expires, the images are inaccessible. You **must** re-host images before writing the canvas that references them. ([developer.getguru.com](https://developer.getguru.com/docs/get-card-attachments))

The migration sequence for each card's images:

1. Parse the card HTML for `<img>` tags with Guru CDN `src` URLs
2. Download each image via authenticated GET to `https://content.api.getguru.com/files/view/{fileId}`
3. Upload to Slack using `files.getUploadURLExternal` plus `files.completeUploadExternal` (Slack's legacy `files.upload` endpoint is being retired; Slack has not published a fixed sunset date but has marked it deprecated since 2024) ([api.slack.com](https://api.slack.com/methods/files.getUploadURLExternal))
4. Get the Slack file permalink from the upload response
5. Replace the Guru CDN URL in your Canvas Markdown with the Slack file reference
6. Write the canvas only after all image uploads for that card are complete

> [!WARNING]
> Do not write the canvas before uploading its images. A canvas referencing a Guru CDN URL will display the image initially (while the URL is still live), but will break silently once Guru access is revoked. Upload to Slack first, then write the canvas with Slack file references.

### Image Migration Performance Estimates

Image re-hosting is the slowest part of the migration. To set concrete expectations:

- Each image requires two API calls: one GET (Guru download) + two calls (Slack upload URL + complete)
- Slack's `files.getUploadURLExternal` and `files.completeUploadExternal` are Tier 4 rate-limited
- A realistic single-threaded round-trip per image (download + upload + API overhead) averages **2–4 seconds** under normal network conditions

**Example**: 500 cards with an average of 4 images each = 2,000 images. At 3 seconds per image single-threaded: **~100 minutes**. With 8 concurrent async workers respecting rate limits: **~15 minutes**. With 16 concurrent workers: **~8 minutes**, approaching Tier 4 throttle risk.

Budget conservatively. Use async workers (Python `asyncio` + `aiohttp`, or Node.js with `Promise.all` pools), cap concurrency at 8–12 workers, and implement exponential backoff on 429 responses. Store the Guru-URL-to-Slack-permalink mapping in a local SQLite database so partial runs can resume without re-uploading already-migrated assets.

## What Happens to Custom Fields, Tags, and History?

### Tags and Custom Fields

**Guru tags** are categorized metadata applied to cards for search and filtering. **Guru custom fields** (available on enterprise plans) add structured key-value pairs to cards.

Slack Canvas has no field model — no custom properties, no tag taxonomy, no structured metadata on a canvas. Your options:

- **Embed as prose**: add a "Tags" or "Metadata" section at the top of each canvas with original tag values as text. Searchable within Slack's canvas search, but not filterable or queryable.
- **Index table**: create a [master "Card Index" canvas](https://clonepartner.com/blog/blog/naming-indexing-10000-slack-canvases-after-migration) with a Markdown table mapping canvas titles to their original tags, custom field values, collection, and verification status.
- **External index**: store the mapping in a spreadsheet or database. Useful for audit but disconnected from the canvases.

None of these recreate Guru's tag-based filtering or the ability to query cards by custom field values. If your workflows depend on tag-driven automation — for example, Guru webhooks that fire when a card with a specific tag is updated — those workflows break entirely and have no Canvas equivalent.

### Version History

Guru exposes historical card versions through `/cards/{cardId}/versions`. Slack canvases have their own revision history, but that history starts once the canvas exists in Slack and covers document restoration, not Guru-style verification ownership or expiry. Export version and verification history to an external store before migration if your compliance posture requires it.

## Slack Canvas API Constraints for the Write Side

Once content is extracted, converted, and re-hosted, writing to Canvas has its own limits:

| Method | Rate limit tier | Practical ceiling | Notes |
|---|---|---|---|
| `conversations.canvases.create` | Tier 2 | ~20 req/min burst | One per channel; second call returns `channel_canvas_already_exists` |
| `canvases.create` | Tier 2 | ~20 req/min burst | Standalone canvases; `channel_id` optional on paid plans |
| `canvases.edit` | Tier 3 | ~50 req/min burst | One operation per API call |
| `canvases.access.set` | Tier 2 | ~20 req/min burst | Grant read/write access per canvas |
| `files.getUploadURLExternal` | Tier 4 | ~100 req/min burst | Required for image re-hosting |
| `files.completeUploadExternal` | Tier 4 | ~100 req/min burst | Completes each upload |

Rate limit tiers follow Slack's published tier definitions ([api.slack.com/docs/rate-limits](https://api.slack.com/docs/rate-limits)); exact burst ceilings vary by workspace plan and API method. Always handle 429 responses with backoff rather than relying on hardcoded ceilings.

Other hard constraints:

- **Content size**: 1 MiB per `document_content` object
- **Table limit**: 300 cells per table
- **Canvas sharing**: each canvas can be shared in up to 1,000 channels
- **Tabs**: up to 15 tabs per conversation (canvases, lists, workflows, messages, files)
- **Free plan**: limited to one canvas tab per channel; standalone canvases require a `channel_id`

At Tier 2 rate limits, writing 500 standalone canvases takes roughly 25 minutes for creation calls alone — not counting image uploads (Tier 4) or access-setting calls (Tier 2). Plan your migration window accordingly.

## Step-by-Step Guru to Slack Canvas Migration Process

### Step 1: Audit and Decide What Moves

Export your Guru card inventory via CSV from Card Manager. The CSV includes title, card ID, folders, tags, date created, created by, last modified, last verified, verifier, trust state, verification interval, and collection name. Use this to:

- Identify stale or unverified cards (`verificationState != TRUSTED`) that should be archived rather than migrated
- Map collections to Slack channels
- Flag cards with `shareStatus = COLLECTION` that require access-controlled canvases
- Flag high-image-count cards that will extend your migration timeline
- Record verification metadata (`verificationState`, `lastVerified`, `verifier`) to your external audit log before any data is lost

### Step 2: Provision Slack Channels and User Groups

Map each Guru Collection to a Slack channel. For large collections with many top-level folders, consider one channel per folder instead. Create channels before running the migration script — you need channel IDs.

For cards with `shareStatus = COLLECTION`, create corresponding Slack user groups mirroring Collection membership. You will need these group IDs for `canvases.access.set` calls.

### Step 3: Extract Cards via the Guru API

Paginate through `GET /api/v1/search/query` with `maxResults=50`. Follow `Link` headers until exhausted. For each card, store:

- Card ID, title, HTML content, `shareStatus`
- Verification metadata (`verificationState`, `verificationInterval`, `lastVerified`, `lastVerifiedBy`, `verifier`) — write to your audit log
- Tags and custom fields
- Folder path (for mapping to channels)
- Image URLs parsed from the content HTML

### Step 4: Download and Re-Host Images

For every Guru CDN image URL found in card content, download via authenticated GET and upload to Slack using `files.getUploadURLExternal` + `files.completeUploadExternal`. Store the mapping of old Guru URLs to new Slack file permalinks in a local SQLite database. Use 8–12 async workers. This is the migration bottleneck — see the performance estimates above.

### Step 5: Convert HTML to Canvas Markdown

Run each card's HTML through your conversion pipeline using `markdownify` (or `pandoc` with a Lua filter for complex content). Replace Guru image URLs with Slack file references from the SQLite mapping. Downgrade h4–h6 headings to h3. Strip iframes and inline styles. Validate output against: under 1 MiB total, tables under 300 cells. Log any cards requiring manual remediation rather than halting the pipeline.

### Step 6: Write Canvases to Slack

For each card, call `canvases.create` with the title and converted Markdown. Tab each standalone canvas to its target channel via `channel_id` as appropriate. For the channel-level index, call `conversations.canvases.create` once per channel — check `channel.properties.canvas` from `conversations.info` first to avoid `channel_canvas_already_exists` errors — with a Markdown document linking to all standalone canvases for that collection.

### Step 7: Set Access and Validate

Call `canvases.access.set` for any canvas migrated from a `COLLECTION`-scoped Guru card, granting access to the appropriate Slack user group. For `TEAM`-scoped cards, leave canvases accessible to the channel's membership.

Spot-check a sample of migrated canvases against original Guru cards for: content fidelity, image rendering, link integrity, heading level accuracy, and table completeness. Verify that review convention headers are in place. Confirm that access-controlled canvases are not visible to users outside their intended group before cutover.

## What Cannot Be Migrated

| Guru feature | Canvas equivalent | Status |
|---|---|---|
| Verification state (`verificationState`) | None | **Lost** — no field, no workflow |
| Verification interval (`verificationInterval`) | None | **Lost** |
| Verifier assignment (`verifier`) | None | **Lost** |
| Trust indicator (UI badge) | None | **Lost** |
| Scheduled verification notifications | None | **Lost** |
| Card version history | None | **Lost** — Canvas has no version write API |
| Tags | Prose header or index table | **Degraded** — searchable, not filterable |
| Custom fields (enterprise) | Prose header or index table | **Degraded** |
| Folder hierarchy (3 levels) | Channels + index canvas | **Approximated** |
| Collection-level permissions (`shareStatus`) | `canvases.access.set` + user groups | **Approximated** — requires pre-provisioned groups |
| Favorites / view counts | None | **Lost** |
| Tag-driven webhook automation | None | **Lost** — no Canvas equivalent |

## The Real Decision

The technical migration is manageable. The trust-model downgrade is the real decision.

If your team uses Guru primarily as a searchable wiki and does not rely on verification workflows, Canvas can work as a lightweight reference layer inside Slack — a landing canvas per channel, standalone canvases per card, and a written review convention gets you most of the way there.

If your team has invested in Guru's verification lifecycle — if trust scores drive accountability, if card expiry prevents stale answers from reaching customers — Canvas is a downgrade that no amount of Slack reminders can fully replace. In that case, consider migrating to a platform with structured metadata: [Notion](https://clonepartner.com/blog/blog/guru-to-notion-migration-the-ctos-technical-guide), [Confluence](https://clonepartner.com/blog/blog/slack-canvas-vs-confluence-architecture-limits-and-migration), or [Discourse](https://clonepartner.com/blog/blog/guru-to-discourse-migration-a-technical-guide).

For teams that do move forward, the hard parts are image re-hosting (slow, three API calls per image, parallelizable to ~8–15 minutes for typical corpora), content conversion (HTML to a restricted Markdown subset with library-specific failure modes), permission mapping (`shareStatus` to `canvases.access.set`), and the organizational question of how to map a hierarchical knowledge base onto a flat channel structure. The Guru API gives you everything you need to extract. The Canvas API accepts Markdown and creates documents. The gap is entirely in what gets lost between the two.

For related migration patterns, see our guides on [Quip to Slack Canvases](https://clonepartner.com/blog/blog/quip-to-slack-canvases-migration-the-official-salesforce-path) and [migrating images and attachments without broken links](https://clonepartner.com/blog/blog/how-to-migrate-images-attachments-embeds-without-broken-links).

## Frequently asked questions

### Can Slack Canvas replace Guru's verification workflow?

No. Slack Canvas has no verification status, no assigned verifier, no review interval, and no trust indicator. You can approximate review cadence with Slack reminders or Workflow Builder triggers, but there is no built-in mechanism to mark a canvas as verified or flag it as stale.

### How many channel canvases can a Slack channel have?

Exactly one. Calling conversations.canvases.create a second time on the same channel returns a channel_canvas_already_exists error. Additional content must go into standalone canvases created via canvases.create and shared or tabbed to the channel.

### What Guru card data is lost when migrating to Slack Canvas?

Verification state, verifier assignments, verification interval, trust indicators, card version history, favorites, and view counts are lost entirely. Tags and custom fields can only be preserved as plain text in the canvas body or an index table — not as structured, filterable metadata.

### Does the Guru API return card content as HTML or Markdown?

HTML. The card content field contains rendered HTML. You must convert this to the Canvas-supported Markdown subset, which limits headings to h1–h3, caps tables at 300 cells, and does not support iframes, Block Kit, or inline CSS.

### How do you handle Guru images when migrating to Slack Canvas?

Download each image from the Guru CDN (content.api.getguru.com) using an authenticated GET request, upload it to Slack via files.getUploadURLExternal and files.completeUploadExternal, then reference the Slack-hosted file in the canvas Markdown. Do not write canvases with Guru CDN URLs — they break when Guru access is revoked.
