---
title: "Confluence to Notion: Automated Link Rewriting Scripts"
slug: confluence-to-notion-automated-link-rewriting-scripts
date: 2026-10-20
author: Abdul Wahab
categories: [Confluence, Notion, Migration Guide]
excerpt: "Fix broken Confluence links in Notion with page-ID mapping, CSV rewrite maps, and working Python and JavaScript scripts."
tldr: "Use Confluence page IDs as the stable key, build an old→new mapping table, and rewrite links before import or via Notion's API after import."
canonical: https://clonepartner.com/blog/confluence-to-notion-automated-link-rewriting-scripts
---

# Confluence to Notion: Automated Link Rewriting Scripts


# Confluence to Notion: Automated Link Rewriting Scripts

Broken Confluence links in Notion are a mapping problem, not a Notion problem. Confluence identifies pages by **page ID** and **title within a space**. Notion identifies every object by a **32-character UUID**. When content moves between the two platforms, the addressing schemes have no overlap — every internal link must be resolved against an explicit old→new lookup table.

The same Confluence page can appear as a title URL (`/display/ENG/API+Guide`), a `pageId` URL (`/pages/viewpage.action?pageId=12345`), a tiny link (`/x/AbCd`), or the Cloud format (`/wiki/spaces/ENG/pages/12345/API+Guide`). On the Notion side, internal references resolve to page UUIDs. Without a mapping table connecting every source identifier to its Notion destination, automated rewriting is impossible. ([support.atlassian.com](https://support.atlassian.com/confluence/kb/the-differences-between-various-url-formats-for-a-confluence-page/))

This guide covers the link formats on both platforms, shows how to build the mapping table, provides working Python and JavaScript scripts, and explains the edge cases that break simple solutions.

> [!NOTE]
> **Scope note:** This guide is about automated link rewriting, not [macro conversion](https://clonepartner.com/blog/blog/confluence-macro-mapping-reference-migrating-dynamic-content) or information-architecture redesign. For the broader migration path — importer caps, macro fidelity, hierarchy limits — start with our [Confluence to Notion migration limits guide](https://clonepartner.com/blog/blog/confluence-to-notion-migration-limits-macros-broken-links).

## Why Do Confluence Links Break in Notion?

Confluence internal links break because no importer — native or third-party — can automatically resolve Confluence's addressing scheme to Notion's UUIDs without an explicit mapping table.

In Confluence's XHTML storage format, an internal link is an `<ac:link>` XML element that references the target page by title:

```xml
<ac:link>
  <ri:page ri:space-key="ENG" ri:content-title="Deployment Runbook" />
  <ac:plain-text-link-body>
    <![CDATA[our deployment process]]>
  </ac:plain-text-link-body>
</ac:link>
```

When you export to HTML (the format Notion's ZIP importer accepts), Atlassian converts these macros into `href` links pointing to `viewpage.action?pageId=123456` or title-based paths. Notion's native importer can resolve some of these within a single-space import, but cross-space links, partial imports, and API-driven migrations consistently leave links broken — either stripped entirely, converted to dead URLs, or left as plain text. ([notion.com](https://www.notion.com/en-gb/help/import-from-confluence))

The problem scales fast. A 500-page wiki can easily contain 3,000–5,000 internal cross-references. Manual fixing is not realistic.

## How Confluence and Notion Store Internal Links

Understanding the link formats on both sides is necessary before writing rewrite logic.

**Confluence storage format** uses XML namespaces with `ac:` and `ri:` prefixes. The actual namespace URIs differ by deployment type:

| Deployment | `ac:` namespace URI | `ri:` namespace URI |
|---|---|---|
| Cloud | `http://atlassian.com/content` | `http://atlassian.com/resource/identifier` |
| Server / DC (7.x) | `http://atlassian.com/content` | `http://atlassian.com/resource/identifier` |
| Server (legacy 5.x–6.x) | `http://www.atlassian.com/schema/confluence/4/ac/` | `http://www.atlassian.com/schema/confluence/4/ri/` |

**Always inspect your export's namespace declarations before writing parser code.** If you have a Server 5.x or 6.x export, the namespace URIs in the `lxml` examples below must be updated to match the legacy schema values. Run `grep -m5 'xmlns'` on your exported XML to confirm which URIs are present.

The key storage format elements:

- **`ri:content-title`** references the target page by title — always required. If the title changes, the link breaks even inside Confluence.
- **`ri:space-key`** is optional. When omitted, the link is relative to the current page's space.
- **`ri:content-id`** appears in some Server and Data Center exports instead of `ri:content-title`. It holds the numeric Confluence page ID directly. This is the preferred key for rewrites when present — see the dedicated section below.
- **`ac:plain-text-link-body`** or **`ac:link-body`** holds the display text. If absent, Confluence renders the target page's title.
- **Anchor links** use the `ac:anchor` attribute on the `<ac:link>` element.
- **Attachment links** replace `<ri:page>` with `<ri:attachment ri:filename="report.pdf">`.

External links use standard `<a href="...">` tags and typically survive migration intact.

**Notion has two link mechanisms** in its API, and choosing the right one affects rendering:

**Inline text links** attach a URL to a span of text inside a `rich_text` array:

```json
{
  "type": "text",
  "text": {
    "content": "our deployment process",
    "link": { "url": "https://www.notion.so/workspace/page-id" }
  }
}
```

**Page mentions** use the `mention` rich text type and reference the target page by UUID. These render as a clickable pill with the page title and update automatically if the page is renamed:

```json
{
  "type": "mention",
  "mention": {
    "type": "page",
    "page": { "id": "3c612f56-fdd0-4a30-a4d6-bda7d7426309" }
  }
}
```

For migration, **inline text links are the better fit** — they preserve the original display text from Confluence. Page mentions overwrite the display text with the Notion page title, which may differ from the original anchor text.

## Decoding Confluence Tiny Links

Confluence tiny links (`/x/AbCd`) are frequently encountered in source content but rarely documented. A tiny link is a base64-encoded representation of the Confluence page ID. Specifically, Confluence encodes the numeric page ID as a big-endian 64-bit integer, base64-encodes that byte sequence, and strips trailing `=` padding.

To decode a tiny link and recover the underlying page ID:

```python
import base64
import struct

def decode_tiny_link(tiny_path: str) -> int:
    """
    Decode a Confluence tiny link path like '/x/AbCd' to a numeric page ID.
    tiny_path should be the base64 segment only (e.g. 'AbCd').
    """
    # Add padding back before decoding
    token = tiny_path.strip('/')
    padding = (4 - len(token) % 4) % 4
    decoded = base64.b64decode(token + '=' * padding)
    # Confluence packs the page ID as a big-endian signed 64-bit integer
    page_id = struct.unpack('>q', decoded.rjust(8, b'\x00'))[0]
    return page_id

# Example: /x/BQAD maps to page ID 263429
print(decode_tiny_link('BQAD'))  # → 263429
```

With the page ID recovered, the lookup key becomes `('page_id', str(page_id))` — the same primary key used for all other URL formats. Add tiny link decoding as the first normalization step in your `canonical_key()` function.

## Building the Rewrite Map

A **rewrite map** is a table that pairs every old Confluence identifier with the final Notion destination. This is the critical data structure — everything downstream depends on it.

**Use Confluence page IDs as your canonical key.** Atlassian states that `pageId` URLs remain static even if a page is renamed or moved, while title-based URLs can change. That makes page IDs the only stable primary key for automated rewrites. Title matching should be a fallback, not the main strategy. ([support.atlassian.com](https://support.atlassian.com/confluence/kb/the-differences-between-various-url-formats-for-a-confluence-page/))

Store every URL variant you observe in the source data — pretty URLs, Cloud `/pages/{id}` URLs, tiny links, and attachment paths:

```csv
confluence_page_id,observed_old_url,confluence_pretty_url,confluence_tiny_url,notion_page_id,notion_url,status
12345,https://source.atlassian.net/wiki/spaces/ENG/pages/12345/API+Guide,/display/ENG/API+Guide,/x/AbCd,3c612f56-fdd0-4a30-a4d6-bda7d7426309,https://www.notion.so/p/3c612f56fdd04a30a4d6bda7d7426309,migrated
```

**Build the map during migration if possible.** When you create each Notion page, record the `{confluence_id, title, space_key, notion_page_id}` tuple immediately. If your pages are already in Notion without a map, you must walk the Notion page tree and match on title — a significantly less reliable fallback.

> [!WARNING]
> **Title collisions are real.** Confluence allows duplicate page titles across spaces, and even within a space if pages sit in different parent hierarchies (Server/DC). Your mapping table should key on `(space_key, title)` — not title alone. A flat title lookup across a merged workspace will produce wrong rewrites.

**Multi-space migration ordering matters.** If Space A contains links to pages in Space B, you must migrate Space B first and have its Notion page IDs in the map before processing Space A's content. If you migrate in the wrong order, cross-space links will fall into the unresolved queue even though the target page eventually exists. Plan your migration order as a dependency graph: identify all cross-space link targets, topologically sort spaces by dependency, migrate in that order.

**Extract pages from Confluence** by paginating through the REST API:

```python
import requests

def get_confluence_pages(base_url, space_key, auth):
    """Fetch all pages in a Confluence space with their IDs and titles."""
    pages = []
    start = 0
    limit = 100
    while True:
        resp = requests.get(
            f"{base_url}/rest/api/content",
            params={"spaceKey": space_key, "type": "page",
                    "start": start, "limit": limit,
                    "expand": "ancestors"},
            auth=auth,
        )
        resp.raise_for_status()
        data = resp.json()
        for p in data["results"]:
            pages.append({
                "confluence_id": p["id"],
                "title": p["title"],
                "space_key": space_key,
            })
        if data["size"] < limit:
            break
        start += limit
    return pages
```

> [!WARNING]
> **Do not use one expanded CQL query as your whole crawler.** Atlassian's CQL docs note that `expand=body.storage.value` can silently cap results at 50 even if you request a larger limit. Use CQL to discover content IDs, then fetch bodies separately. ([developer.atlassian.com](https://developer.atlassian.com/cloud/confluence/advanced-searching-using-cql/))

## Handling Blog Post Links

Confluence HTML exports exclude blog posts entirely. Notion's ZIP importer also skips them. If internal links point to blog post entries, an export-based rewrite pass will never see the source or destination content.

The fix is to fetch blog posts separately via the REST API using `type=blogpost` and include them in your mapping table:

```python
def get_confluence_blogposts(base_url, space_key, auth):
    """Fetch all blog posts in a Confluence space."""
    blogposts = []
    start = 0
    limit = 100
    while True:
        resp = requests.get(
            f"{base_url}/rest/api/content",
            params={"spaceKey": space_key, "type": "blogpost",
                    "start": start, "limit": limit,
                    "expand": "ancestors,metadata.labels"},
            auth=auth,
        )
        resp.raise_for_status()
        data = resp.json()
        for p in data["results"]:
            blogposts.append({
                "confluence_id": p["id"],
                "title": p["title"],
                "space_key": space_key,
                "posting_day": p.get("history", {}).get("createdDate", "")[:10],
                "type": "blogpost",
            })
        if data["size"] < limit:
            break
        start += limit
    return blogposts
```

Blog posts in Confluence storage format use `<ri:blog-post ri:space-key="ENG" ri:posting-day="2023-08-15" ri:content-title="Q3 Retrospective" />` as the link target element (instead of `<ri:page>`). Your XML parser must handle this element type separately. Map each `(space_key, posting_day, content_title)` tuple to its Notion destination, since blog posts can share titles across different dates.

If blog posts are being migrated to Notion as regular pages (the most common approach), add them to the same mapping CSV with a `type` column set to `blogpost`. The rewrite logic is otherwise identical once the page ID is resolved.

## Handling `ri:content-id` References

When [migrating from Confluence Data Center to Notion](https://clonepartner.com/blog/blog/confluence-data-center-to-notion-migration-the-on-prem-exit-path), some Server and Data Center exports emit `ri:content-id` instead of `ri:content-title` on page references:

```xml
<ac:link>
  <ri:page ri:content-id="12345" />
  <ac:plain-text-link-body><![CDATA[See the runbook]]></ac:plain-text-link-body>
</ac:link>
```

`ri:content-id` holds the numeric Confluence page ID directly — the same value used in `viewpage.action?pageId=12345` URLs. This is actually the best case for automated rewriting: no title lookup or fuzzy matching required. When your parser encounters `ri:content-id`, extract it as a string and look it up directly in the `('page_id', id)` slot of your mapping table.

If the same export contains a mix of `ri:content-id` and `ri:content-title` references (which happens when content was created across multiple Confluence versions), your parser must handle both. Check for `ri:content-id` first; fall through to `ri:content-title` only when the ID attribute is absent.

## When to Rewrite: Before Import, During Migration, or After

The right rewrite phase depends on which system currently has the most complete, machine-readable version of the content.

| Rewrite phase | Best for | Main advantage | Main trap |
|---|---|---|---|
| Pre-import | HTML or Markdown exports | One deterministic rewrite pass | HTML export misses blog posts and comments |
| In-flight | Custom API migrations | Pages land with correct links on first write | Must maintain old→new map during creation |
| Post-import | Pages already in Notion | Works on existing content | Unsupported blocks may surface as `<unknown>` |

**Pre-import** is the cleanest option when content still exists as exported HTML or Markdown. **In-flight** is best when your migration engine already knows both the source and destination IDs at page-creation time — the first version of the page lands with correct links. **Post-import** is now practical because Notion exposes markdown read and write endpoints (`GET /v1/pages/:page_id/markdown` and `PATCH /v1/pages/:page_id/markdown`) and supports `allow_async: true` for background processing on larger jobs. ([developers.notion.com](https://developers.notion.com/guides/data-apis/working-with-markdown-content))

The markdown route is not universal. The table below shows which Notion block types survive the markdown round-trip cleanly and which require block-level API calls:

| Block type | Markdown round-trip behavior | Recommended handler |
|---|---|---|
| Paragraph, heading, bulleted/numbered list | Clean — round-trips correctly | Markdown API |
| Code block, quote | Clean — round-trips correctly | Markdown API |
| Toggle | Partially — nesting may flatten | Markdown API with spot-check |
| Table | Partially — basic tables survive, complex nesting may not | Markdown API with spot-check |
| Callout | Returns as `<unknown>` | Block-level PATCH |
| Bookmark / link preview | Returns as `<unknown>` | Block-level PATCH |
| Embed | Returns as `<unknown>` | Block-level PATCH |
| Breadcrumb | Returns as `<unknown>` | Block-level PATCH |
| Template button | Returns as `<unknown>` | Block-level PATCH |
| Synced block | Returns as `<unknown>` | Block-level PATCH |

If a page contains any block type marked `<unknown>`, skip the markdown route entirely and use block-level `PATCH` calls for that page. Round-tripping through markdown will corrupt those blocks.

**Decision rule for choosing the handler:**
1. Fetch the page markdown via `GET /v1/pages/{page_id}/markdown`.
2. If the response contains any `<unknown>` tag, route to the block-level handler.
3. Otherwise, proceed with the markdown rewrite path.

## Python: Rewrite Links Before Import

This script rewrites Markdown-format links using a CSV map. It prefers Confluence page IDs as the primary lookup key, decodes tiny links, falls back to normalized paths when no page ID is available, and sends unresolved fragments to a review queue. ([support.atlassian.com](https://support.atlassian.com/confluence/kb/the-differences-between-various-url-formats-for-a-confluence-page/))

```python
import base64
import csv
import re
import struct
from urllib.parse import parse_qs, urlsplit

MD_LINK = re.compile(r'\[([^\]]+)\]\(([^)]+)\)')
TINY_LINK = re.compile(r'^/x/([A-Za-z0-9+/=]+)')

def decode_tiny_link_path(path: str):
    """Return the page ID embedded in a Confluence tiny link path, or None."""
    m = TINY_LINK.match(path)
    if not m:
        return None
    token = m.group(1)
    padding = (4 - len(token) % 4) % 4
    try:
        decoded = base64.b64decode(token + '=' * padding)
        page_id = struct.unpack('>q', decoded.rjust(8, b'\x00'))[0]
        return str(page_id)
    except Exception:
        return None

def canonical_key(url: str):
    parts = urlsplit(url)
    qs = parse_qs(parts.query)

    # Tiny link: /x/AbCd
    tiny_id = decode_tiny_link_path(parts.path)
    if tiny_id:
        return ('page_id', tiny_id, parts.fragment)

    # pageId query parameter
    if 'pageId' in qs:
        return ('page_id', qs['pageId'][0], parts.fragment)

    # Cloud URL: /wiki/spaces/ENG/pages/12345/...
    m = re.search(r'/spaces/[^/]+/pages/(\d+)', parts.path)
    if m:
        return ('page_id', m.group(1), parts.fragment)

    return ('path', parts.path.rstrip('/').lower(), parts.fragment)

def load_map(csv_path: str):
    lookup = {}
    with open(csv_path, newline='', encoding='utf-8') as f:
        for row in csv.DictReader(f):
            if row.get('confluence_page_id'):
                lookup[('page_id', row['confluence_page_id'])] = row
            for field in ('observed_old_url', 'confluence_pretty_url',
                          'confluence_tiny_url'):
                if row.get(field):
                    kind, value, _ = canonical_key(row[field])
                    lookup[(kind, value)] = row
    return lookup

def rewrite_url(url: str, lookup: dict, unresolved: list):
    kind, value, fragment = canonical_key(url)
    row = lookup.get((kind, value))
    if not row:
        return url

    target = row['notion_url']
    if fragment:
        unresolved.append({
            'source_url': url,
            'confluence_page_id': row.get('confluence_page_id'),
            'fragment': fragment,
            'notion_url': target,
        })
    return target

def rewrite_markdown(markdown: str, lookup: dict):
    unresolved = []
    def repl(match):
        text, url = match.groups()
        return f'[{text}]({rewrite_url(url, lookup, unresolved)})'
    rewritten = MD_LINK.sub(repl, markdown)
    return rewritten, unresolved

lookup = load_map('rewrite-map.csv')
rewritten, unresolved = rewrite_markdown(source_markdown, lookup)
```

The regex is intentionally simple. If your pages mix Markdown and embedded HTML, run the same `rewrite_url()` helper inside an HTML parser to catch raw `<a>` tags. Keep the `unresolved` list as a real artifact — it becomes your QA queue, not a log nobody reads.

## Parsing Confluence Storage Format Directly

If you are working with the Confluence REST API rather than an HTML export, you need to parse the XHTML storage format to extract `<ac:link>` elements. Use `lxml` for namespace-aware XML parsing. **Verify the namespace URIs against your export before running this code** — Server 5.x/6.x exports use different URIs than the values below (see the namespace table earlier in this guide).

```python
from lxml import etree

# Verified namespace URIs for Confluence Cloud and Server 7.x / Data Center
# For Server 5.x–6.x, replace with:
#   ac: http://www.atlassian.com/schema/confluence/4/ac/
#   ri: http://www.atlassian.com/schema/confluence/4/ri/
NAMESPACES = {
    "ac": "http://atlassian.com/content",
    "ri": "http://atlassian.com/resource/identifier",
}

def extract_internal_links(storage_body):
    """
    Parse Confluence storage format and return internal link targets.
    Handles both ri:content-title and ri:content-id references,
    and both page and blog-post link types.
    """
    wrapped = (
        f"<root xmlns:ac=\"{NAMESPACES['ac']}\" "
        f"xmlns:ri=\"{NAMESPACES['ri']}\">"
        f"{storage_body}</root>"
    )
    tree = etree.fromstring(wrapped.encode("utf-8"))
    links = []

    for ac_link in tree.iter(f"{{{NAMESPACES['ac']}}}link"):
        # Standard page reference
        ri_page = ac_link.find(f"{{{NAMESPACES['ri']}}}page")
        ri_blog = ac_link.find(f"{{{NAMESPACES['ri']}}}blog-post")
        target_el = ri_page if ri_page is not None else ri_blog
        if target_el is None:
            continue

        # Prefer ri:content-id (numeric page ID) over ri:content-title
        content_id = target_el.get(f"{{{NAMESPACES['ri']}}}content-id")
        title = target_el.get(f"{{{NAMESPACES['ri']}}}content-title")
        space = target_el.get(f"{{{NAMESPACES['ri']}}}space-key")
        posting_day = target_el.get(f"{{{NAMESPACES['ri']}}}posting-day")
        anchor = ac_link.get(f"{{{NAMESPACES['ac']}}}anchor")

        body_el = ac_link.find(f"{{{NAMESPACES['ac']}}}plain-text-link-body")
        display = (body_el.text
                   if body_el is not None and body_el.text
                   else title)

        links.append({
            "content_id": content_id,   # present in some Server/DC exports
            "title": title,
            "space_key": space,
            "posting_day": posting_day, # blog posts only
            "link_type": "blogpost" if ri_blog is not None else "page",
            "anchor": anchor,
            "display_text": display,
        })
    return links
```

## Fuzzy Matching for Renamed Pages

When a Confluence page was renamed between the time links were written and the time of migration, neither the page ID (if unavailable) nor the current title will match. Title-based fallback will fail silently and produce an unresolved entry.

A practical fuzzy matching strategy for the title-fallback layer:

1. **Normalize both sides** before comparison: lowercase, strip leading/trailing whitespace, collapse multiple spaces, remove punctuation. This catches the most common drift (capitalization, trailing periods, extra spaces).
2. **Levenshtein distance threshold**: accept a match if edit distance ÷ max(len(a), len(b)) ≤ 0.15 (i.e., ≤15% of characters changed). This threshold catches single-word renames and typo corrections while rejecting unrelated pages. Use the `python-Levenshtein` library for performance on large page sets.
3. **Require space key agreement**: only fuzzy-match within the same space. Cross-space fuzzy matching produces too many false positives.
4. **Human review queue**: log all fuzzy matches with their similarity score and both the old and new titles. Do not apply fuzzy matches silently — route them to a review CSV for human confirmation before writing.

```python
import Levenshtein

def normalize_title(title: str) -> str:
    import re
    return re.sub(r'\s+', ' ', title.lower().strip().translate(
        str.maketrans('', '', r'.,;:!?-_')
    ))

def fuzzy_title_lookup(title: str, space_key: str, title_index: dict,
                        threshold: float = 0.15):
    """
    title_index: dict mapping (space_key, normalized_title) -> mapping row
    Returns (row, similarity_score) or (None, None)
    """
    norm = normalize_title(title)
    exact_key = (space_key, norm)
    if exact_key in title_index:
        return title_index[exact_key], 1.0

    best_row, best_score = None, None
    for (sk, nt), row in title_index.items():
        if sk != space_key:
            continue
        max_len = max(len(norm), len(nt))
        if max_len == 0:
            continue
        dist = Levenshtein.distance(norm, nt)
        score = 1.0 - dist / max_len
        if score >= (1.0 - threshold):
            if best_score is None or score > best_score:
                best_row, best_score = row, score
    return best_row, best_score
```

All fuzzy matches should be written to a separate `fuzzy-matches.csv` with columns: `original_title`, `matched_title`, `similarity_score`, `space_key`, `notion_url`. Treat this as a mandatory human review step before cutover.

## JavaScript: Fix Links After Pages Are in Notion

If pages already exist in Notion, you can fetch their markdown representation, rewrite locally, and send it back. Notion's markdown API supports both `replace_content` (full page replacement) and `update_content` (targeted search-and-replace). ([developers.notion.com](https://developers.notion.com/guides/data-apis/working-with-markdown-content))

```js
const headers = {
  'Authorization': `Bearer ${process.env.NOTION_API_KEY}`,
  'Notion-Version': '2022-06-28', // check docs for the version required by the markdown endpoints
  'Content-Type': 'application/json',
};

async function getMarkdown(pageId) {
  const res = await fetch(
    `https://api.notion.com/v1/pages/${pageId}/markdown`,
    { headers }
  );
  if (!res.ok) throw new Error(await res.text());
  return res.json();
}

async function replaceMarkdown(pageId, newMarkdown) {
  const res = await fetch(
    `https://api.notion.com/v1/pages/${pageId}/markdown`,
    {
      method: 'PATCH',
      headers,
      body: JSON.stringify({
        allow_async: true,
        type: 'replace_content',
        replace_content: { new_str: newMarkdown },
      }),
    }
  );
  if (!res.ok) throw new Error(await res.text());
  return res.json();
}

function rewriteAll(markdown, replacements) {
  let out = markdown;
  for (const { from, to } of replacements) {
    out = out.split(from).join(to);
  }
  return out;
}

async function rewritePage(pageId, replacements) {
  const { markdown } = await getMarkdown(pageId);

  // If the page contains unsupported blocks, skip markdown route entirely
  if (markdown.includes('<unknown>')) {
    console.warn(`Page ${pageId} contains <unknown> blocks — routing to block-level handler`);
    return { skipped: true, pageId };
  }

  const rewritten = rewriteAll(markdown, replacements);
  return replaceMarkdown(pageId, rewritten);
}
```

Use `replace_content` only when your migration system owns the full page body and can regenerate it safely. If the fetched markdown contains `<unknown>` tags, skip that page and route it to a block-level handler — round-tripping through markdown will corrupt those blocks.

For block-level handling, the pattern is: call `GET /v1/blocks/{page_id}/children` to retrieve all blocks on a page, inspect each block's `rich_text` array for URLs matching your old Confluence domain, build the corrected `rich_text` payload by swapping the URL via your lookup table, and `PATCH /v1/blocks/{block_id}` to write it back. Remember that Notion does not return nested content in a single response — if a block has `has_children: true`, you must recursively fetch its children. Links hidden inside toggles, tables, or nested bullet lists will stay broken if you only read top-level blocks.

## Attachments, Anchors, and Cross-Space References

Treat these as separate migration classes. They fail for different reasons and need different rewrite logic.

**Attachment links** (`<ri:attachment>`) need their own mapping table: `{confluence_page_id, filename} → {new_file_url}`. Confluence attachment URLs require authentication, and Notion's external URL import expects public SSL URLs with usable `Content-Type` and `Content-Length` metadata. If the source wiki will be retired, download the binaries and upload them into Notion directly — paid workspaces support files up to 5 GB via multi-part upload. ([developers.notion.com](https://developers.notion.com/guides/data-apis/uploading-small-files)) For attachment-heavy migrations, see our [guide on migrating images, attachments, and embeds](https://clonepartner.com/blog/blog/how-to-migrate-images-attachments-embeds-without-broken-links).

**Anchor links** (`#heading-name`) have no clean Notion equivalent. Notion does not support URL-fragment navigation to a specific heading. You can link to the target page, but reaching a specific heading requires mapping to the heading block's UUID — a second mapping layer that most migrations don't justify. The conservative approach: strip the anchor, link to the page, and log the fragment for manual review.

**Cross-space links** are the most common collision source. A link from Space A to a page in Space B uses an explicit `ri:space-key`. If both spaces were migrated, your mapping table handles it — the lookup key is `(space_key, title)` or `(space_key, page_id)`. If only one space was migrated, those links are dead and should be flagged, not silently dropped.

**Deleted or restricted pages** will have no entry in your mapping table. Your script must handle missing lookups gracefully and log every unresolved reference. Ideally, flag these in the output so users know the target is permanently gone rather than just broken.

## Rate Limits and API Constraints

**Notion** enforces rate limits of **600 requests per minute** on Business and Enterprise plans, and **180 requests per minute** on other plans. Back off using the `Retry-After` header on HTTP 429 and 529 responses. Keep each write payload under the documented ceiling of **1,000 block elements** and **500 KB**. ([developers.notion.com](https://developers.notion.com/reference/request-limits))

Notion's block architecture makes link rewriting API-intensive. A page with complex nesting (toggles containing tables containing bulleted lists) can consume dozens of GET calls just to traverse, plus a PATCH for each block with a broken link. A rewrite pass on 1,000 pages generating roughly 5,000 API calls takes about 28 minutes of sustained throughput on a Business plan — not counting retries.

**Confluence** REST API is paginated. Handle pagination cursors correctly, or you will silently drop pages from your map. As noted above, CQL queries with `expand=body.storage.value` can silently cap results at 50. ([developer.atlassian.com](https://developer.atlassian.com/cloud/confluence/advanced-searching-using-cql/))

Confluence HTML exports also have gaps: blog posts are excluded, page comments are not exported, and Notion's ZIP importer also skips comments. If links live in blog posts or comments, an HTML-export rewrite pass will never see them. ([support.atlassian.com](https://support.atlassian.com/confluence-cloud/docs/export-content-to-word-pdf-html-and-xml))

## Validation Before Cutover

A rewrite job is done when the unresolved set is boring, not when the script finishes.

- **Record-count parity:** The number of links extracted from Confluence should equal the number of rewrite attempts. Log every unmatched reference.
- **Page existence check:** For every rewritten URL, confirm the target Notion page ID returns HTTP 200 from the Retrieve Page endpoint. A 404 means the target was trashed, not shared with the integration, or never migrated.
- **Cover every URL family:** Test samples from title-based URLs, `pageId` URLs, Cloud `/spaces/.../pages/{id}` URLs, tiny links (decoded), and `ri:content-id` references. Multiple URL families can point at the same Confluence page.
- **Validate blog post links separately:** Because blog posts require a different extraction path, run a dedicated existence check on all rewritten blog post targets.
- **Review fuzzy match queue:** All entries in `fuzzy-matches.csv` must be human-confirmed before cutover. Do not treat automated fuzzy matches as resolved.
- **Sample spot-checks:** Open 10–20 rewritten pages in the Notion UI and click through links. Automated checks miss rendering issues that only surface in the browser.
- **Dead link report:** Generate a CSV of all links that could not be resolved — this is your remediation queue, not a log to ignore.
- **Check after pages are shared.** Notion says imported pages land private to the importing user. Run link validation after pages are moved to their final teamspaces, or you may miss real permission-related failures. ([notion.com](https://www.notion.com/en-gb/help/import-from-confluence))

> [!TIP]
> **Run the rewrite on a staging copy first.** Duplicate your Notion workspace (or a subset) and run the full rewrite before touching production. One wrong regex in the URL matcher can corrupt links at scale, and Notion has no bulk undo.

## Failure Mode Taxonomy

The table below classifies link rewrite failures by type, root cause, and recommended resolution. Use this as a diagnostic checklist when your unresolved queue is larger than expected.

| Failure mode | Root cause | Detection | Resolution |
|---|---|---|---|
| Missing page ID in map | Page not crawled (pagination bug, CQL cap) | Count map entries vs. Confluence page count | Re-crawl with fixed pagination; check CQL `expand` cap |
| Title mismatch | Page renamed after links were written | Fuzzy match queue has entries | Human review of fuzzy-matches.csv |
| Duplicate title collision | Same title in multiple spaces or hierarchies | Rewrites pointing to wrong page | Key on `(space_key, title)`, not title alone |
| Cross-space link to unmigrated space | Partial migration | All links with that space key unresolved | Migrate missing space first, then reprocess |
| Tiny link not decoded | Missing decode step in `canonical_key()` | `/x/` paths remain in unresolved queue | Add tiny link decoder before other URL checks |
| `ri:content-id` ignored | Parser only handles `ri:content-title` | Server/DC links all unresolved | Update parser to check `ri:content-id` first |
| Blog post link | HTML export excludes blog posts | Links with `ri:blog-post` elements all unresolved | Fetch blog posts via API; add to mapping table |
| Anchor stripped silently | Fragment lost in rewrite | Anchor references missing from output | Log fragment in unresolved queue; link to page |
| `<unknown>` block corruption | Markdown round-trip on unsupported block type | `<unknown>` tags in fetched markdown | Route page to block-level handler |
| Page not shared with integration | Target page exists but returns 404 | Existence check fails post-migration | Move pages to teamspace before running validation |
| Wrong migration order | Space A migrated before Space B | Cross-space links unresolved despite target existing | Plan migration order as dependency graph |

## Frequently asked questions

### Why do internal links break when migrating from Confluence to Notion?

Confluence stores internal links as XML elements referencing pages by title and space key. Notion identifies pages by UUID. No importer can automatically resolve one addressing scheme to the other without an explicit mapping table connecting every source page to its Notion ID.

### Should I match Confluence pages by title or page ID?

Page ID. Atlassian documents pageId URLs as stable across renames and page moves, while title-based URLs can change. Use title matching only as a fallback when page IDs are not available.

### Does Notion's native Confluence importer fix internal links?

Not reliably. The native importer can resolve some links within a single-space import, but cross-space links, partial imports, and large batched imports consistently leave links broken, stripped, or converted to plain text.

### Can I rewrite links after pages are already in Notion?

Yes. Notion exposes markdown read and write endpoints for page content, and larger rewrites can run async. Pages with unsupported block types (bookmarks, embeds, template buttons) need block-level PATCH calls instead of the markdown route.

### What happens to Confluence anchor links in Notion?

Notion does not support URL-fragment navigation to a specific heading. Anchor links can be rewritten to point to the target page, but reaching a specific heading requires mapping to individual Notion block UUIDs — a second mapping layer most migrations don't justify.
