Back Up Notion to GitHub with the Official API and GitHub Actions
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:
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:
- Open Notion Integrations
- Create a new integration and choose
Internal - Give it at least
Read content - 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:
- open the page in Notion
- click the
...menu - choose
Add connections - 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_TOKENNOTION_PAGE_ID
Then create:
.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-clientandnotion2md - run your export script
- commit the result if content changed
A minimal version looks like this:
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 "[email protected]"
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:
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:
- make failure logs more explicit in the Action output
- 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.