---
title: "Slack Canvas Access After Migration: canvases.access.set Guide"
slug: slack-canvas-access-after-migration-canvasesaccessset-guide
date: 2026-09-30
author: Abdul Aleem
categories: [Slack Canvas, Quip, Migration Guide]
excerpt: "App-created Slack canvases are invisible until shared. Learn how canvases.access.set works, its 20-ID batch limit, channel-only constraints, and error handling."
tldr: "App-created Slack canvases are invisible by default. Use canvases.access.set with channel_ids in batches of 20, write content before granting access, and watch the 1,000-channel ceiling."
canonical: https://clonepartner.com/blog/slack-canvas-access-after-migration-canvasesaccessset-guide
---

# Slack Canvas Access After Migration: canvases.access.set Guide


# Slack Canvas Access After Migration: `canvases.access.set` Complete Guide

*Last verified against Slack API documentation: July 2025. Slack Canvas API methods have changed since the 2023 launch; check `docs.slack.dev` for current method signatures before production use.*

A canvas created through `canvases.create` with a bot token is owned by the app and invisible to every human in the workspace. There is no implicit sharing, no inheritance from the channel the bot lives in, and no admin override that auto-distributes newly created canvases. If your migration script creates 500 canvases and never calls `canvases.access.set`, you have 500 documents that exist but nobody can find, open, or read. ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.create))

This guide covers the full mechanics of granting access to migrated Slack canvases: the API constraints, the batching math, the channel-type restrictions, the Enterprise Grid ceiling, the token-type differences, ownership transfer from a bot context, and the ordering rules that determine whether your users see content or an error.

If you're migrating from Quip to Slack Canvases, start with our [Quip to Slack Canvases migration guide](https://clonepartner.com/blog/blog/quip-to-slack-canvases-migration-the-official-salesforce-path) for the end-to-end process. This article focuses on the access-granting step that guide doesn't cover in API depth.

> [!CAUTION]
> **Created does not mean visible.** Every canvas created via `canvases.create` with a bot token is owned by the bot. No workspace member can see it until you call `canvases.access.set`. This is documented behavior, not a bug. Plan your migration script accordingly.

## Why App-Created Canvases Are Invisible by Default

**A [standalone canvas](https://clonepartner.com/blog/blog/slack-canvas-api-channel-vs-standalone-for-document-migrations) created via the Slack API is owned by the acting token's identity.** When that token belongs to a bot, the bot is the owner and sole viewer. No workspace member — not even a Workspace Owner or Org Admin — can see the canvas in their sidebar, search results, or canvas list until access is granted through the API.

Slack documents `canvases.create` as creating "a new standalone canvas owned by the acting user." When the acting user is a bot, the bot is that owner. The canvas exists on Slack's servers, occupies storage, and has a valid `canvas_id` (prefixed with `F`), but it is a private document belonging to a non-human identity. The default canvas visibility is **Invite only**, meaning the canvas will not appear in search or the canvas browser for anyone it has not been shared with. ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.create))

This matters for migrations because a human who creates a canvas in the UI usually shares it immediately. A migration app does not. A technically successful create — `"ok": true` — can still produce a cutover failure from the user's perspective: the content exists, but the people it was migrated for cannot see it.

## How `canvases.access.set` Works

**`canvases.access.set` is Slack's Web API method for granting `read`, `write`, or `owner` access to a canvas for specified channels or users.** It requires the `canvases:write` scope on either a bot or user token. Slack documents it at Tier 3 rate limits — 50 or more requests per minute per workspace per app. ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.access.set/))

### Required parameters

| Parameter | Type | Description |
|-----------|------|-------------|
| `token` | string | Bot or user token with `canvases:write` scope |
| `canvas_id` | string | The `F`-prefixed ID returned by `canvases.create` |
| `access_level` | enum | `read`, `write`, or `owner` |

### Target parameters (mutually exclusive)

| Parameter | Type | Limit | Description |
|-----------|------|-------|-------------|
| `channel_ids` | array | Max 20 per call | Channels to grant access to |
| `user_ids` | array | Max 20 per call | Users to grant access to |

You cannot pass both `channel_ids` and `user_ids` in the same request. If a document needs to be shared with three channels and two specific users, that requires two separate HTTP requests.

### The three access levels

- **`read`** — view-only access to the canvas content.
- **`write`** — read and edit access.
- **`owner`** — transfers ownership. Only works with `user_ids`, not `channel_ids`. Only the current owner can set another user as owner, and cross-team ownership transfers are blocked. Passing `channel_ids` with `owner` returns `invalid_arguments`. ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.access.set/))

