---
title: "How to Migrate Images, Attachments & Embeds Without Broken Links"
slug: how-to-migrate-images-attachments-embeds-without-broken-links
date: 2026-08-12
author: Nachi
categories: [Migration Guide, General]
excerpt: "A technical guide to re-hosting images, migrating attachments, and transforming embeds during platform migrations — with code, size limits, and validation."
tldr: "Treat media as first-class migration data: inventory every reference, re-host under stable URLs, rewrite content from a mapping table, and validate rendered pages before decommissioning the source."
canonical: https://clonepartner.com/blog/how-to-migrate-images-attachments-embeds-without-broken-links/
---

# How to Migrate Images, Attachments & Embeds Without Broken Links


Extracting text from an API is the easy part of a data migration. The engineering challenge starts when you hit the rich text body. Migrate a knowledge base, help desk, or wiki by copying HTML from source to target, and your migration will look successful — until every inline image breaks, every attachment returns a 403, and your iframe embeds render as blank rectangles.

Text fields are self-contained. Media is not. Every image, attachment, or embed is stored on the source platform's infrastructure and referenced through platform-specific URLs or proprietary markup. When you extract content and load it into a new system, those media references break in predictable ways — and teams rarely discover the damage until customers start filing tickets about missing screenshots and dead download links.

This guide covers the failure modes, the engineering pipeline to prevent them, and the platform-level constraints you need to account for — whether you're moving between Zendesk, Confluence, Notion, HubSpot, Intercom, or any combination.

If you're planning a broader migration, pair this with our [knowledge base migration checklist](https://clonepartner.com/blog/blog/the-ultimate-knowledge-base-migration-checklist-a-zero-downtime-plan/) for the full scope, or our [data migration mapping cheat sheet](https://clonepartner.com/blog/blog/data-migration-mapping-cheat-sheet-sample-scripts/) for field-level transformation scripts.

## Why Images, Attachments, and Embeds Break During Migration

Most failures fall into five categories:

**1. Hotlinking to the source CDN.** When you push source HTML into your new system, every `<img src>` tag still points at the old platform's CDN. The images render fine in staging — then fail when the source account is decommissioned, the subscription lapses, or the vendor expires the URLs. Zendesk explicitly warns that hardcoded published theme asset URLs can break when the asset is replaced. ([support.zendesk.com](https://support.zendesk.com/hc/en-us/articles/4408829155226-Using-your-own-theme-assets-for-help-center))

**2. Expiring presigned URLs.** Platforms like Notion and other AWS-backed services use presigned URLs for media. The API returns a URL with a cryptographic token that expires — Notion's file URLs expire after one hour. ([developers.notion.com](https://developers.notion.com/guides/data-apis/uploading-small-files)) If your migration script parses all articles first and downloads images later, the URLs may have already expired.

