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

Confluence to Notion: Automated Link Rewriting Scripts

Fix broken Confluence links in Notion with page-ID mapping, CSV rewrite maps, and working Python and JavaScript scripts.

Abdul Wahab Abdul Wahab · · 18 min read
Confluence to Notion: Automated Link Rewriting Scripts
TALK TO AN ENGINEER

Planning a migration?

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

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

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)

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.

Info

Scope note: This guide is about automated link rewriting, not macro conversion or information-architecture redesign. For the broader migration path — importer caps, macro fidelity, hierarchy limits — start with our Confluence to Notion migration limits guide.

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:

<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)

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

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:

{
  "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:

{
  "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.

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:

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)

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

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:

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)

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:

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, some Server and Data Center exports emit ri:content-id instead of ri:content-title on page references:

<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)

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.

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)

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).

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.
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.

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)

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) For attachment-heavy migrations, see our guide on migrating images, attachments, and embeds.

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)

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)

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)

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)
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.

More from our Blog