A minimal channel-based grant:

```bash
curl -X POST https://slack.com/api/canvases.access.set \
  -H "Authorization: Bearer xoxb-your-bot-token" \
  -H "Content-Type: application/json" \
  -d '{
    "canvas_id": "F07ABC123DE",
    "access_level": "write",
    "channel_ids": ["C01ABCDEF", "C02GHIJKL"]
  }'
```

If you are granting access to private channels, the app must be a member of those channels first. Slack returns `restricted_action` or `no_permission` when the bot lacks channel access. ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.access.set/))

## Bot Tokens vs. User Tokens: What Changes

**Token type determines what operations are available and who appears as the acting identity.** For canvas access operations, the practical differences are:

| Capability | Bot Token (`xoxb-`) | User Token (`xoxp-`) |
|-----------|--------------------|--------------------|
| Grant `read`/`write` via `channel_ids` | ✓ | ✓ |
| Grant `read`/`write` via `user_ids` | ✓ | ✓ |
| Transfer `owner` via `user_ids` | Only if the bot is current owner | Only if the authed user is current owner |
| Access private channels | Must be invited first | Must be a member first |
| Canvas creator (and initial owner) | Bot identity | Human user identity |

The critical migration implication: **when your script creates canvases with a bot token, the bot is the owner**. The ownership transfer question — "can the bot hand ownership to a human?" — has a specific answer: yes, the bot can call `canvases.access.set` with `access_level: "owner"` and `user_ids: ["U_TARGET_USER"]`, using the same bot token that owns the canvas. This is the correct sequence for post-migration stewardship handoff.

However, once ownership transfers to a human user, any subsequent `owner` changes require a **user token** for that human, not the bot token. The bot is no longer the owner and cannot perform further ownership transfers. Plan the ownership chain before you begin: bot creates → bot transfers to human steward → human manages further ownership through the UI or a user token.

Cross-team ownership transfers are explicitly blocked by Slack's API regardless of token type. ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.access.set/))

## Why `channel_ids` Is the Only Reliable Pattern

**Granting access by `user_ids` fails when the user is already a member of a channel where the canvas is shared.** This is not a race condition — it is documented behavior. Slack's own documentation states: "Even if the user is a member of a channel where the canvas has been shared, calling the `canvases.access.set` method with a `user_ids` argument will fail. Since the permissions are set at the channel level, they must also be changed at the channel level." ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.access.set/))

The same model applies on the delete side: if a canvas was shared through a channel, you cannot remove one person's access with `user_ids`. You must update the channel permissions instead. Once you accept that Slack Canvas permissions are channel-first, your migration logic gets simpler and more predictable.

