---
title: "Slack Canvas to Google Docs Migration: API Limits & Data Mapping"
slug: slack-canvas-to-google-docs-migration-api-limits-data-mapping
date: 2026-10-01
author: Roopendra Talekar
categories: [Slack Canvas, Google Docs, Migration Guide]
excerpt: "Technical guide to migrating Slack Canvas to Google Docs via the API: index drift in batchUpdate, heading mapping, table rebuilding, image hosting, rate limits, and edge cases."
tldr: "Slack Canvas to Google Docs migration requires extracting markdown, rebuilding docs through batchUpdate with reverse-ordered index requests, and handling tables, images, and comments that don't map cleanly."
canonical: https://clonepartner.com/blog/slack-canvas-to-google-docs-migration-api-limits-data-mapping
---

# Slack Canvas to Google Docs Migration: API Limits & Data Mapping


# Slack Canvas to Google Docs Migration: API Limits & Data Mapping

There is no native export tool to convert a Slack Canvas into a Google Doc — no Google importer, no Slack exporter that produces `.gdoc` files. You extract canvas content as markdown via Slack's `canvases.getContent` method, parse it, and rebuild each document through the Google Docs API's `documents.batchUpdate` endpoint using index-based insert requests. The central engineering challenge is **index drift**: every insertion shifts subsequent character positions, corrupting formatting unless you order your requests deliberately.

