Launched:self-serve migrations intoSuperhuman Docs (Coda)
Try it now
01Agent-first
Runs where you already work
Plug it into Claude, ChatGPT or Cursor. Describe the move in plain English; the agent runs it.
02Engineer-led
Our production engine, unlocked
The pipeline our engineers use on managed enterprise migrations — the same code, now something you can drive yourself.
03Pricing
Try 10 pages free, then $1 a page
Credit-based, pay-as-you-go. No scoping call, no quote — sample it on your own docs before you spend anything.
04Sources
NotionSlabConfluenceSoonGoogle DocsSoon
Skip to content

How to Migrate Quip Attachments to Slack Canvases

Quip attachment URLs die when your subscription lapses. Learn how to extract blobs via the Quip API, upload to Slack, rewrite canvas markdown, and validate every file.

Nachi Raman Nachi Raman · · 18 min read
How to Migrate Quip Attachments to Slack Canvases
TALK TO AN ENGINEER

Planning a migration?

Get a free 30-min call with our engineers. We'll review your setup and map out a custom migration plan — no obligation.

Schedule a free call
  • 1,500+ migrations completed
  • Zero downtime guaranteed
  • Transparent, fixed pricing
  • Project success responsibility
  • Post-migration support included

How to Migrate Quip Attachments to Slack Canvases

Extracting text from a Quip document is straightforward. The engineering challenge starts with the attachments. Copy the HTML or markdown body into Slack, and the migration looks good in staging — until every inline image breaks, every PDF returns a 403, and embedded spreadsheets render as blank spaces.

Every image, PDF, and file embedded in a Quip document is stored as a blob on Quip's infrastructure. The document body references those blobs through Quip-specific URLs. When you pull content via the API and push it into Slack, those references still point at Quip servers. They resolve while your subscription is active. They die when it isn't.

This guide covers the full pipeline: pulling blobs out of Quip before access ends, uploading them to Slack using the current file API, wiring them into canvas markdown, and proving every attachment survived.

For the broader Quip-to-Slack migration context, start with our Quip to Slack Canvases guide. For cross-platform attachment migration patterns, see How to Migrate Images, Attachments & Embeds Without Broken Links.

Danger

Quip End-of-Life (March 2027): Salesforce is retiring all Quip products. Subscriptions cannot be renewed after March 1, 2027. After your subscription expires: 90 days of read-only access → blocked logins → permanent data deletion. Quip content is not automatically migrated anywhere. Attachment URLs die with your subscription — download every blob while you still have API access.

Why Quip Attachment URLs Break After Migration

Quip attachment URLs require an active, authenticated session or subscription to resolve. The document HTML references blobs through a URL pattern like /blob/{thread_id}/{blob_id}. When you extract that HTML and push it to Slack, the image tags still point at Quip's servers.

This works during testing because your browser has an active Quip session cookie. But to anyone else in the Slack workspace — and to you, once the Quip tenant is decommissioned — those links are dead.

The failure mode is silent. Canvas text renders correctly. Every heading, table, and paragraph looks fine. But every image shows a broken placeholder, and every attachment link returns a 403 or 404. Teams usually discover this weeks after go-live.

To prevent data loss, you must physically download the binary file from Quip, upload it to Slack's infrastructure, and rewrite the document markup to point at the new Slack-hosted URL. There is no shortcut.

Quip source object Slack destination pattern Rule
Inline image blob Slack upload → ! [alt](slack-permalink) Upload first, embed the permalink
Attached PDF, DOCX, XLSX, ZIP Slack upload → [filename](slack-permalink) Link from canvas body; Slack previews when it can
Oversize or blocked file External host → link Keep the canvas small, store the binary somewhere durable

How to Retrieve Attachments from Quip Using the Blob API