In a migration context, `user_ids` is impractical as your primary distribution mechanism. You would need to first send the canvas link directly to every individual user (via `chat.postMessage` to each user's DM), then call `canvases.access.set` with their user IDs. For a migration touching hundreds of canvases and thousands of users, that sequence is unreasonable.

**Design your migration around `channel_ids` from the start.** Grant access to the channels where the document belongs, and every member of those channels inherits the access level. Treat `user_ids` as a special-case tool for DM-based sharing or ownership transfers only.

One trade-off to watch: sharing through a **public channel** can widen visibility more than intended. If an invite-only canvas is shared in a public channel, it becomes visible to everyone in that workspace or Enterprise organization. The right approach is to map each document to the smallest stable set of channels that matches the source audience. ([slack.com](https://slack.com/help/articles/15678967614611-Manage-access-permissions-for-canvases-and-lists))

### Decision table: `channel_ids` vs. `user_ids` vs. manual follow-up

| Scenario | Recommended approach |
|----------|---------------------|
| Canvas shared to a team or project | `channel_ids` → target channel |
| Canvas shared to a few users in a common channel | `channel_ids` → their shared channel |
| Canvas shared via DM in source system | `user_ids` if no shared channel exists |
| Canvas owner transfer to human steward | `user_ids` with `access_level: "owner"` |
| User is already in a channel where canvas is shared | Update via `channel_ids` only |
| Users have no common channel | Create a private channel, then `channel_ids` |
| Truly org-wide document | `channel_ids` to high-membership channels (#general, #announcements) |

> [!WARNING]
> **Do not build a general migration on `user_ids`.** This is a documented API constraint. Slack's architecture ties canvas access to channel-level permissions. Use direct user grants only for narrow DM-style cases or ownership transfers.

## DM and MPDM Channel IDs Are Rejected

**`canvases.access.set` rejects direct message (DM) and multi-party direct message (MPDM) channel IDs when passed as `channel_ids`.** The method only accepts regular channel IDs — public or private channels prefixed with `C`. Passing a DM ID (prefixed with `D`) or MPDM ID (prefixed with `G`) results in an unsuccessful request. ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.access.set/))

If your source system has documents shared in DMs — common in Quip, where documents can be shared with individual users — you need a different approach:

1. **Map DM-shared documents to a channel.** If users A, B, and C shared a Quip doc, create or identify a private channel containing those users and share the canvas there.
2. **Accept the gap.** Some DM-context documents will not map cleanly to Slack's channel-based model. Document these cases and handle them as manual follow-ups post-migration.
3. **Use `chat.postMessage`** with the canvas link to each user's DM as a notification mechanism, separate from the access grant.

Pre-filter DM and MPDM IDs in your data mapping layer before you hit the API. A simple ID-prefix check (`if not channel_id.startswith("C")`) catches this class of error before it fails at the API boundary.

## The 20-ID Batch Limit and How to Loop It

**Each call to `canvases.access.set` accepts a maximum of 20 `channel_ids` or 20 `user_ids`.** If a canvas needs to be shared to 60 channels, that requires a minimum of three API calls. ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.access.set/))

Batching logic in Python using the Slack SDK, with correct `Retry-After` header handling:

```python
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
import time

client = WebClient(token="xoxb-your-bot-token")

def grant_canvas_access(canvas_id: str, channel_ids: list[str], access_level: str = "write"):
    """Grant access in batches of 20 channel IDs, respecting Retry-After on 429."""
    batch_size = 20
    for i in range(0, len(channel_ids), batch_size):
        batch = channel_ids[i:i + batch_size]
        while True:  # retry loop for rate limiting
            try:
                response = client.canvases_access_set(
                    canvas_id=canvas_id,
                    access_level=access_level,
                    channel_ids=batch,
                )
                if response["ok"]:
                    break  # success, move to next batch
                else:
                    print(f"Failed batch {i // batch_size}: {response['error']}")
                    break  # non-retryable failure
            except SlackApiError as e:
                if e.response.status_code == 429:
                    retry_after = int(e.response.headers.get("Retry-After", 60))
                    print(f"Rate limited. Waiting {retry_after}s before retry.")
                    time.sleep(retry_after)
                    # loop continues to retry the same batch
                else:
                    print(f"Error on batch {i // batch_size}: {e}")
                    break
```

The Tier 3 rate limit gives you 50+ requests per minute per workspace per app. Under normal conditions, one request per 1.2 seconds is a conservative pace. Under actual rate limiting, **always read the `Retry-After` header** — Slack sets this dynamically and it may be longer than 1.2 seconds. Hardcoding a fixed sleep value is incorrect behavior that will fail under load.

**Migration math example:** 200 canvases each shared to 40 channels = 200 × 2 batches = 400 API calls. At 50 calls per minute, that is roughly 8 minutes of API time just for access grants, assuming no rate limit responses. Factor this into your migration timeline and consider running access grants off-hours if your workspace is active.

## What Does the 1,000-Channel Limit Mean?

