# Back Up Notion to GitHub with the Official API and GitHub Actions

> Canonical: https://vxi.me/en/posts/notion-api-github-actions-backup/
> Locale: en-us
> Published: 2026-03-27
> Updated: 2026-03-27
> Markdown: https://vxi.me/en/posts/notion-api-github-actions-backup.md

If you have ever tried backing up Notion seriously, you have probably hit one of two walls. Either you relied on unofficial browser tokens that eventually expired, or you got an export that looked successful until you noticed it only captured the top page while child pages, databases, attachments, and file names were still a mess.

The setup I ended up keeping is much more stable: use the official Notion API, give it a proper Internal Integration, and let GitHub Actions run the export on a schedule. That already removes three common problems at once: no more cookie-based auth, no dependence on a local machine staying online, and a clean place to extend the export flow with recursion and file renaming.

If you are searching for things like `Notion backup to GitHub`, `Notion GitHub Actions backup`, or `export Notion to Markdown with the official API`, that is exactly the problem this post is about.

## Why I stopped relying on unofficial token-based backups

Unofficial methods can work, especially when you just want a quick proof of concept. The trouble starts later.

You get a workflow that looks fine until one of these happens:

- the browser session expires
- the token gets invalidated
- a different IP or environment starts returning 401 or 403
- the export still runs, but the data scope is incomplete

The official API fixes the most fragile layer first:

- auth is based on an Internal Integration token, not a borrowed browser session
- page access is explicit, so you know why a page is readable or not
- GitHub Actions gives you a predictable scheduler and logs out of the box

What you still need to build yourself is the practical part: recursive traversal, attachment handling, and human-readable output.

## What the final backup flow looks like

The whole chain is easier to reason about if you look at it from top to bottom:

```text
Top-level Notion page
        ↓
Internal Integration authorization
        ↓
GitHub Actions scheduled run
        ↓
Python script using notion-client + notion2md
        ↓
Recursive traversal of child pages and child databases
        ↓
Markdown export + attachment download
        ↓
Zip extraction
        ↓
Rename block-id files to real page titles
        ↓
Commit everything back to GitHub
```

GitHub Actions is just the runner here. The real value comes from the Python layer in the middle, because the stock export path still does not give you a full recursive archive in the shape most people actually want.

## Get the official authorization right first

Start in the Notion developer console and create an Internal Integration:

1. Open [Notion Integrations](https://www.notion.so/my-integrations)
2. Create a new integration and choose `Internal`
3. Give it at least `Read content`
4. Copy the token that starts with `secret_`

That token alone is not enough. Notion permissions are explicit, which is good, but it also means the integration still cannot read a page until you connect it to that page.

So for the top-level page you want to back up:

1. open the page in Notion
2. click the `...` menu
3. choose `Add connections`
4. add the integration you just created

If that step is missing, the most common failure pattern is simple:

- the token looks valid
- the page ID looks valid
- the API still says the block cannot be found

You also need the root page ID. Usually that is the 32-character ID in the page URL, with or without dashes.

## On the GitHub side, two secrets are enough

I would not hardcode secrets inside the workflow file. The clean version is to store them as repository secrets:

- `NOTION_INTEGRATION_TOKEN`
- `NOTION_PAGE_ID`

Then create:

```text
.github/workflows/backup.yml
```

The outer workflow does not need to be complicated. It only has to:

- check out the repository
- set up Python
- install `notion-client` and `notion2md`
- run your export script
- commit the result if content changed

A minimal version looks like this:

```yaml
name: "Notion Official API Backup"

on:
  schedule:
    - cron: "0 0 * * *"
  workflow_dispatch:

jobs:
  backup:
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.10"

      - name: Install dependencies
        run: pip install notion2md notion-client

      - name: Run export
        env:
          NOTION_TOKEN: ${{ secrets.NOTION_INTEGRATION_TOKEN }}
          NOTION_PAGE_ID: ${{ secrets.NOTION_PAGE_ID }}
        run: python export_script.py

      - name: Commit changes
        run: |
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action Backup"
          git add .
          if git diff --staged --quiet; then
            echo "No content changes"
          else
            git commit -m "Auto backup: $(date +'%Y-%m-%d %H:%M:%S')"
            git push
          fi
```

That is enough to make the automation real. The interesting part is still the export script itself.

## The important part is recursive export, not the workflow wrapper

The default problem with official API-based export is not that it fails completely. It is that it stops too early.

If you only export one page, you often end up with:

- the root page exported
- child pages missing
- child databases treated as separate problems
- filenames based on block IDs instead of readable titles

That is why I prefer wrapping the export in a Python script that explicitly handles:

- Markdown export for the current page
- traversal through child pages
- traversal through pages inside child databases
- zip extraction for downloaded assets
- renaming from block IDs to page titles

This is the core shape worth keeping:

```python
import os
import zipfile
from notion_client import Client
from notion2md.exporter.block import MarkdownExporter

token = os.environ["NOTION_TOKEN"]
root_id = os.environ["NOTION_PAGE_ID"].replace("-", "")
notion = Client(auth=token)

def sanitize_title(title: str) -> str:
    safe = "".join(
        c for c in title
        if c.isalnum() or c in (" ", "-", "_", "（", "）", "【", "】", "、", ".")
    ).rstrip()
    return safe or "untitled"

def export_recursive(block_id: str, output_dir: str, title: str):
    block_id = block_id.replace("-", "")
    safe_title = sanitize_title(title)
    os.makedirs(output_dir, exist_ok=True)

    MarkdownExporter(block_id=block_id, output_path=output_dir, download=True).export()

    zip_path = os.path.join(output_dir, f"{block_id}.zip")
    if os.path.exists(zip_path):
        with zipfile.ZipFile(zip_path, "r") as zip_ref:
            zip_ref.extractall(output_dir)
        os.remove(zip_path)

    md_path = os.path.join(output_dir, f"{block_id}.md")
    new_md_path = os.path.join(output_dir, f"{safe_title}.md")
    if os.path.exists(md_path):
        if os.path.exists(new_md_path):
            os.remove(new_md_path)
        os.rename(md_path, new_md_path)

    results = []
    next_cursor = None
    while True:
        res = notion.blocks.children.list(block_id=block_id, start_cursor=next_cursor)
        results.extend(res.get("results", []))
        next_cursor = res.get("next_cursor")
        if not next_cursor:
            break

    child_dir = os.path.join(output_dir, safe_title)

    for block in results:
        if block["type"] == "child_page":
            export_recursive(block["id"], child_dir, block["child_page"]["title"])
        elif block["type"] == "child_database":
            db_dir = os.path.join(child_dir, sanitize_title(block["child_database"]["title"]))
            pages = notion.databases.query(database_id=block["id"]).get("results", [])
            for page in pages:
                page_title = "untitled"
                for _, prop in page["properties"].items():
                    if prop["type"] == "title" and prop["title"]:
                        page_title = prop["title"][0]["plain_text"]
                        break
                export_recursive(page["id"], db_dir, page_title)
```

That is the layer that turns a basic export into a backup workflow you can actually keep.

## Why this is more maintainable than `token_v2`

The main difference is not branding. It is operational stability.

`token_v2`-style approaches are really just temporary access borrowed from a logged-in browser session. An Internal Integration is an explicit service identity with explicit permissions. That is a much better fit for unattended automation.

Once you combine that with GitHub Actions, the whole thing starts feeling like an actual maintenance system instead of a workaround.

## Where this workflow is most likely to break

In practice, I would check these first:

- the token in Secrets is wrong or includes bad whitespace
- the integration was created, but never added to the target page
- the page ID was copied incorrectly
- the export script works for the root page but fails in recursion, zip extraction, or rename steps

There is also a rate-limit reality here. If your workspace has many nested pages or lots of attachments, a longer run time does not automatically mean the workflow is broken. Sometimes it is just doing the traversal and downloads slowly.

So if GitHub Actions sits in `Run` for a while, check the logs before assuming the whole pipeline is dead.

## What I would watch if I were maintaining this long term

Once the first successful export is done, the question changes from “can it work once” to “does it keep tracking real changes over time”.

I would keep an eye on:

- whether the repository still gets regular backup commits
- whether newly added child pages show up automatically
- whether database entries are exported as expected
- whether the rename logic behaves well with mixed languages and special characters

If I had to improve this further, I would probably do two small upgrades first:

1. make failure logs more explicit in the Action output
2. keep the backup repo structure clean so the archive stays readable over time

## Who this is for, and who it is not for

This is a good fit if:

- you use Notion as a serious knowledge base
- you want a stable backup path that does not depend on browser cookies
- you already use GitHub and do not mind letting a repository keep the export history

This is probably overkill if:

- you only export manually once in a while
- you do not want to touch GitHub Actions or Python at all
- you do not care about child pages, databases, or cleaner output structure

If all you need is an occasional one-off export, the built-in Notion export is still fine. The automation becomes worth it once your notes have grown enough that manual export is already turning into a habit you are likely to skip.