This guide covers the exact API mechanics, data mapping, size constraints, rate limits, required OAuth scopes, and error codes you will hit when building this pipeline. If you are moving an entire Slack organization, see [Slack Enterprise Grid Migration: The Complete 2026 Technical Guide](https://clonepartner.com/blog/blog/slack-enterprise-grid-migration-the-complete-2026-technical-guide). If you need background on exporting content from Google Docs afterward, see [How to Export Data from Google Docs](https://clonepartner.com/blog/blog/how-to-export-data-from-google-docs-api-limits-methods-portability). If you are migrating in the opposite direction, see [Google Docs to Slack Canvas Migration: The Technical Guide](https://clonepartner.com/blog/blog/google-docs-to-slack-canvas-migration-the-technical-guide).

## Required OAuth scopes for both APIs

Before writing a single line of migration code, confirm your tokens have every scope the pipeline needs. Missing a scope produces a 403 that looks identical to a permission error, wasting debugging time.

### Slack scopes

| Scope | Token Type | Required For |
|---|---|---|
| `files:read` | User token | `files.list` (full workspace visibility) |
| `canvases:read` | Bot or User | `canvases.getContent` |
| `channels:read` | Bot or User | Resolving `linked_channel_id` metadata |
| `users:read` | Bot or User | Resolving `@mention` display names |
| `files:read` | Bot or User | Downloading image binaries via `files.info` |

**Use a user token from a Workspace Admin for inventory.** Bot tokens only see canvases in channels the bot has joined, and standalone canvases require explicit sharing. A bot-token inventory will silently under-count.

### Google scopes

| Scope | Required For |
|---|---|
| `https://www.googleapis.com/auth/documents` | `documents.create`, `documents.batchUpdate`, `documents.get` |
| `https://www.googleapis.com/auth/drive` | Drive file creation, permission modification |
| `https://www.googleapis.com/auth/drive.file` | Narrower alternative if you create all files via the API |

**Service account vs. OAuth user token for Google.** Rate limits on the Google side are attributed per user per project. A service account running as a single identity is subject to the 60-writes-per-minute-per-user cap regardless of how many threads call it. If you need higher throughput, use domain-wide delegation to impersonate multiple users and distribute write load across accounts — each impersonated identity gets its own 60-writes-per-minute bucket.

## How to inventory all Slack Canvases before migration

**There is no `canvases.list` endpoint.** To enumerate all canvases in a workspace, use the `files.list` method filtered to the `canvas` type ([Slack canvas surface docs](https://docs.slack.dev/surfaces/canvases/)):

```bash
GET https://slack.com/api/files.list?types=canvas&count=100
```

The endpoint returns a paginated list of file objects, each including a `canvas_id` (prefixed with `F`), title, timestamps, and ownership metadata. The file object also exposes canvas-specific fields like `is_channel_space` and `linked_channel_id`, which help you separate standalone canvases from channel-linked ones during mapping.

`files.list` is **Tier 3** on Slack's rate limit scale — 50+ requests per minute per app per workspace. With the default page size of 100, that is roughly 5,000 canvas records per minute before you start hitting 429s. For large Enterprise Grid workspaces, paginate with the `page` parameter and implement retry logic using `Retry-After` headers.

One documentation edge case worth noting: the generic `files.list` reference only says its type list is not exhaustive. The [canvas surface guide](https://docs.slack.dev/surfaces/canvases/) is the page that actually documents `types=canvas`. Trust the canvas guide for this pair.

> [!WARNING]
> **Bot tokens only see canvases they have access to.** A bot must be invited to a channel to discover that channel's canvas, and standalone canvases require explicit sharing. Run the inventory with a user token (`files:read` scope) from a Workspace Admin to get a complete count. If you skip the access audit, your inventory will be incomplete.

Once you have the full list, pull each canvas's content with `canvases.getContent`:

```bash
POST https://slack.com/api/canvases.getContent
{"canvas_id": "F08ABC1234X", "content_type": "markdown"}
```

`canvases.getContent` is also Tier 3 (50+ per minute) and returns the entire canvas in a single `content` string without pagination — which makes preflight size checks and checksum validation straightforward. For a workspace with 500 canvases, the read phase alone takes at least 10 minutes if you respect rate limits conservatively.

**`canvases.getContent` returns the current published version only.** Slack canvas revision history is not accessible through standard canvas API methods. If you need to preserve edit history, that data cannot be migrated through this pipeline — it is a hard limitation of Slack's API surface, not a solvable engineering problem.

## The index drift problem in Google Docs batchUpdate

**Index drift** is the phenomenon where inserting content at one position in a Google Doc shifts every subsequent index forward, causing later requests in the same batch to target the wrong positions. Google's own [best practices](https://developers.google.com/workspace/docs/api/concepts/request-response) state it plainly: within a single call to `documents.batchUpdate`, order your requests in *descending order* of index location to eliminate the need to compute index changes.

Docs indexes are measured in **UTF-16 code units**, not characters. Emoji and other non-BMP characters consume two index positions. Emoji-heavy canvases will break naive character counts ([Google Docs API: Move text](https://developers.google.com/workspace/docs/api/how-tos/move-text)).

Here is a concrete example of the problem:

```python
# WRONG: top-to-bottom order causes drift
requests = [
    {"insertText": {"text": "First heading\n", "location": {"index": 1}}},
    {"insertText": {"text": "Second heading\n", "location": {"index": 16}}},
    {"insertText": {"text": "Third heading\n", "location": {"index": 32}}},
]
```

The first insert lands correctly at index 1. But it pushes every subsequent index forward by the length of the inserted text. The second insert now targets index 16, which may land in the middle of the first paragraph. The third is even further off. If your pipeline builds a document top-to-bottom without recalculating offsets, formatting requests will apply to the wrong words, splitting sentences and breaking tables.

### Reverse ordering (recommended for bulk migration)

Insert content from the bottom of the document to the top. Each insert lands at a position that has not been shifted because nothing has been inserted below it:

```python
# Build in document order, then reverse before sending
requests = [
    {"insertText": {"text": "First heading\n", "location": {"index": 1}}},
    {"insertText": {"text": "Second heading\n", "location": {"index": 1}}},
    {"insertText": {"text": "Third heading\n", "location": {"index": 1}}},
]
requests.reverse()
```

The complexity is not reversing an array — it is computing correct index offsets for every formatting request (bold, italic, heading styles) that must pair with the text it references. Tables and images compound this because they occupy variable amounts of index space.

### Append with endOfSegmentLocation

For simple append-only document builds, insert each element at the end of the document body. This sidesteps index math entirely:

```python
requests = [
    {"insertText": {"text": "First\n", "endOfSegmentLocation": {"segmentId": ""}}},
    {"insertText": {"text": "Second\n", "endOfSegmentLocation": {"segmentId": ""}}},
    {"insertText": {"text": "Third\n", "endOfSegmentLocation": {"segmentId": ""}}},
]
```

The catch: `endOfSegmentLocation` only appends. The moment you need to insert a table or image between existing paragraphs, or apply formatting to a range with `updateParagraphStyle`, you are back to explicit indexes. And formatting *always* requires explicit index ranges — you cannot style a heading without knowing its `startIndex` and `endIndex`.

For new-document creation, the append-only approach with a tracked UTF-16 cursor is often easier. Reverse ordering becomes essential when styling existing ranges or backfilling content into structures you have already created.

### Retry and backoff for Google Docs API errors

Do not rely on linear polling. The correct retry pattern for `batchUpdate` calls:

```python
import time, random

def batchUpdate_with_retry(service, doc_id, requests, max_retries=5):
    base_delay = 1.0  # seconds
    for attempt in range(max_retries):
        try:
            return service.documents().batchUpdate(
                documentId=doc_id, body={"requests": requests}
            ).execute()
        except HttpError as e:
            if e.resp.status == 429:
                delay = base_delay * (2 ** attempt) + random.uniform(0, 1)
                time.sleep(delay)
            elif e.resp.status in (500, 503):
                time.sleep(base_delay * (2 ** attempt))
            else:
                raise  # 400, 403, 404 are not retryable
    raise Exception("Max retries exceeded")
```

Key points: 429 (rate limit) and 5xx (transient server errors) are retryable with exponential backoff plus jitter. **400, 403, and 404 are not retryable** — they indicate a structural problem with the request or permissions that will not resolve on retry. Looping on a 400 wastes quota and delays detection of real bugs.

> [!NOTE]
> **Engineering cost of reverse ordering.** For a simple markdown-to-Docs converter, reverse ordering adds roughly two days of work. The real time is spent computing correct index offsets for every formatting request that must pair with the text it references. Tables and images compound this because they occupy variable amounts of index space. Budget for this up front.

## Expected HTTP error codes and their migration-specific causes

Understanding which error codes are structural versus transient prevents retry loops on unfixable problems and speeds up debugging.

| HTTP Code | API | Specific Cause in This Pipeline | Retryable? |
|---|---|---|---|
| `400 Bad Request` | Google Docs | Document exceeds ~1,020,000 character limit; malformed index in `batchUpdate`; invalid `namedStyleType` value | No |
| `403 Forbidden` | Google Docs | Image URI not publicly accessible during `InsertInlineImageRequest`; missing OAuth scope; Drive file permission not yet propagated | No (fix permission first) |
| `403 Forbidden` | Slack | Bot token accessing canvas without channel membership | No (fix access first) |
| `404 Not Found` | Google Docs | `documentId` created but deleted between steps; stale `canvas_id` from inventory | No |
| `429 Too Many Requests` | Slack | Exceeded Tier 3 (50+/min) on `files.list` or `canvases.getContent`; check `Retry-After` header | Yes |
| `429 Too Many Requests` | Google Docs | Exceeded 60 writes/min (user) or 600/min (project); check `Retry-After` or use 60s default | Yes |
| `500 Internal Server Error` | Google Docs | Transient Google infrastructure error; safe to retry with backoff | Yes |
| `503 Service Unavailable` | Either | Temporary service outage | Yes |

The most common silent failure in this pipeline: a 403 on `InsertInlineImageRequest` because the Drive file permission was set but not yet propagated. Google's permission propagation is not instantaneous — adding a 2–3 second sleep between the Drive `permissions.create` call and the Docs `batchUpdate` call eliminates most of these.

## How to map Slack Canvas headings to Google Docs named styles

**Slack Canvas markdown supports three heading levels: `# h1`, `## h2`, and `### h3`.** These are the only heading levels available in canvases; `h4` through `h6` are not supported due to [Slack Canvas's simpler architecture](https://clonepartner.com/blog/blog/slack-canvas-vs-google-docs-architecture-limits-and-migration).

Google Docs supports six named paragraph styles: `HEADING_1` through `HEADING_6`. Map them directly and leave the extra levels unused:

| Canvas Markdown | Google Docs Named Style | Notes |
|---|---|---|
| `# Heading` | `HEADING_1` | Direct 1:1 map |
| `## Heading` | `HEADING_2` | Direct 1:1 map |
| `### Heading` | `HEADING_3` | Direct 1:1 map |
| *(not available)* | `HEADING_4`–`HEADING_6` | Unused — no canvas source |

Do not invent sub-heading levels that did not exist in the source. If a canvas author used only `h1` and `h3` (skipping `h2`), preserve that structure exactly. The goal is fidelity, not aesthetics.

Applying a heading requires two operations in your `batchUpdate` payload: one to insert the plain text, and a subsequent `UpdateParagraphStyleRequest` to apply the named style to the exact index range:

```python
{
    "updateParagraphStyle": {
        "range": {"startIndex": start, "endIndex": end},
        "paragraphStyle": {"namedStyleType": "HEADING_1"},
        "fields": "namedStyleType"
    }
}
```

## How to migrate Slack Canvas tables into Google Docs

**Slack Canvas tables are capped at 300 cells per table** — any combination of rows and columns that totals 300. They support plain text, links, checkboxes, and basic formatting, but no formulas, no merged cells, and no nested tables. Treat them as static layout, not spreadsheet data.

Google Docs tables are structural elements with their own start and end indexes. You cannot insert a table and fill its cells in the same `batchUpdate`; Google requires you to insert the table first, then send a separate update to write cell content ([Google Docs API: Tables](https://developers.google.com/workspace/docs/api/how-tos/tables)). The sequence:

1. **Insert the table structure** with `InsertTableRequest` at a specific index, specifying rows and columns.
2. **Read back the document** with `documents.get` to find the index of each cell's paragraph element.
3. **Insert text into cells** in a second `batchUpdate`, working in reverse document order to avoid index drift.

```python
# Step 1: Insert a 3x2 table at index 1
{"insertTable": {"rows": 3, "columns": 2, "location": {"index": 1}}}

# Step 2: Refetch document to get cell indexes
# Step 3: Fill cells in reverse order in a second batchUpdate
cell_requests = [
    {"insertText": {"text": "C3", "location": {"index": cell_6_idx}}},
    {"insertText": {"text": "C2", "location": {"index": cell_5_idx}}},
    # ... continuing in reverse
]
```

The refetch between step 1 and step 3 costs one read request per table. For a canvas with 10 tables, that is 10 extra `documents.get` calls against your 300-reads-per-minute-per-user quota. You can compute cell indexes arithmetically from the table's `startIndex` since each empty cell occupies a known number of index positions — but this is fragile and breaks if any prior request modified the document structure.

> [!TIP]
> **Batch the table fill with reversal.** The most reliable approach: insert the table in one `batchUpdate`, immediately `documents.get` to learn the structure, then fill all cells in a second `batchUpdate` with requests sorted in descending index order. Two API calls per table, zero index drift.

Canvas table checkboxes become plain text `☐` / `☑` in Google Docs since Docs has no native checkbox in table cells. If a team was using a canvas table as a lightweight tracker, the safe target in Google Docs is still just a table, not a Sheets-like model.

## How to handle images from Slack Canvas in Google Docs

**The Google Docs API does not accept binary image uploads.** The `InsertInlineImageRequest` requires a `uri` field — a publicly accessible HTTPS URL. The URI must be under 2 KB in length, and the image must be PNG, JPEG, or GIF, under 50 MB, and no more than 25 megapixels. You cannot POST image bytes directly through `batchUpdate`.

Slack Canvas images are referenced in the markdown as standard image syntax pointing to Slack-hosted URLs:

```markdown
![graph](https://your_workspace.slack.com/files/U071SRU8BA7/F073FVDABQS/image.png)
```

These URLs require authentication (`Authorization: Bearer xoxb-...`). Google's servers cannot fetch them. Two options:

**Option A: Upload to Google Drive, then insert by Drive URL.** This is the most reliable approach. The pipeline:

1. Download the binary image from Slack using the authenticated `files.info` download URL.
2. Upload it to Google Drive via the Drive API (`drive.files.create`).
3. Set the Drive file permissions to `anyone with the link can view` temporarily using `drive.permissions.create` with `role: reader` and `type: anyone`.
4. Wait 2–3 seconds for permission propagation before proceeding.
5. Extract the `webContentLink` from the Drive API response.
6. Pass that link into the Google Docs API `InsertInlineImageRequest`.
7. Revoke the public permission on the Drive file using `drive.permissions.delete`.

If you skip the permission modification or proceed immediately without the propagation delay, the Google Docs API will return a `403 Forbidden` error when attempting to fetch the image URI.

**Option B: Host on a temporary public URL.** Upload images to a cloud storage bucket (GCS, S3) with a short-lived signed URL, insert them into the document, then revoke access. The Docs API fetches and embeds the image at insertion time, so the URL only needs to be live during the `batchUpdate` call.

Either way, parse every `! [alt](url)` reference in the canvas markdown, resolve the image, host it, and replace the markdown reference with an `InsertInlineImageRequest` at the correct index position. For a canvas with 15 images, this adds 15 download + upload round trips before you start building the document. For a deeper treatment of image migration strategy, see [How to Migrate Images, Attachments & Embeds Without Broken Links](https://clonepartner.com/blog/blog/how-to-migrate-images-attachments-embeds-without-broken-links).

## Size limits: Canvas document_content vs. Google Docs characters

**A Slack Canvas `document_content` object is [capped at 1,048,576 characters (1 MiB)](https://clonepartner.com/blog/blog/slack-canvas-api-limits-that-break-bulk-migrations).** A Google Doc is limited to approximately 1,020,000 characters (1.02 million). The gap is roughly 28,576 characters.

| Platform | Character Limit | Notes |
|---|---|---|
| Slack Canvas | 1,048,576 (1 MiB) | Per `document_content` object |
| Google Docs | ~1,020,000 (1.02M) | Includes structural elements like paragraph markers |

These two ceilings are close enough that most canvases fit, but the near-identical limits create a specific architectural risk. A canvas near the 1 MiB limit could exceed the Google Docs ceiling after conversion — especially if the transformation adds characters. Expanding Slack emoji shortcodes (`:large_green_circle:`) into Unicode, or the hidden newline characters (`\n`) required to terminate every paragraph in Google Docs, can push you over. The `batchUpdate` request will return a `400 Bad Request` error citing quota limits.

> [!NOTE]
> **Character counting differs between platforms.** Slack counts raw markdown characters. Google Docs counts rendered text plus structural elements like paragraph markers and table cell boundaries. Count characters **in UTF-16 code units** before creating the target Doc — emoji shortcodes expanded to Unicode may occupy two UTF-16 code units each. Split oversized canvases before the first write attempt, not after the first 400 error.

In practice, canvases at 95%+ capacity are rare. But your pipeline should preflight the output character count before the final `batchUpdate` and flag documents over the limit. The only workaround for oversized documents is splitting them, which destroys cross-references and internal links.

## What happens to Slack Canvas comments during migration

**Slack Canvas comments live as messages in the canvas's associated Slack channel conversation** — they are not standalone structured objects with text-range anchors. You can retrieve comment text from the channel conversation using `conversations.replies`, but the anchoring information that ties a comment to a specific section of the canvas content is not preserved in the export.

The Google Docs API supports programmatically creating anchored comments via the Drive API's `comments.create` endpoint, which accepts an `anchor` field referencing a text range. However, the problem is on the Slack side: Slack's standard canvas methods do not expose comment text paired with stable text-range anchors. Even with Drive API comment creation capability, you cannot reconstruct which paragraph or sentence a comment was attached to.

Your realistic options:

- **Migrate comments as unanchored Drive comments.** Use the Drive API's `comments.create` endpoint to create a comment on the Google Doc for each canvas comment, including the original author and timestamp in the comment body. The reader loses spatial context.
- **Append comments as a footnote section.** Add a "Comments" section at the end of the Google Doc with each comment's text, author, and timestamp. Low-tech but preserves all content.
- **Drop comments entirely.** If the canvases are primarily reference documents where comments were transient discussion, this may be acceptable. Confirm with stakeholders first.

None of these preserve the original anchoring. This is a hard limitation of Slack's data model, not a solvable engineering problem.

## Rate limits on both sides of the pipeline

The migration pipeline hits two separate rate limit regimes simultaneously.

### Slack API rate limits (source)

Slack uses a tiered system, applied per API method, per app, per workspace:

| Method | Tier | Minimum Rate |
|---|---|---|
| `files.list` | Tier 3 | 50+ per minute |
| `canvases.getContent` | Tier 3 | 50+ per minute |
| `files.info` | Tier 4 | 100+ per minute |
| `conversations.replies` | Tier 3 | 50+ per minute |

Slack returns HTTP 429 with a `Retry-After` header when you exceed a tier. Always read the `Retry-After` value from the response header rather than assuming a fixed delay — Slack's actual retry window varies. The `+` in "50+ per minute" means Slack tolerates short bursts above the floor, but do not design around burst capacity for a sustained migration.

### Google Docs API rate limits (target)

Google Docs enforces two layers of rate limits per Google Cloud project:

| Operation | Per Project | Per User Per Project |
|---|---|---|
| Read requests | 3,000/min | 300/min |
| Write requests | 600/min | 60/min |

The per-user limit is the binding constraint for migration scripts running as a single service account. At 60 writes per minute per user, and assuming 2 `batchUpdate` calls per document (structure + cell fill), you can migrate roughly 30 documents per minute. A workspace with 500 canvases takes approximately 17 minutes of pure write time, ignoring reads and retries. A workspace with 10,000 canvases reaches nearly six hours of continuous API execution.

**To increase Google throughput beyond 60 writes/min:** use domain-wide delegation to impersonate multiple user accounts. Each delegated user identity receives its own 60-writes-per-minute bucket. Two delegated users give you 120 writes/min effective throughput; ten give you 600/min (matching the project ceiling). You will hit the 600-writes-per-minute project cap before you hit per-user limits at approximately 10 parallel identities.

> [!NOTE]
> **`batchUpdate` counts as one write request.** A single `batchUpdate` call containing 200 insert operations still counts as one request against the 60-writes-per-minute quota. Pack as many operations as possible into each batch. This is the single biggest throughput lever on the Google side.

Combining both sides: the Slack read phase (50 canvases/min at Tier 3) is slower than the Google write phase (30 documents/min with a single identity), so **Slack is your bottleneck** for most workspaces unless you use multiple Google identities. Your migration script must implement exponential backoff and jitter on both APIs — see the retry pattern in the index drift section above.

## The full migration pipeline

1. **Inventory** — `files.list?types=canvas` with user token to enumerate all canvases. Page through results. Record `canvas_id`, title, `linked_channel_id`, owner, and timestamps.
2. **Extract** — `canvases.getContent` for each canvas, requesting markdown format. Preflight character count in UTF-16 code units. Flag documents over ~900,000 UTF-16 units for manual review.
3. **Parse** — Walk the markdown AST. Identify headings (h1–h3), tables, images, code blocks, lists, bold/italic ranges, links, emoji shortcodes, and mentions. Maintain a UTF-16 cursor throughout.
4. **Resolve images** — Download each image from Slack (authenticated via `files.info`), upload to Google Drive or a public staging bucket. Complete permission setup before building the Docs payload.
5. **Create target doc** — `documents.create` via the Google Docs API. Record the returned `documentId`.
6. **Build content** — Assemble all `batchUpdate` requests: text insertions, heading styles, inline images. Order requests in descending index order.
7. **Handle tables** — Insert table structures first, refetch the document via `documents.get` to get cell indexes, then fill cells in a second `batchUpdate` with reverse-ordered requests.
8. **Migrate metadata** — Set permissions via Drive API. Migrate comments as unanchored Drive comments or a footnote section.
9. **Validate** — Compare source canvas heading count, table count, image count, link count, and UTF-16 character count against the output document. Log any mismatches as migration warnings rather than silent failures.

Steps 3 through 7 are where the engineering time lives. Parsing canvas markdown is straightforward — any markdown parser handles it. Generating correct, reverse-ordered `batchUpdate` requests with paired formatting ranges, handling the table refetch cycle, and managing image authentication handoffs is the part that takes days, not hours.

## Edge cases that break migrations silently

- **Slack emoji shortcodes.** Canvas markdown contains `:emoji_name:` syntax. Google Docs does not render these. Convert to Unicode equivalents where possible; each expanded emoji may consume 2 UTF-16 code units. Log unconvertible shortcodes rather than silently dropping them.
- **Channel and user mentions.** Canvas markdown uses `! [](#C073UAJRW4R)` for channel mentions and `! [](@U071CCRCVFH)` for user mentions. These have no Google Docs equivalent — convert to plain text like `#channel-name` or `@username` by resolving IDs against `conversations.list` and `users.list` respectively.
- **Code blocks.** Canvas supports fenced code blocks. Google Docs has no native code block element. Apply a monospace font and optionally a background color via `updateTextStyle` and `updateParagraphStyle`.
- **Checklists.** Canvas supports `- [ ]` and `- [x]` checkbox syntax. Google Docs has checklist support applied via `updateParagraphStyle` with a `bullet` preset `CHECKBOX` — not the same as inserting markdown checkboxes. The implementation path differs from a simple text insertion.
- **Column layouts.** Canvas supports a flexbox-based column layout. Google Docs has no column layout in the document body. Content from multi-column layouts must be linearized, which changes the reading order.
- **Drive permission propagation delay.** As noted in the image section, setting a Drive file to public and immediately using its URL in `InsertInlineImageRequest` produces a 403. Add a 2–3 second delay between the `permissions.create` call and the `batchUpdate` that uses the URL.

## When to build this yourself

For a workspace with fewer than 50 simple canvases — mostly text, few tables, no images — a senior engineer can build a working pipeline in 3–5 days. The reverse-ordering logic, table refetch cycle, and image staging are the time sinks.

For workspaces with hundreds of canvases, complex tables, embedded images, and comments that need preserving, the engineering investment grows non-linearly. Each edge case — emoji conversion, mention resolution, checklist mapping, column linearization, permission propagation handling — adds a day or more. At 200+ canvases with mixed content, expect 2–3 weeks of focused engineering work including validation.

Writing a script to pull markdown from Slack is a weekend project. Writing a deterministic layout engine that parses markdown, calculates exact UTF-16 string lengths, models Google Docs table cell boundaries, manages image authentication handoffs, handles Drive permission propagation timing, and executes reverse-ordered `batchUpdate` requests is a multi-week engineering commitment.

> ClonePartner has migrated content across 1,500+ projects, including document platforms with the same index-math and API-ordering challenges described in this guide. If you'd rather spend your engineering time on product work, we'll handle the pipeline — extraction, transformation, validation, and all the edge cases.
>
> [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

### Is there a native way to migrate Slack Canvas to Google Docs?

No. There is no built-in migration path. You must extract canvas content as markdown via canvases.getContent, transform it, and rebuild each document through the Google Docs API's documents.batchUpdate endpoint.

### What is index drift in the Google Docs API?

Index drift occurs when inserting content at one position shifts all subsequent character indexes forward. Google's documentation recommends ordering batchUpdate requests in descending index order to avoid this. Failing to handle it corrupts formatting and misplaces content.

### How do you list all Slack Canvases via the API?

There is no canvases.list endpoint. Use files.list filtered to type canvas (GET https://slack.com/api/files.list?types=canvas). This is a Tier 3 method allowing 50+ requests per minute per workspace.

### Can you embed images directly into Google Docs via the API?

No. The InsertInlineImageRequest requires a uri field with a publicly accessible HTTPS URL. You must first upload Slack-hosted images to Google Drive or a public storage bucket, then reference that URL in the insert request.

### What are the character limits for Slack Canvas vs Google Docs?

Slack Canvas document_content is capped at 1,048,576 characters (1 MiB). Google Docs is limited to approximately 1.02 million characters. The gap is roughly 28,000 characters, so most canvases fit, but near-limit documents should be checked before conversion.