**A single canvas can be shared to a [maximum of 1,000 channels](https://clonepartner.com/blog/blog/slack-canvas-api-limits-that-break-bulk-migrations).** This limit applies to the cumulative total across both API-based grants and manual sharing through the UI. Your migration script and any subsequent human sharing count against the same ceiling.

Slack's own API documentation does not prominently surface this limit in the `canvases.access.set` reference page. If you encounter this ceiling, the API returns an error rather than a partial success. Test against a staging workspace with a high channel count before running production migrations for org-wide documents.

For most migrated documents, 1,000 channels is well above what's needed. But on Enterprise Grid — where organizations routinely have thousands of channels across multiple workspaces — this limit matters for company-wide reference documents like HR policies, engineering standards, or all-hands announcements.

**Alternatives for org-wide documents:**

- **Share to high-membership org-wide channels.** Instead of sharing to 2,000 individual channels, share to the 5–10 channels that already have the broadest membership (`#general`, `#announcements`, department-level channels).
- **Duplicate by workspace or audience.** Past a certain scale, maintaining one canonical canvas shared everywhere is less practical than creating workspace-specific copies.
- **Keep the source of truth elsewhere.** If a document is truly org-wide, consider whether it belongs in a knowledge base rather than Slack's channel-based access model. A canvas is a channel-scoped document, not a broadcast medium.

> [!NOTE]
> **The 1,000-channel limit applies to both API and manual sharing.** If your script shares a canvas to 990 channels during migration, users can only manually add it to 10 more.

## Enterprise Grid: Multi-Workspace Canvas Sharing

**On Enterprise Grid, canvas access behavior depends on whether channels span workspaces.** When you share a canvas to a channel that belongs to a specific workspace, members of that workspace can access it. When a channel is shared across workspaces (a multi-workspace channel), canvas access follows the channel's membership across those workspaces.

Key Grid-specific constraints:

- **Cross-team ownership transfers are blocked.** `canvases.access.set` with `owner` will fail if the target user is in a different workspace than the canvas owner. Ownership must remain within the same workspace.
- **Bot scope is workspace-scoped.** A bot token from workspace A cannot grant access to channels in workspace B, even within the same Grid org. You need separate app installations (and tokens) per workspace.
- **Org-wide channels.** If your Grid org uses org-wide channels (channels that span all workspaces), sharing a canvas to one of these channels can expose it across the entire organization. Verify channel scope before bulk-sharing.
- **Audit logs.** Enterprise Grid customers can use Slack's Audit Logs API to verify canvas access events: `canvas_access_added`, `canvas_access_upgraded`, and `canvas_access_revoked` are the relevant event types.

If your Slack topology is still changing, read our [Slack Enterprise Grid migration guide](https://clonepartner.com/blog/blog/slack-enterprise-grid-migration-the-complete-2026-technical-guide) before you lock in the document distribution strategy.

## Why You Must Write Content Before Granting Access

**Always write the full document content before calling `canvases.access.set`.** The moment access is granted, channel members can see the canvas. If the content is not finished, users see a partial or empty document.

This ordering matters for three reasons:

1. **User experience.** A canvas appearing in a channel with half-written content erodes trust in the migration. Users see broken formatting, missing sections, or placeholder text and assume the migration failed.

2. **Edit contention.** If you grant write access before content is complete, a human user could edit the canvas while your script is still writing via `canvases.edit`. Slack does not provide document-level locking through the API, so concurrent edits produce unexpected results.

3. **The `canvases.edit` one-operation limit.** Each call to `canvases.edit` supports only one operation (insert, replace, or delete). If your document requires multiple edit operations after creation, you need sequential API calls. Granting access mid-sequence means users see intermediate states.

The correct sequence for each canvas:

1. `canvases.create` — create the canvas with initial content (up to 1 MiB of markdown)
2. `canvases.sections.lookup` — retrieve section IDs if you need to target specific sections for subsequent edits
3. `canvases.edit` — apply any additional content operations if needed (one operation per call, sequential)
4. `canvases.access.set` — grant access only after all content is finalized
5. **Verify** — confirm access state (see verification section below)
6. `canvases.access.set` with `owner` — transfer ownership to a human steward if required (final step)

> [!TIP]
> **Transfer ownership last.** Ownership transfer only works with `user_ids`, only by the current owner, and not across teams. Once the bot transfers ownership to a human, subsequent ownership changes require a user token for that human. Make it the final step in your sequence.

## Verifying That Access Was Actually Granted

**Your migration script should confirm access state programmatically, not assume success from `"ok": true`.**

The primary verification tool is `files.info`, which returns metadata about the canvas including sharing state. A canvas with a valid `canvas_id` is also a file with an `F`-prefixed ID — the same identifier.

```python
def verify_canvas_access(canvas_id: str) -> dict:
    """Check sharing state of a canvas via files.info."""
    response = client.files_info(file=canvas_id)
    if not response["ok"]:
        return {"verified": False, "error": response["error"]}
    
    file_obj = response["file"]
    return {
        "verified": True,
        "is_public": file_obj.get("is_public", False),
        "shared_in_channels": file_obj.get("channels", []),
        "shared_in_groups": file_obj.get("groups", []),  # private channels
        "num_stars": file_obj.get("num_stars", 0),
    }
```

The `files.info` response includes:
- `channels` — list of public channel IDs the file is shared in
- `groups` — list of private channel IDs the file is shared in
- `is_public` — boolean indicating public visibility
- `shares` — detailed sharing data including per-channel share timestamps

Compare the `channels` and `groups` lists against your intended-grants ledger. A canvas that returns `"ok": true` from `canvases.access.set` but does not appear in the expected channel list in `files.info` indicates a silent failure requiring investigation.

For Enterprise Grid customers, the Audit Logs API provides a more complete picture. Query for `canvas_access_added` events filtered by `entity_id` (the canvas ID) to confirm every grant was recorded server-side.

## Revoking Access: `canvases.access.delete`

**`canvases.access.delete` is the symmetric operation for removing canvas access.** It shares the same parameter structure as `canvases.access.set` and the same constraints: 20 IDs per call, mutually exclusive `channel_ids` vs. `user_ids`, no DM IDs, requires `canvases:write` scope.

```bash
curl -X POST https://slack.com/api/canvases.access.delete \
  -H "Authorization: Bearer xoxb-your-bot-token" \
  -H "Content-Type: application/json" \
  -d '{
    "canvas_id": "F07ABC123DE",
    "channel_ids": ["C01ABCDEF"]
  }'
```

Key behavioral constraints for `canvases.access.delete`:

- **Channel-level grants must be revoked at the channel level.** If access was granted via `channel_ids`, you cannot remove an individual user's access using `user_ids`. You must revoke the channel's access entirely.
- **Revoking a channel removes access for all channel members simultaneously.** There is no mechanism to remove one user's access while preserving another's through the same channel grant.
- **`access_level` is not a parameter for delete.** The call removes the relationship entirely; it does not downgrade from `write` to `read`.

Migration error recovery pattern: if your access grant script runs and you discover incorrect grants (wrong channel, wrong access level), call `canvases.access.delete` for the incorrect grants before re-running `canvases.access.set` with the correct parameters. ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.access.delete/))