The Quip blob endpoint (GET https://platform.quip.com/1/blob/{thread_id}/{blob_id}) returns the raw binary content of an image or file embedded in a Quip document. You need the thread_id (document ID) and the blob_id (specific file identifier extracted from the HTML). (quip.com)

Required OAuth Scopes

Before writing a single line of download code, confirm your Quip API token has the correct scopes. Missing scopes are the most common first-hour blocker:

Scope Required for
READ_DOCUMENT Fetching thread HTML via /1/threads/{thread_id}
READ_DOCUMENT Downloading blobs via /1/blob/{thread_id}/{blob_id}
ADMIN_READ Admin blob endpoint /1/admin/blob/{thread_id}/{blob_id}
ADMIN_READ + company_id Accessing blobs across all users in an org

The standard Automation API token created under Settings → Apps → Automation API carries READ_DOCUMENT by default but does not grant ADMIN_READ. To generate an admin token, you must be a company admin and create the token via the Admin API console. Attempting to call the admin blob endpoint with a non-admin token returns HTTP 403 with no further detail.

Finding blob IDs in Quip documents

Blob IDs are not returned by the thread-retrieval endpoint in a structured field. You extract them by parsing the HTML body and finding references that match Quip's blob URL pattern — <img> tags, <a> tags pointing at blob URLs, and embedded-object markup.

The reliable discovery path for large documents is GET /2/threads/{threadIdOrSecretPath}/html, which returns paginated HTML via cursor and next_cursor parameters. At migration scale, extract blob IDs programmatically from the fetched HTML rather than inspecting documents manually. (quip.com)

import re
import requests
 
QUIP_BASE = "https://platform.quip.com/1"
TOKEN = "your-quip-access-token"
 
def get_thread_html(thread_id: str) -> str:
    resp = requests.get(
        f"{QUIP_BASE}/threads/{thread_id}",
        headers={"Authorization": f"Bearer {TOKEN}"}
    )
    resp.raise_for_status()
    return resp.json()["html"]
 
def extract_blob_ids(html: str, thread_id: str) -> list[str]:
    pattern = rf'/blob/{re.escape(thread_id)}/([A-Za-z0-9_-]+)'
    return list(set(re.findall(pattern, html)))

Downloading blob content

Once you have blob IDs, download each one as raw bytes:

def download_blob(thread_id: str, blob_id: str, dest_path: str):
    resp = requests.get(
        f"{QUIP_BASE}/blob/{thread_id}/{blob_id}",
        headers={"Authorization": f"Bearer {TOKEN}"},
        stream=True
    )
    resp.raise_for_status()
    with open(dest_path, "wb") as f:
        for chunk in resp.iter_content(chunk_size=8192):
            f.write(chunk)

For centralized admin-led migrations across content the integration user doesn't directly own, use the Admin API variant: GET /1/admin/blob/{thread_id}/{blob_id} with a required company_id. This requires the ADMIN_READ scope and allows higher rate limits — 100 requests per minute vs. 50 for the standard Automation API.

The API may not reliably return the original filename or MIME type in response headers for all legacy attachments. Use the Content-Type header when available, and fall back to a library like libmagic to inspect file signatures locally. Slack uses filename extensions to determine preview behavior, so getting the extension right matters.

Store a source manifest as you download: thread ID, blob ID, discovered filename (if present), MIME type, byte length, SHA-256 hash, and every document location where the blob appeared. This manifest is what makes later deduplication and validation possible.

Warning

Quip returns HTTP 503 for rate limits, not 429. Most HTTP client libraries treat 503 as a transient server error, not a rate-limit signal. Your download script needs custom backoff handling that watches for 503 responses. Quip does not send a Retry-After header — use the X-Ratelimit-Reset timestamp from response headers to calculate your wait. To distinguish rate-limit 503s from genuine server errors, check for the X-Ratelimit-Remaining header: if it's present and shows 0, you're rate-limited; if absent, the server is actually down.

Retry logic with Quip's 503 rate-limit behavior

Because Quip's rate-limit signal is a 503 (not a 429), standard retry libraries will treat it as a server error and either retry immediately or give up. You need explicit handling:

import time
 
def download_blob_with_retry(thread_id: str, blob_id: str, dest_path: str, max_retries: int = 5):
    for attempt in range(max_retries):
        resp = requests.get(
            f"{QUIP_BASE}/blob/{thread_id}/{blob_id}",
            headers={"Authorization": f"Bearer {TOKEN}"},
            stream=True
        )
        if resp.status_code == 200:
            with open(dest_path, "wb") as f:
                for chunk in resp.iter_content(chunk_size=8192):
                    f.write(chunk)
            return
 
        if resp.status_code == 503:
            remaining = resp.headers.get("X-Ratelimit-Remaining")
            reset_ts = resp.headers.get("X-Ratelimit-Reset")
            if remaining is not None:
                # Rate-limited: wait until reset
                wait = max(0, int(reset_ts) - int(time.time())) if reset_ts else (2 ** attempt)
                time.sleep(wait + 1)
            else:
                # Genuine server error: exponential backoff
                time.sleep(2 ** attempt)
            continue
 
        resp.raise_for_status()  # 4xx errors bubble up immediately
 
    raise RuntimeError(f"Failed to download blob {blob_id} after {max_retries} attempts")

The key distinction: if X-Ratelimit-Remaining is present in the 503 response, you are rate-limited and should wait for the reset timestamp. If the header is absent, Quip's backend is genuinely unhealthy and you should use exponential backoff without assuming a fixed reset window.

Rate limit math

Quip's Automation API enforces 50 requests per minute per user token and 600 requests per minute per company across all API types. For a workspace with 5,000 documents averaging 3 blobs each, that's 15,000 blob-download requests at 50/min — roughly 5 hours of download time per token. Parallelizing across multiple admin tokens (still bounded by the 600/min company cap) reduces wall-clock time proportionally: 3 tokens → ~1.7 hours; 6 tokens → ~50 minutes; beyond 12 tokens, the 600/min company cap becomes your binding constraint. Plan for the math before choosing your token count.

Discovering blobs in Quip spreadsheet sections

Quip documents that contain embedded spreadsheets store spreadsheet-cell images as blobs using the same URL pattern, but they appear in a different section of the HTML — inside <div class="spreadsheet"> or similar container elements, not in the main document flow. A regex that scans only top-level <img> and <a> tags will miss these.

from bs4 import BeautifulSoup
 
def extract_all_blob_ids(html: str, thread_id: str) -> list[str]:
    """Extract blob IDs from all document sections, including spreadsheets."""
    soup = BeautifulSoup(html, "html.parser")
    blob_ids = set()
    pattern = re.compile(rf'/blob/{re.escape(thread_id)}/([A-Za-z0-9_-]+)')
 
    # Scan img src and a href across the entire DOM, not just top-level
    for tag in soup.find_all(["img", "a"]):
        attr = tag.get("src") or tag.get("href") or ""
        match = pattern.search(attr)
        if match:
            blob_ids.add(match.group(1))
 
    # Also scan raw text for any blob URLs not captured in standard attributes
    for match in pattern.finditer(html):
        blob_ids.add(match.group(1))
 
    return list(blob_ids)

Using BeautifulSoup over the entire parsed DOM — rather than a raw regex on the full HTML string — catches blobs embedded in spreadsheet cell markup, table cells, and non-standard attribute positions. The final finditer pass on the raw HTML string catches any blob references in non-standard markup that the parser might not surface as tag attributes.

How to Upload Files to Slack Using the Current API

Slack's file upload requires a two-step flow using files.getUploadURLExternal and files.completeUploadExternal. The old files.upload method is deprecated. (docs.slack.dev)

The flow:

  1. Request an upload URL: Call files.getUploadURLExternal with the filename and file size in bytes. Slack returns a temporary upload URL and a file_id.
  2. POST the binary: Send the raw file bytes to the provided URL.
  3. Finalize: Call files.completeUploadExternal with the file_id. Optionally specify a channel_id to share the file into a channel.
from slack_sdk import WebClient
import requests as http_requests
from pathlib import Path
 
slack = WebClient(token="xoxb-your-bot-token")
 
def upload_to_slack(file_path: str, filename: str, channel_id: str = None) -> dict:
    file_size = Path(file_path).stat().st_size
 
    # Step 1: Get upload URL
    url_resp = slack.files_getUploadURLExternal(
        filename=filename,
        length=file_size
    )
    upload_url = url_resp["upload_url"]
    file_id = url_resp["file_id"]
 
    # Step 2: Upload the bytes
    with open(file_path, "rb") as f:
        http_requests.post(upload_url, data=f)
 
    # Step 3: Complete
    complete_resp = slack.files_completeUploadExternal(
        files=[{"id": file_id}],
        channel_id=channel_id
    )
    return complete_resp

files.getUploadURLExternal is rate-limited at Tier 4 (100+ per minute per Slack's rate limit tiers). The Quip extraction side is your bottleneck, not Slack.

Slack upload error taxonomy

Your upload loop will encounter several distinct failure modes. Handle them explicitly rather than catching all exceptions the same way:

HTTP status / error code Cause Handling
invalid_filename Filename contains disallowed characters or extension Sanitize filename: strip special characters, normalize extension
file_too_large File exceeds 1 GB hard limit Route to oversized handler (see below)
storage_limit_exceeded Workspace storage quota full Alert operator; pause batch; do not retry
not_in_channel Bot not a member of the target channel Add bot to channel before upload; retry
posting_to_general_channel_denied Workspace restricts posting to #general Use a different channel for file uploads
HTTP 429 Slack-side rate limit Back off per Retry-After header
HTTP 500 / 503 Slack transient error Exponential backoff, max 3 retries

Unlike Quip, Slack uses standard HTTP 429 for rate limiting with a Retry-After header. Your Quip and Slack retry handlers need different logic.

Watch file access permissions. files.completeUploadExternal leaves a file private if you don't provide a sharing destination, while canvas access is controlled separately through APIs like canvases.access.set. A canvas that everyone can open but whose linked files are still private to the migration bot is a broken migration. Plan file sharing permissions alongside canvas access. (docs.slack.dev)

Slack file size and type constraints

Slack enforces a 1 GB per-file upload limit on every plan (Free, Pro, Business+, Enterprise Grid). This limit cannot be raised. The constraint that varies by plan is workspace storage: Free plans get 5 GB total, Pro gives 10 GB per member, Business+ gives 20 GB per member, Enterprise Grid provides 1 TB per member.

Slack does not publish a universal file-type blocklist, but workspace admins can restrict allowed file types through admin settings, and Slack Connect conversations block a range of executable and archive-like extensions. Slack also scans uploads for malware and can reject infected files. Verify your workspace's file-type policy before starting bulk uploads. (slack.com)

Info

Free Slack plan file retention: On Slack's Free plan, files older than 90 days are automatically hidden and eventually deleted. If you're migrating to a Free workspace, your uploaded attachments will start disappearing three months later. This is a Slack plan constraint, not a migration bug.

How Images and Files Work in Slack Canvas Markdown

You cannot embed binary image data directly in a Slack canvas document_content payload. The canvas API accepts only markdown text (up to 1 MiB per document_content object), and images must be referenced as URLs. (api.slack.com)

The URL must be one of:

  • A Slack-hosted permalink — obtained by uploading the image to Slack first, then retrieving the permalink field via files.info
  • A publicly reachable HTTPS URL — any URL that resolves to the image without authentication

A Quip blob URL is neither. It requires Quip authentication and dies with your subscription.

For inline images, use markdown image syntax: ! [alt text](slack-permalink). For non-image attachments (PDFs, spreadsheets, ZIPs), insert a regular link [filename](slack-permalink) in the canvas body. The file will appear as a downloadable link, not an embedded preview.

# After uploading, get the permalink for canvas use
def get_slack_permalink(file_id: str) -> str:
    info = slack.files_info(file=file_id)
    return info["file"]["permalink"]
 
# Use in canvas markdown
permalink = get_slack_permalink("F0ABC123")
canvas_markdown = f"## Project Overview\n![diagram]({permalink})\n"

Do not try to use Block Kit for this. Slack's canvas docs explicitly state Block Kit is not supported in canvases. (docs.slack.dev)

Splitting canvases that exceed 1 MiB

The document_content object in canvases.create and canvases.edit is limited to 1,048,576 characters (1 MiB). Quip documents with many large tables or extensive embedded content will exceed this. When they do, split the write using canvases.edit with insert_at_end after the initial canvases.create:

def create_canvas_with_content(channel_id: str, markdown: str) -> str:
    """Create a canvas, splitting content into 1 MiB chunks if needed."""
    MAX_CHUNK = 900_000  # Leave headroom below 1 MiB limit
 
    chunks = [markdown[i:i+MAX_CHUNK] for i in range(0, len(markdown), MAX_CHUNK)]
 
    # Create canvas with the first chunk
    create_resp = slack.canvases_create(
        title="Migrated Document",
        document_content={"type": "markdown", "markdown": chunks[0]}
    )
    canvas_id = create_resp["canvas_id"]
 
    # Append remaining chunks
    for chunk in chunks[1:]:
        slack.canvases_edit(
            canvas_id=canvas_id,
            changes=[{
                "operation": "insert_at_end",
                "document_content": {"type": "markdown", "markdown": chunk}
            }]
        )
 
    return canvas_id

Set your chunk size to ~900,000 characters rather than 1,048,576 to leave headroom for multibyte UTF-8 characters that expand when encoded. A document split into N chunks requires N-1 calls to canvases.edit (Tier 3, 50+ per minute) after the initial canvases.create (Tier 2, 20+ per minute). Factor this into your rate-limit planning. (docs.slack.dev)

The Correct Operation Order: Files Before Canvases

Creating canvases before their referenced files exist in Slack is the single most common mistake in this migration. If a canvas references a permalink that doesn't exist yet, the image renders as broken — and Slack does not retroactively resolve it when the file appears later.

The non-negotiable pipeline:

  1. Enumerate all Quip documents via the Automation or Admin API
  2. Parse each document's HTML to extract blob IDs (including spreadsheet sections)
  3. Download every blob via GET /1/blob/{thread_id}/{blob_id}, storing locally with content hashes
  4. Deduplicate by hash — one upload per unique file per access cohort (see below)
  5. Upload each unique file to Slack via the two-step file API
  6. Retrieve Slack permalinks via files.info
  7. Rewrite document markup — replace every Quip blob URL with the Slack permalink
  8. Convert HTML to Slack canvas markdown
  9. Create canvases via canvases.create or conversations.canvases.create, splitting at 1 MiB if needed
  10. Validate — count checks, hash checks, reference-integrity checks
  11. Generate the migration manifest

Phase 9 cannot begin until phases 5 and 6 are fully complete for every file referenced by the documents you're writing.

Tip

Build a reference map, not a linear pipeline. Maintain a persistent lookup table (SQLite or Postgres) that maps every (thread_id, blob_id) pair to its local file path, SHA-256 hash, Slack file ID, and Slack permalink. This map is your single source of truth for the rewrite step and for validation afterward.

Canvas rate limits: canvases.create is Tier 2 at 20+ per minute and canvases.edit is Tier 3 at 50+ per minute. At 20 canvases/minute, migrating 5,000 documents takes ~4.2 hours of canvas-creation time alone — before accounting for file uploads and validation.

Deduplicating Files Across Documents

The same image or attachment often appears in multiple Quip documents — a company logo, a shared template header, a reused architecture diagram. Without deduplication, you upload the same file N times, wasting storage and extending your migration timeline.

Deduplicate by content hash, not by filename or blob ID. Different Quip documents may reference the same underlying file with different blob IDs (copied documents create new blob IDs), and different files may share the same filename.

import hashlib
 
def file_hash(file_path: str) -> str:
    h = hashlib.sha256()
    with open(file_path, "rb") as f:
        for chunk in iter(lambda: f.read(8192), b""):
            h.update(chunk)
    return h.hexdigest()
 
# During extraction, build a dedup index:
# {sha256_hash: {"local_path": ..., "slack_file_id": ..., "permalink": ...}}

When you encounter a blob whose content hash already exists in your index, skip the Slack upload and reuse the existing permalink. Update the reference map so all blob IDs pointing to that content resolve to the same Slack permalink.

Deduplication is a security decision, not just an optimization

Quip permissions and Slack canvas permissions don't map one-to-one. If Doc A is accessible only to Team A and Doc B is accessible only to Team B, and both contain the same company logo, deduplicating to a single Slack file means that file must be accessible to both teams. That's fine for a logo. It is not fine for a shared financial spreadsheet that was scoped to different audiences in Quip by coincidence of document structure rather than intent.

The safe rule: deduplicate only within the same destination access cohort. An access cohort is the set of canvases that share the same channel membership or permission scope in Slack. Concretely: if Canvas A lives in #team-engineering and Canvas B lives in #team-finance, treat them as separate cohorts even if they reference identical file content. Upload the file once per cohort, not once globally.

This matters most for:

  • Documents copied from templates (same blob IDs or same content, different audiences)
  • Shared assets that were narrowly scoped in Quip but would become broadly visible if hosted as a single Slack file
  • Any content subject to compliance or access-control requirements

Hash equality is a necessary condition for deduplication. Matching access cohort is the sufficient condition.

Rewriting In-Document References

Once every file has a Slack permalink, walk through each document's body and replace Quip blob URLs with their Slack equivalents. The rewrite must handle several reference patterns:

  • Inline images: <img src="/blob/{thread_id}/{blob_id}"> → ! [alt]({slack_permalink})
  • File download links: <a href="https://corp.quip.com/-/blob/..."> → [filename]({slack_permalink})
  • Relative blob paths: Some Quip HTML uses relative paths — normalize these before rewriting
def rewrite_blob_refs(html: str, thread_id: str, ref_map: dict) -> str:
    """Replace Quip blob URLs with Slack permalinks.
    ref_map: {blob_id: {"permalink": "https://...", "filename": "..."}}
    """
    for blob_id, meta in ref_map.items():
        patterns = [
            rf'https?://[^"\s]+/blob/{re.escape(thread_id)}/{re.escape(blob_id)}[^"\s]*',
            rf'/blob/{re.escape(thread_id)}/{re.escape(blob_id)}[^"\s]*',
        ]
        for pat in patterns:
            html = re.sub(pat, meta["permalink"], html)
    return html

After rewriting the HTML references, convert the document to Slack canvas markdown. Slack's canvas API only accepts type: "markdown", not HTML. This is a separate transformation step: convert headings, lists, tables, bold/italic, and links from HTML to markdown, with the already-rewritten Slack URLs preserved.

Rewriting should operate at the reference level, not with blind string replacement. If a file appears five times in one document, the validation target is five rewritten references, not one successful upload.

Handling Files Slack Won't Accept

Slack's hard per-file limit is 1 GB. Quip doesn't impose the same ceiling, so some Quip files won't fit. For oversized or blocked files:

  • Compress where appropriate — re-encode large PNGs, compress oversized PDFs
  • Offload to external storage — upload to Google Drive, S3, or SharePoint and link from the canvas
  • Log and flag — record every rejection with a reason code and an owner

Your pipeline should catch upload rejections and continue. Don't let one blocked .exe or oversized video halt a 10,000-file batch.

def upload_with_fallback(file_path: str, filename: str, max_size=1_073_741_824):
    size = Path(file_path).stat().st_size
    if size > max_size:
        return {"status": "oversized", "size": size, "path": file_path}
    try:
        result = upload_to_slack(file_path, filename)
        return {"status": "uploaded", "file_id": result["files"][0]["id"]}
    except Exception as e:
        return {"status": "failed", "error": str(e), "path": file_path}

When to use files.remote.add instead of direct upload

Slack's files.remote.add registers an externally hosted file (on S3, Google Drive, SharePoint, etc.) as a Slack file object without moving the bytes into Slack's storage. This is the right choice when:

  • The file exceeds Slack's 1 GB upload limit
  • You need the file to appear in Slack search and as a remote_file type but want the authoritative copy to stay in an existing content system
  • You're migrating very large media files (video recordings, large datasets) that would consume disproportionate Slack storage

It is not the right choice when:

  • You need the file to render inline as an image in a canvas (remote files don't render as embedded images)
  • Your security team requires DLP scanning of the content (Slack's DLP does not scan externally hosted files)
  • You need file contents to be accessible if the external host goes away

To use it, call files.remote.add with the external URL, a title, and optionally a thumbnail. The file appears in Slack as a linked reference, not a hosted copy. Requires the remote_files:write scope. (docs.slack.dev)

Validating That Every Attachment Arrived

Assuming attachments migrated correctly is how teams end up with hundreds of broken images discovered months later. Validation is a core pipeline stage, not an afterthought.

Count-based validation

The simplest check: compare the number of unique blobs extracted from Quip against the number of successful Slack uploads. If you extracted 4,200 unique blobs and uploaded 4,187, you have 13 failures to investigate.

Content-hash validation

For every file uploaded to Slack, download it back via the url_private_download field from files.info and compare its SHA-256 hash against the original. This catches silent corruption, truncated uploads, and encoding issues.

Reference-integrity validation

After creating canvases, pull each canvas body back via canvases.getContent and verify that no quip.com, platform.quip.com, or /blob/ references remain. If any Quip URL exists in the final canvas, your rewrite logic failed. (docs.slack.dev)

Beyond checking for absent Quip URLs, confirm the Slack URLs actually work. Extract all file URLs from the canvas body and verify each resolves to a real Slack file — a 200 from files.info for each file ID found in the canvas permalinks.

The migration manifest

Produce a final report that maps every Quip document to its Slack canvas, with the status of every attachment:

Field Description
quip_thread_id Source document ID
quip_blob_id Original blob identifier
content_hash SHA-256 of the downloaded file
slack_file_id Slack file ID after upload
slack_permalink URL used in the canvas
status uploaded, deduplicated, oversized, failed, type_blocked
access_cohort Slack channel or permission scope for this file
canvas_id Slack canvas where this file is referenced
verified Whether the reference resolves in the live canvas

This manifest is your audit trail. It proves the migration was complete, and it's the first place to look when someone reports a broken image six months later.

Warning

Do not sign off a migration because the canvas opens and the text looks right. Sign off when every source blob has a mapped destination, every in-document appearance has a rewritten reference, and no Quip URLs remain.

Edge Cases That Will Bite You

Inline images in Quip spreadsheet cells. Quip spreadsheets can contain images in cells. These blobs follow the same URL pattern but are harder to discover — you need to parse the full DOM, not just top-level <img> tags. Use the BeautifulSoup approach described in the extraction section above.

Quip Live Apps and Data Mentions. Content rendered by Quip Live Apps (Salesforce record embeds, Jira cards, etc.) is not stored as blobs. These are dynamic references that don't migrate as files. They require separate handling — typically replacing them with static text or links to the source system.

Quip bulk export expiring=true URLs. Quip's bulk export endpoints expose downloadable file URLs marked expiring=true. These are temporary. For attachment fidelity, the blob-by-blob download pass is the approach to trust. If you're planning a wider off-Quip export, pair this with our Quip export guide. (quip.com)

Your actual deadline is your subscription term-end date, not March 2027. The March 2027 date is when renewals stop. If your subscription ends in October 2026, the read-only clock starts then. Do not rely on the read-only window for blob extraction — API behavior during wind-down phases is not contractually guaranteed, and write endpoints fail during read-only mode.

Slack Enterprise Grid cross-workspace considerations. On Enterprise Grid, canvases created in one workspace are not automatically visible across workspaces. If your migration targets multiple workspaces within a Grid org, files uploaded to Workspace A are not accessible by members of Workspace B. You must either upload files to each destination workspace independently, or use org-level file hosting and verify that your canvas permalinks resolve for all intended audiences. Admin API tokens on Enterprise Grid require additional org-admin scopes beyond standard workspace-admin tokens.

When to Build This Yourself vs. Getting Help

If your Quip workspace has fewer than 100 documents with light attachment use, a well-written script following this pipeline is manageable for a senior engineer over a few days.

If you're dealing with thousands of documents, hundreds of thousands of blobs, enterprise rate limits, and a hard EOL deadline — the engineering cost of building, testing, and validating a one-time pipeline can exceed the cost of having a team that's already done it. Attachments are consistently the piece teams underestimate. The text migration looks done, and then someone opens a document.

Frequently Asked Questions

Do Quip attachment URLs still work after the subscription expires?
Quip blob URLs are tied to your active subscription. During the 90-day read-only phase after expiry, API reads including blob downloads may still work. After the blocked-logins phase, all URLs stop resolving permanently. Download every blob while your subscription is active — do not rely on the read-only window.
Can I embed binary image data directly in a Slack Canvas?
No. Slack canvases accept only a markdown payload (up to 1 MiB per document_content object). Images must be referenced as URLs — either a Slack-hosted permalink obtained by uploading the file first, or a publicly reachable HTTPS URL. Raw binary data cannot be included in the canvas creation payload.
What is the Quip Blob API endpoint for downloading attachments?
Use GET https://platform.quip.com/1/blob/{thread_id}/{blob_id} with a Bearer token. The Admin API variant at /1/admin/blob/{thread_id}/{blob_id} requires ADMIN_READ scope and allows higher rate limits (100 req/min vs. 50 req/min for the Automation API).
What is the maximum file size Slack allows for uploads?
Slack enforces a 1 GB per-file upload limit on every plan (Free, Pro, Business+, Enterprise Grid). This cannot be raised. Files exceeding 1 GB must be compressed, split, or hosted externally and linked from the canvas.
How do I verify that all Quip attachments migrated to Slack?
Run three checks: count-based (compare extracted blob count vs. successful uploads), content-hash (download files back from Slack and compare SHA-256 hashes against originals), and reference-integrity (pull each canvas body via canvases.getContent and verify no Quip blob URLs remain and all Slack URLs resolve).

More from our Blog