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.
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.
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.
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:
- Request an upload URL: Call
files.getUploadURLExternalwith the filename and file size in bytes. Slack returns a temporary upload URL and afile_id. - POST the binary: Send the raw file bytes to the provided URL.
- Finalize: Call
files.completeUploadExternalwith thefile_id. Optionally specify achannel_idto 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_respfiles.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)
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
permalinkfield viafiles.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\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_idSet 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:
- Enumerate all Quip documents via the Automation or Admin API
- Parse each document's HTML to extract blob IDs (including spreadsheet sections)
- Download every blob via
GET /1/blob/{thread_id}/{blob_id}, storing locally with content hashes - Deduplicate by hash — one upload per unique file per access cohort (see below)
- Upload each unique file to Slack via the two-step file API
- Retrieve Slack permalinks via
files.info - Rewrite document markup — replace every Quip blob URL with the Slack permalink
- Convert HTML to Slack canvas markdown
- Create canvases via
canvases.createorconversations.canvases.create, splitting at 1 MiB if needed - Validate — count checks, hash checks, reference-integrity checks
- 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.
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 htmlAfter 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_filetype 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.
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).