## Error Handling: Bad IDs, Partial Failures, and Recovery

**If any ID in a `channel_ids` or `user_ids` array is invalid, the entire call fails.** The API returns `"ok": false` with an error like `channel_not_found`. Slack does not apply partial success for validation errors — none of the valid IDs in the same batch receive access.

There is an important distinction for server-side errors: Slack documents that `fatal_error` and `internal_error` responses **may have partially succeeded**. For these transient errors, do not blindly replay the entire batch. Reconcile against `files.info` first, because some grants in that batch may already have been applied. ([docs.slack.dev](https://docs.slack.dev/reference/methods/canvases.access.set/))

```python
def grant_with_retry(canvas_id: str, channel_ids: list[str], access_level: str = "write"):
    """Grant access with single-ID fallback on batch failure, Retry-After on 429."""
    batch_size = 20
    for i in range(0, len(channel_ids), batch_size):
        batch = channel_ids[i:i + batch_size]
        try:
            resp = client.canvases_access_set(
                canvas_id=canvas_id,
                access_level=access_level,
                channel_ids=batch,
            )
            if not resp["ok"]:
                error = resp.get("error", "")
                if error in ("internal_error", "fatal_error"):
                    # May have partially succeeded — reconcile before retry
                    queue_for_reconcile(canvas_id, batch)
                else:
                    # Validation error — fall back to one-at-a-time
                    for cid in batch:
                        try:
                            single_resp = client.canvases_access_set(
                                canvas_id=canvas_id,
                                access_level=access_level,
                                channel_ids=[cid],
                            )
                            if not single_resp["ok"]:
                                log_failure(canvas_id, cid, single_resp["error"])
                        except SlackApiError as e:
                            if e.response.status_code == 429:
                                retry_after = int(e.response.headers.get("Retry-After", 60))
                                time.sleep(retry_after)
                                # retry this single ID
                            else:
                                log_failure(canvas_id, cid, str(e))
        except SlackApiError as e:
            if e.response.status_code == 429:
                retry_after = int(e.response.headers.get("Retry-After", 60))
                time.sleep(retry_after)
            else:
                log_failure(canvas_id, "batch", str(e))
```

### Error code reference

| Error | Meaning | Action |
|-------|---------|--------|
| `channel_not_found` | Channel ID does not exist or bot is not a member | Verify channel exists; ensure bot is invited |
| `canvas_not_found` | Canvas ID is wrong or was deleted | Check creation step succeeded |
| `invalid_parameters` | Both `channel_ids` and `user_ids` were passed | Fix the request — they are mutually exclusive |
| `invalid_arguments` | `owner` access level used with `channel_ids` | Only `user_ids` supports ownership transfer |
| `no_permission` | Bot lacks access to the channel | Invite the bot to the channel first |
| `restricted_action` | Workspace admin settings block this operation | Check canvas sharing settings in admin panel |
| `ratelimited` | Too many requests | Read `Retry-After` header; do not use fixed sleep |
| `internal_error` / `fatal_error` | Server-side issue | May have partially succeeded — reconcile via `files.info` before retry |
| `not_allowed` | Cross-team ownership transfer attempted | Ownership must stay within the same workspace |
| `cant_set_dm_channel` | DM or MPDM ID passed as `channel_ids` | Pre-filter IDs; only `C`-prefixed IDs are valid |

## Reconciling Source Permissions with Slack's Channel-First Model

**Permission reconciliation is the job of translating the source system's access control list into Slack's channel-first model without silently widening or dropping access.** That translation is never 1:1 when the source system supports permission states that Slack Canvas does not.

Salesforce's own Quip-to-Slack documentation is direct about the gaps: account matching is email-based, users without a matching Slack account lose access, comment-only users become read-only, external users from the originating Quip company can lose access, and no users should gain elevated permissions automatically from the conversion. ([help.salesforce.com](https://help.salesforce.com/s/articleView?id=005387799&type=1))

### Access level translation table

| Source system level | Slack Canvas equivalent | Notes |
|--------------------|------------------------|-------|
| Owner / Creator | `owner` (via `user_ids`) | Only one owner per canvas; bot transfers first |
| Can edit / Collaborator | `write` | Standard editor access |
| Can comment | `read` | Slack Canvas has no comment-only level |
| Can view | `read` | Direct mapping |
| No access / Removed | `canvases.access.delete` | Must explicitly revoke if previously granted |

### The three reconciliation steps

**1. Extract the source sharing model.** For each document, capture who has access and at what level. In Quip, this comes from the [`get_folder` and `get_thread` API responses](https://clonepartner.com/blog/blog/quip-to-slack-canvas-migration-at-scale-api-limits-scripting-guide). In Confluence, it is page restrictions plus space permissions. Export this as a structured grants table: `(document_id, principal_type, principal_id, access_level)`.

**2. Map source principals to Slack channels.** This is the hard part. A Quip document shared with users A, B, and C does not have a natural channel equivalent unless those users already share a channel. Your options:
- If the users share a channel, use that channel ID
- If they don't, create a dedicated private channel (adds channel sprawl but preserves access isolation)
- If the document was shared with a Quip folder, map the folder to the closest Slack channel or set of channels

**3. Translate access levels.** Use the table above. If multiple people "owned" a source document, pick one Slack owner and downgrade the rest to `write`. Document this decision in your migration ledger.

### The intended-grants ledger

Your migration needs a **grants ledger**: a database table recording `(canvas_id, channel_id_or_user_id, access_level, grant_status, verified_at)` for every intended grant. Structure:

```sql
CREATE TABLE canvas_grants (
    canvas_id       TEXT NOT NULL,
    target_type     TEXT NOT NULL CHECK (target_type IN ('channel', 'user')),
    target_id       TEXT NOT NULL,
    access_level    TEXT NOT NULL CHECK (access_level IN ('read', 'write', 'owner')),
    grant_status    TEXT NOT NULL DEFAULT 'pending',  -- pending, granted, failed, reconcile_needed
    attempted_at    TIMESTAMP,
    verified_at     TIMESTAMP,
    error_code      TEXT
);
```

Slack's API does not provide a method to read back the complete current access list for a canvas. Your database is the source of truth for intended state. Use `files.info` for spot checks, and on Enterprise Grid use audit log events (`canvas_access_added`, `canvas_access_upgraded`, `canvas_access_revoked`) to verify what actually happened server-side.

> [!TIP]
> **Build the permissions mapping table before you write any migration code.** Export your source system's sharing data, map each document to its target Slack channels, and validate that every channel exists and that your bot is a member. Discovering missing channels mid-migration turns a batched operation into an incident.

## Canvas Access Checklist for Migration Scripts

Before running a bulk canvas migration, verify each of these:

- [ ] Bot token has `canvases:write` scope (and `canvases:read` if using `canvases.getContent` for verification)
- [ ] Bot is a member of every target channel (`conversations.join` for public channels; admin invite for private)
- [ ] No target channel IDs are DMs (`D`-prefix) or MPDMs (`G`-prefix) — pre-filter with ID prefix check
- [ ] Channel list for each canvas is under 1,000 cumulative (API + manual)
- [ ] Batching logic chunks `channel_ids` into groups of ≤20
- [ ] Content is fully written before `canvases.access.set` is called
- [ ] `canvases.sections.lookup` used before any section-targeted `canvases.edit` operations
- [ ] Rate limit handling reads `Retry-After` header on HTTP 429 (not fixed sleep)
- [ ] Error handling distinguishes validation failures (retry individually) from `internal_error`/`fatal_error` (reconcile first)
- [ ] Intended-grants ledger populated before migration begins
- [ ] Post-migration verification runs `files.info` against a sample of canvases and compares to ledger
- [ ] Ownership transfer to human steward is the final step, using `user_ids` with `access_level: "owner"`
- [ ] `canvases.access.delete` script prepared for rollback of incorrect grants
- [ ] On Enterprise Grid: bot token is scoped to the correct workspace; cross-workspace operations use separate tokens

## The Step That Decides Whether the Migration Worked

A Slack canvas migration is only successful when the right humans can open the right document on day one. Content conversion is the straightforward part. The harder part is translating a source system's person-centric or folder-centric sharing model into Slack's channel-centric permissions, batching those grants safely, handling the bot-to-human ownership handoff correctly, and proving the result after cutover through programmatic verification.

Access granting is the step where the most failures occur silently. A canvas that exists but nobody can see looks like a failed migration to the user — even though the creation succeeded. The debugging cycle ("is the content there? is the access set? is the bot in the channel? is the channel a DM? did the `Retry-After` get respected? did the partial-success case get reconciled?") can consume more time than the actual data migration if the scaffolding isn't built before cutover.

> Migrating documents to Slack Canvases and need the access model handled correctly? Our team builds custom migration scripts that handle canvas creation, content transfer, permission grants, and post-migration verification — including the edge cases this guide covers. Book a 30-minute call and we'll scope it together.
>
> [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

### Why can't anyone see my Slack canvas after creating it with the API?

A canvas created via canvases.create with a bot token is owned by the bot. The default visibility is Invite only. No workspace member can see it until you call canvases.access.set to grant read or write access to specific channels or users.

### Can I mix channels and users in a single canvases.access.set call?

No. The channel_ids and user_ids parameters are mutually exclusive. You must make separate API calls if you need to grant access to both channels and specific users.

### What happens if one channel ID in a canvases.access.set batch is invalid?

The entire call fails and none of the IDs in the batch receive access. For validation errors like channel_not_found, fall back to single-ID calls to isolate the bad ID. For internal_error or fatal_error, reconcile first because the batch may have partially succeeded.

### Can I share a Slack canvas to a DM or MPDM using canvases.access.set?

No. The channel_ids parameter only accepts regular public and private channel IDs. DM and MPDM IDs are rejected. For DM-based sharing, Slack suggests user_ids, but that requires the canvas to have been sent directly to the user first.

### How many channels can a single Slack canvas be shared to?

A single canvas can be shared to a maximum of 1,000 channels. This limit covers both API-driven and manual sharing combined. On Enterprise Grid with thousands of channels, org-wide documents may need distribution via high-membership channels instead.