**3. Reference format mismatch.** Confluence stores inline images as `<ac:image><ri:attachment ri:filename="screenshot.png" /></ac:image>` — proprietary XML referencing an attachment on the same page. ([confluence.atlassian.com](https://confluence.atlassian.com/doc/confluence-storage-format-790796544.html)) Notion uses nested JSON blocks with `file` type objects. HubSpot uses standard `<img>` tags but hosts files on its own CDN. Moving content between any two of these requires rewriting every media reference into the target's format — not just the article body.

**4. Silent file size rejection.** Attachments that fit in the source platform may exceed the target's limits. The API returns success for the article body but silently drops the oversized file. The article looks complete until someone needs that 25 MB PDF that never made it across.

**5. Scope gaps.** Teams migrate article bodies but miss assets stored elsewhere. Zendesk theme assets live in theme code and a CDN, not inside article attachments. ([support.zendesk.com](https://support.zendesk.com/hc/en-us/articles/4408829155226-Using-your-own-theme-assets-for-help-center)) Ticketing systems like Jira and Zendesk Support attach files to records without embedding them in the body text. If you only parse HTML for media references, you'll miss these orphaned attachments entirely.

> [!WARNING]
> If your plan is "export HTML, import HTML," assume broken media until you prove otherwise. Silent data loss during migration rarely comes from missing text — it comes from embedded assets that fail to render after the move.

## Platform Attachment Size Limits

Before you build any migration tooling, compare file size limits between source and target. This is where teams get surprised:

| Platform | Attachment Limit | Notes |
|---|---|---|
| **Zendesk Guide** | 20 MB per file | No per-article storage cap, but performance degrades with many large files |
| **Zendesk Tickets** | 50 MB per file | Up to 500 files per ticket |
| **Confluence Cloud** | 100 MB default (configurable) | Files over 100 MB skip text extraction and indexing |
| **Confluence Data Center** | 100 MB default (configurable) | Large attachments can cause out-of-memory errors during indexing |
| **Notion** | Multi-part upload supported | Standard API rate limits apply |
| **HubSpot** | 20 MB (free), up to 2 GB (paid) | Files over 1 GB may have upload issues |
| **Intercom** | Varies by content type | Rejects unsupported embed URLs entirely |

If you're migrating from Confluence (100 MB limit) to Zendesk Guide (20 MB limit), any attachment between 20 MB and 100 MB will fail on import. Your migration script needs to catch these *before* attempting upload, flag them for manual review, and either compress or externally host the oversized files.

## Build an Asset Manifest Before You Export

An **asset manifest** is the control file that tracks every media reference from source to target. Without it, you cannot rewrite content deterministically or prove what broke after launch.

Do not scrape only `img [src]` and `a [href]`. Confluence uses `ri:attachment` nodes in storage format. Zendesk inline images are separate article attachment objects. Notion pages are block-based and may include `image`, `file`, `PDF`, and `embed` blocks — and unsupported block types can come back as `unsupported`, so a migration script needs block-level extraction, not a single HTML regex pass. ([confluence.atlassian.com](https://confluence.atlassian.com/doc/confluence-storage-format-790796544.html))

Inventory theme and layout assets too. Banner images, icons, CSS background images, and downloadable files referenced by templates frequently sit outside article bodies.

A minimal manifest tracks:

```text
page_id,ref_type,source_ref,source_url,sha256,target_container,target_asset_id,target_url,rewrite_status,qa_status
KB-142,image,body-img-03,/hc/article_attachments/123/logo.png,6f0c...,article:987,att_445,/files/logo.png,done,verified
KB-142,file,download-link-02,/files/guide.pdf,3c19...,article:987,file_992,/downloads/guide.pdf,done,pending
KB-142,embed,loom-01,https://www.loom.com/share/abc,n/a,article:987,embed_14,card:fallback,done,verified
```

At minimum, track source page, reference type, media hash, target container, target asset ID, target URL, rewrite status, and validation status. Use the binary hash — not the filename — as your deduplication key. `screenshot.png` appears across hundreds of articles with different content. Hash-first mapping lets you deduplicate storage without confusing different files that share a name.

Normalize source references before matching. Resizing parameters, cache-busting strings (`?v=1652914577`), and signed tokens make the same binary look like different assets unless you collapse them deliberately.

## Step 1: Parse the Source Content Body

Your first task is identifying every asset that needs to move. Do not use Regular Expressions to find image URLs in HTML. Regex cannot reliably parse the DOM — it fails on nested tags, malformed HTML, commented-out code, and complex attributes like `srcset`.

Use a DOM parser like BeautifulSoup (Python) or Cheerio (Node.js) to traverse the syntax tree:

```python
from bs4 import BeautifulSoup

def extract_assets(html_content):
    soup = BeautifulSoup(html_content, 'html.parser')
    assets = []
    
    # Extract inline images
    for img in soup.find_all('img'):
        src = img.get('src')
        if src and not src.startswith('data:'):  # Skip base64
            assets.append({'type': 'image', 'url': src, 'element': img})
            
    # Extract file attachments
    for link in soup.find_all('a'):
        href = link.get('href')
        if href and is_attachment_url(href):
            assets.append({'type': 'attachment', 'url': href, 'element': link})
            
    return assets
```

### Relative vs. Absolute Paths

When parsing, you'll encounter both absolute URLs (`https://source.com/image.png`) and relative URLs (`/api/v2/attachments/123.png`). Self-hosted wikis (Confluence Data Center, XWiki) commonly use relative image paths like `/download/attachments/12345/image.png`. Normalize all relative URLs into absolute URLs using the source platform's base domain before attempting download.

### Lazy Loading Attributes

Modern platforms implement lazy loading where the `<img>` tag has a placeholder in `src` while the actual image URL is in a `data-src` or `srcset` attribute. If your parser only reads `src`, you'll migrate low-resolution thumbnails or blank placeholders. Inspect the raw source HTML to determine which attribute holds the production URL.

### Orphaned Attachments

If you rely entirely on HTML parsing to find assets, you'll miss files attached to a record but not linked in the body text — common in Jira and Zendesk Support tickets. Query the source platform's dedicated attachment endpoints (e.g., Zendesk's `/api/v2/tickets/{id}/attachments`) to capture these files separately, download them, and attach them to the target record independently of the HTML rewrite process.

## Step 2: Download Assets with Authentication and Rate Limiting

Once you have a list of asset URLs, download the binary files to local or temporary storage. This step requires handling authentication, rate limits, and network timeouts.

If the source platform requires authentication to view files, a standard HTTP GET will fail. Pass the correct authentication headers — and note that some platforms use a different auth schema for media files than for standard JSON API endpoints.

> [!WARNING]
> Always check the source platform's API documentation for attachment downloads. Zendesk, Confluence, and others may require specific authorization headers or session cookies for file retrieval that differ from their standard API authentication.

Downloading thousands of images will trigger API rate limits. Notion averages three requests per second per connection. ([developers.notion.com](https://developers.notion.com/reference/request-limits)) Zendesk's rate limits vary by plan. Your download function must implement exponential backoff:

```python
import requests
import time

def download_file(url, headers, max_retries=3):
    for attempt in range(max_retries):
        response = requests.get(url, headers=headers, stream=True)
        
        if response.status_code == 200:
            return response.content
        elif response.status_code == 429:
            retry_after = int(response.headers.get('Retry-After', 5))
            time.sleep(retry_after)
        else:
            response.raise_for_status()
            
    raise Exception(f"Failed to download {url} after {max_retries} attempts")
```

> [!TIP]
> Strip query parameters from image URLs before using them as filenames. Source CDNs frequently append cache-busting tokens (e.g., `?v=1652914577`) that create duplicates or invalid filenames on the target platform.

Store downloaded files temporarily, mapping the original URL to the local file path and binary hash. You'll need this mapping in Step 4.

## Step 3: Upload Assets to the Target Platform

With files downloaded, upload them to the target platform. This is where API constraints get heavily platform-specific.

Most APIs require `multipart/form-data` uploads, but the exact sequence varies:

- **Zendesk Guide:** Create the article first, then upload images to that article's ID via `/api/v2/help_center/articles/{article_id}/attachments`. You cannot upload unattached images to a global media library. ([developer.zendesk.com](https://developer.zendesk.com/api-reference/help_center/help-center-api/article_attachments/))
- **Confluence Cloud:** Upload attachments to a specific Page ID. Requires an `X-Atlassian-Token: no-check` header to prevent CSRF during file uploads.
- **HubSpot:** Upload files to the global File Manager API, which returns a public CDN URL. If using URL-based import, the endpoint is asynchronous — wait for completion before rewriting content. ([developers.hubspot.com](https://developers.hubspot.com/docs/api-reference/latest/files/files/import-from-url))

### Attachment Ordering Matters

Create parent records (articles, tickets, contacts) first and capture the new record IDs. Then upload attachments, associating each with its correct parent record ID. Reversing this creates orphaned files with no parent association.

```text
Source: Article #4521 → attachment: setup-guide.pdf (8.2 MB)
                       → attachment: config-template.xlsx (1.1 MB)
                       → inline image: step1-screenshot.png

Target: Article #NEW-7890 → upload setup-guide.pdf → associate with #NEW-7890
                          → upload config-template.xlsx → associate with #NEW-7890
                          → rehost step1-screenshot.png → rewrite <img src> in body
```

When the target platform processes the upload, its API returns a new URL for the asset. Store this alongside the original URL in your manifest.

> [!NOTE]
> For attachments (PDFs, ZIPs), set the correct `Content-Type` and `Content-Disposition` headers during upload so the target platform serves the file correctly to end users. Files with non-ASCII characters in filenames — common in multilingual knowledge bases — may fail silently. Sanitize filenames or explicitly set UTF-8 encoding on upload requests.

## Step 4: Rewrite the Content Body

With files hosted on the target platform, update the HTML body to point to the new URLs before publishing.

Pass the original HTML back through your DOM parser and replace old URLs with new ones from your mapping:

```python
def rewrite_html(html_content, url_mapping):
    soup = BeautifulSoup(html_content, 'html.parser')
    
    for img in soup.find_all('img'):
        old_src = img.get('src')
        if old_src in url_mapping:
            img['src'] = url_mapping[old_src]
            
    for link in soup.find_all('a'):
        old_href = link.get('href')
        if old_href in url_mapping:
            link['href'] = url_mapping[old_href]
            
    return str(soup)
```

Only after the HTML has been rewritten should you send the final POST or PUT request to publish the article on the target platform.

> [!CAUTION]
> Never write signed or expiring API URLs into permanent page bodies. Use them only to transfer the binary, then replace them with stable target URLs or the target's native attachment reference.

For a broader look at how we map data structures during this phase, see our [Data Migration Mapping Cheat Sheet + Sample Scripts](https://clonepartner.com/blog/blog/data-migration-mapping-cheat-sheet-sample-scripts/).

## Handling iFrame Embeds, Videos, and Proprietary Macros

Images and attachments are binary data you can download and re-host. Embedded content — YouTube videos, Loom recordings, Google Maps, Figma frames — is different. You don't download the video; you migrate the embed reference. But embeds are rendered through three completely different mechanisms depending on the platform:

**oEmbed** — A protocol where the platform sends a URL to an oEmbed endpoint and receives structured embed data. WordPress, Notion, and many CMS platforms use oEmbed for YouTube, Vimeo, and Twitter/X.

**Raw iframe** — The article body contains a literal `<iframe src="...">` tag. Zendesk Guide, Confluence, and platforms that allow raw HTML support this approach.

**Proprietary embed blocks** — Notion uses a dedicated `embed` block type. Confluence uses macros like `<ac:structured-macro ac:name="widget">`. Intercom has specific embed block types in its content API.

### Embed Compatibility Across Platforms

This is where most migration tools give up:

| Embed Type | Zendesk Guide | Confluence | Notion | Intercom | HubSpot KB |
|---|---|---|---|---|---|
| YouTube `<iframe>` | ✅ HTML body | ✅ Widget macro | ✅ Embed block | ⚠️ Supported URLs only | ✅ Embed module |
| Loom video | ✅ iframe | ✅ iframe | ✅ oEmbed | ❌ Often rejected | ✅ iframe |
| Google Maps | ✅ iframe | ✅ Widget macro | ✅ Embed block | ❌ | ⚠️ Limited |
| Figma embed | ✅ iframe | ✅ iframe | ✅ Embed block | ❌ | ❌ |
| Twitter/X post | ✅ Script embed | ⚠️ Widget macro | ✅ oEmbed | ⚠️ URL preview only | ⚠️ Limited |

### Migrating Proprietary Macros

If you're migrating out of Confluence, you'll encounter proprietary macros instead of standard iframes. The Widget Connector macro embeds videos like this:

```html
<ac:structured-macro ac:name="widget">
  <ac:parameter ac:name="url">https://www.youtube.com/watch?v=12345</ac:parameter>
</ac:structured-macro>
```

Push this raw XML into Zendesk, Document360, or any non-Atlassian platform and it renders as broken text. Your migration script must parse the `ac:structured-macro` tag, extract the URL, and transform it into a standard HTML `<iframe>` or the target platform's embed format.

For a deep dive into handling Confluence's storage formats, see our [Confluence Macro Mapping Reference](https://clonepartner.com/blog/blog/confluence-macro-mapping-reference-migrating-dynamic-content/).

### Embed Migration Strategy

Classify every embed into one of four outcomes: **native embed** (target supports it natively), **authenticated preview** (requires reader login), **static screenshot plus link** (fallback), or **plain link** (last resort).

**For iframe-based embeds moving to oEmbed platforms:**
1. Extract the `src` URL from the iframe tag
2. Check if the URL's domain is supported by the target platform's oEmbed provider
3. If supported: convert to the target's embed format
4. If not supported: fall back to a hyperlink with a descriptive label — don't silently drop it

**For proprietary embeds going to standard HTML platforms:**
1. Parse the macro XML to extract the underlying URL
2. Determine if the target has an equivalent embed mechanism
3. If yes: transform to the target format
4. If no: insert as a link, or capture a screenshot as a static fallback

> [!NOTE]
> Many platforms strip `<iframe>` tags from API payloads to prevent XSS attacks. Before migrating iframes, whitelist the external domains (e.g., `youtube.com`, `loom.com`) in the target platform's security settings. Intercom's content API rejects unsupported embed URLs entirely — the API call succeeds but the embed block is silently omitted.

## Edge Cases That Derail Migrations

Even with a solid pipeline, these specific problems will cause failures if left unaddressed.

**The Base64 trap.** Some developers try to bypass the download/upload pipeline by converting all images to Base64 strings embedded directly in the HTML. Don't. Base64 encoding increases file size by ~33%. A single 2 MB image becomes 2.6 MB of inline text. If an article contains five images, you're pushing a 13 MB text payload. Most APIs have strict payload limits (often 1–5 MB for text bodies) and will reject the request. Even if accepted, rendering massive Base64 strings destroys page load performance.

**File type restrictions.** HubSpot and Intercom restrict certain file types. A `.exe` or `.msi` attachment from Zendesk may be rejected by the target with no warning in the API response.

**Storage quotas.** Confluence Cloud's Standard plan includes 250 GB per application. Migrating a large Confluence Data Center instance with years of accumulated attachments can exceed this on day one.

**Duplicate filenames across records.** Two articles may both reference `screenshot.png` with different content. Namespace your downloaded files by article ID or use hash-based deduplication to avoid collisions.

## Validate Media Integrity Post-Migration

Migration without validation is a gamble. No human is going to click through 2,000 articles checking for broken images. Automate it.

### Automated Validation Checklist

- **Image count comparison.** For each migrated article, count `<img>` tags in both source and target versions. Any mismatch means a missing image.
- **HTTP status check on all image URLs.** Crawl every image URL in the migrated content and verify it returns `200 OK`. Flag any `404`, `403`, or `5xx` responses.
- **Attachment count per record.** Compare attachment counts between source and target for every record. If source article #4521 had 3 attachments and the target has 2, something was dropped.
- **File size spot-check.** For a sample of attachments, compare file sizes between source and target. A significant difference indicates corruption during transfer.
- **Embed render test.** For articles containing embeds, load the target article in a headless browser (Playwright, Puppeteer), take screenshots, and compare.

```python
def audit_images(source_articles, target_articles):
    mismatches = []
    for src, tgt in zip(source_articles, target_articles):
        src_count = count_images(src['body'])
        tgt_count = count_images(tgt['body'])
        if src_count != tgt_count:
            mismatches.append({
                'article_id': src['id'],
                'source_images': src_count,
                'target_images': tgt_count,
                'delta': src_count - tgt_count
            })
    return mismatches
```

**Permission-aware testing matters.** Run checks as an anonymous user, an authenticated reader, and an admin. Confluence Smart Links can require auth to display correctly. HubSpot private files require signed access. Test all access states before declaring the migration complete. ([support.atlassian.com](https://support.atlassian.com/confluence-cloud/docs/insert-links-and-anchors/))

### The 48-Hour Rule

Don't decommission the source platform immediately after migration. Keep it running for at least 48 hours while you validate. If your source CDN URLs are still live, any missed images can be re-downloaded and re-hosted. Once the source goes dark, recovery becomes manual and painful. The first week after cutover is when caches expire, auth contexts change, and signed-link mistakes surface.

## When Not to Re-Host

Re-hosting isn't always the right move:

- **Images on permanent URLs you control.** If your articles reference images hosted on your own domain, S3 bucket, or CDN, don't re-host them. Verify the URLs remain valid after migration and move on.
- **Third-party embed content.** A YouTube video lives on YouTube. You don't download the video. You only need to ensure the embed format is compatible with the target platform.
- **Very large file sets.** If you're migrating 50 GB+ of attachments, uploading them all through the target platform's API may be impractical. Consider hosting files in S3 or GCS and linking to them from the target instead.

The rule: **re-host anything stored on the source platform's infrastructure. Leave alone anything hosted independently.**

## Common Failure Patterns by Migration Path

Different platform pairings produce different breakage. Here are the patterns we see most often:

**Zendesk Guide → Confluence:** Every inline image must be downloaded from Zendesk's CDN, uploaded as a Confluence page attachment, and the `<img src>` rewritten to a Confluence attachment reference (`<ri:attachment ri:filename="..."/>`). CSV export preserves none of this. See our [Zendesk to Confluence guide](https://clonepartner.com/blog/blog/zendesk-guide-to-confluence-migration-the-ctos-technical-guide/).

**Confluence → Notion:** Confluence macros for embedded content (widget connector, draw.io diagrams, Gliffy) have no Notion equivalent. They degrade to plain text or disappear entirely. Confluence inline images stored as page attachments need to be downloaded and re-uploaded via Notion's file upload API — and Notion API file URLs expire after one hour, so download immediately upon extraction.

**Salesforce → HubSpot:** Ticket and contact attachments migrate last, after parent records are created. Load order matters — parent objects first, then child objects, then attachments. Reversing this creates orphaned files. See our [Salesforce to HubSpot guide](https://clonepartner.com/blog/blog/salesforce-to-hubspot-migration-pipeline-tickets-attachments/).

**Any platform → Intercom:** Intercom's content API rejects embed blocks with unrecognized URLs. Callouts and collapsible sections degrade or disappear. Test with a small batch before running the full migration.

## What Good Looks Like at Cutover

You're ready to launch when no page body points at source-hosted media (unless that dependency is intentional), every attachment has a target object recorded in your manifest, public/private access is explicit, and every unsupported embed has a documented fallback.

A 200-article knowledge base with clean markdown and a few screenshots per article? A developer can handle the image re-hosting in a day or two. A 5,000-article multilingual KB with embedded videos, Confluence macros, PDF attachments, and strict SEO requirements? That's weeks of engineering time just for the media layer.

The text is easy. The media is the work.

At ClonePartner, this is exactly the kind of problem we solve. Our migration scripts handle authenticated downloads, rate limit retries, format transformations, and produce validation reports showing exactly what moved, what didn't, and why. If you're staring at a migration with thousands of inline images or complex embeds, we can scope it in a 30-minute call.

> Images, attachments, and embeds are where migrations silently fail. Talk to our engineers about keeping every asset intact during your platform move.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Why do images break after migrating to a new platform?

Inline images are stored on the source platform's CDN. When you export article content, the <img src> tags still reference the old CDN. Once the source account is decommissioned, the subscription lapses, or presigned URLs expire (Notion URLs expire after one hour), every image breaks. The fix is to re-host: download each image, upload it to the target platform, and rewrite the reference in the article body.

### Can I use Regex to find and replace image URLs in my migration script?

Avoid Regex for HTML parsing. It fails on nested tags, malformed HTML, commented-out code, and attributes like srcset. Use a DOM parser like BeautifulSoup (Python) or Cheerio (Node.js) to accurately extract src and href attributes.

### How do I prevent attachments from being silently dropped during migration?

Compare file size limits between source and target platforms before migrating. For example, Confluence allows 100 MB attachments while Zendesk Guide caps at 20 MB. Build pre-upload validation into your script that flags oversized files, and run attachment count comparisons per record post-migration to catch any silent drops.

### Should I convert inline images to Base64 to simplify migration?

No. Base64 encoding increases file size by ~33%. A few images per article can push the text payload to 10+ MB, exceeding most API payload limits (often 1–5 MB). Even if accepted, rendering large Base64 strings severely degrades page load performance.

### How do I validate that all images and attachments migrated correctly?

Automate three checks: compare image counts per article between source and target, HTTP-request every image URL in migrated content to verify 200 OK responses, and compare attachment counts per record. For embeds, use a headless browser to render migrated articles and take screenshots. Don't decommission the source platform until validation is complete — keep it live for at least 48 hours.
