# Notion mit offizieller API und GitHub Actions nach GitHub sichern

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

Wer Notion schon einmal ernsthaft sichern wollte, ist wahrscheinlich auf eines von zwei Problemen gestoßen: Entweder basierte der Ablauf auf inoffiziellen Browser-Tokens, die irgendwann abliefen, oder der Export wirkte erfolgreich, bis auffiel, dass nur die oberste Seite erfasst wurde und Unterseiten, Datenbanken, Anhänge und Dateinamen weiterhin chaotisch waren.

Der Aufbau, den ich am Ende beibehalten habe, ist deutlich stabiler: die offizielle Notion API verwenden, eine saubere Internal Integration einrichten und GitHub Actions den Export nach Zeitplan ausführen lassen. Damit verschwinden drei typische Schwachstellen auf einmal: keine Cookie-basierte Authentifizierung mehr, keine Abhängigkeit von einem eingeschalteten lokalen Rechner und ein sauberer Ort, um Rekursion und Dateiumbenennung in den Export einzubauen.

Wenn du nach Dingen wie `Notion backup to GitHub`, `Notion GitHub Actions backup` oder `export Notion to Markdown with the official API` suchst, geht es in diesem Beitrag genau um dieses Problem.

## Warum ich inoffizielle tokenbasierte Backups nicht mehr verwende

Inoffizielle Methoden können funktionieren, besonders für einen schnellen Proof of Concept. Die Schwierigkeiten kommen später.

Der Workflow sieht gut aus, bis eines dieser Dinge passiert:

- die Browser-Session läuft ab
- das Token wird ungültig
- eine andere IP oder Umgebung liefert plötzlich 401 oder 403
- der Export läuft noch, erfasst aber nicht den vollständigen Datenumfang

Die offizielle API stabilisiert zuerst die fragilste Schicht:

- Auth basiert auf einem Internal-Integration-Token statt auf einer ausgeliehenen Browser-Session
- Seitenzugriff ist explizit, sodass klar ist, warum eine Seite lesbar ist oder nicht
- GitHub Actions liefert Scheduler und Logs direkt mit

Selbst bauen muss man weiterhin den praktischen Teil: rekursives Traversieren, Behandlung von Anhängen und lesbare Ausgabe.

## So sieht der fertige Backup-Flow aus

Die gesamte Kette lässt sich leichter verstehen, wenn man sie von oben nach unten betrachtet:

```text
Notion-Seite auf oberster Ebene
        ↓
Autorisierung der Internal Integration
        ↓
Geplanter GitHub-Actions-Lauf
        ↓
Python-Skript mit notion-client + notion2md
        ↓
Rekursive Traversierung von Unterseiten und Unterdatenbanken
        ↓
Markdown-Export + Download von Anhängen
        ↓
ZIP-Extraktion
        ↓
Block-ID-Dateien auf echte Seitentitel umbenennen
        ↓
Alles zurück nach GitHub committen
```

GitHub Actions ist hier nur der Runner. Der eigentliche Mehrwert steckt in der Python-Schicht dazwischen, weil der Standard-Exportpfad noch kein vollständiges rekursives Archiv in der Form liefert, die man in der Praxis meistens möchte.

## Zuerst die offizielle Autorisierung korrekt einrichten

Beginne in der Notion Developer Console und erstelle eine Internal Integration:

1. Öffne [Notion Integrations](https://www.notion.so/my-integrations)
2. Erstelle eine neue Integration und wähle `Internal`
3. Gib ihr mindestens `Read content`
4. Kopiere das Token, das mit `secret_` beginnt

Dieses Token allein reicht nicht. Notion-Berechtigungen sind explizit – was gut ist –, aber die Integration kann eine Seite erst lesen, nachdem sie mit dieser Seite verbunden wurde.

Für die oberste Seite, die gesichert werden soll:

1. Seite in Notion öffnen
2. auf das Menü `...` klicken
3. `Add connections` auswählen
4. die gerade erstellte Integration hinzufügen

Fehlt dieser Schritt, sieht das typische Fehlerbild so aus:

- Token scheint gültig
- Page ID scheint gültig
- API meldet trotzdem, dass der Block nicht gefunden wird

Zusätzlich brauchst du die ID der Root-Seite. In der Regel ist das die 32 Zeichen lange ID in der Seiten-URL, mit oder ohne Bindestriche.

## Auf GitHub reichen zwei Secrets

Secrets würde ich nicht direkt in die Workflow-Datei schreiben. Sauberer ist es, sie als Repository Secrets zu speichern:

- `NOTION_INTEGRATION_TOKEN`
- `NOTION_PAGE_ID`

Dann erstellst du:

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

Der äußere Workflow muss nicht kompliziert sein. Er soll lediglich:

- das Repository auschecken
- Python einrichten
- `notion-client` und `notion2md` installieren
- das Export-Skript ausführen
- das Ergebnis committen, wenn sich Inhalte geändert haben

Eine minimale Variante sieht so aus:

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

Damit ist die Automation bereits real. Der interessante Teil bleibt aber das Export-Skript.

## Entscheidend ist der rekursive Export, nicht der Workflow-Wrapper

Das Standardproblem eines Exports über die offizielle API ist nicht, dass er komplett scheitert. Er hört nur zu früh auf.

Wer nur eine Seite exportiert, landet häufig bei:

- Root-Seite exportiert
- Unterseiten fehlen
- Unterdatenbanken werden zu separaten Problemen
- Dateinamen basieren auf Block-IDs statt auf lesbaren Titeln

Darum bevorzuge ich ein Python-Skript um den Export herum, das explizit Folgendes behandelt:

- Markdown-Export der aktuellen Seite
- Traversierung durch Unterseiten
- Traversierung durch Seiten in Unterdatenbanken
- ZIP-Extraktion für heruntergeladene Assets
- Umbenennung von Block-IDs in Seitentitel

Diese Grundform lohnt sich zu behalten:

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

Diese Ebene macht aus einem einfachen Export einen Backup-Workflow, den man dauerhaft behalten kann.

## Warum das wartbarer ist als `token_v2`

Der wichtigste Unterschied ist nicht das Branding, sondern die Betriebsstabilität.

Ansätze mit `token_v2` sind im Grunde temporärer Zugriff, der aus einer angemeldeten Browser-Session übernommen wurde. Eine Internal Integration ist dagegen eine explizite Service-Identität mit expliziten Berechtigungen. Für unbeaufsichtigte Automation ist das deutlich passender.

Zusammen mit GitHub Actions wirkt das Ganze damit wie ein echtes Wartungssystem statt wie ein Workaround.

## Wo dieser Workflow am ehesten kaputtgeht

In der Praxis würde ich zuerst diese Punkte prüfen:

- das Token in Secrets ist falsch oder enthält störende Leerzeichen
- die Integration wurde erstellt, aber nie mit der Zielseite verbunden
- die Page ID wurde falsch kopiert
- das Export-Skript funktioniert für die Root-Seite, scheitert aber bei Rekursion, ZIP-Extraktion oder Umbenennung

Auch Rate Limits gehören zur Realität. Wenn ein Workspace viele verschachtelte Seiten oder große Mengen an Anhängen besitzt, bedeutet eine lange Laufzeit nicht automatisch, dass der Workflow kaputt ist. Manchmal braucht Traversierung und Download einfach Zeit.

Wenn GitHub Actions also längere Zeit in `Run` bleibt, zuerst die Logs ansehen, bevor die gesamte Pipeline für tot erklärt wird.

## Was ich bei langfristiger Wartung beobachten würde

Nach dem ersten erfolgreichen Export ändert sich die Frage von „funktioniert es einmal?“ zu „bildet es echte Änderungen dauerhaft ab?“.

Ich würde darauf achten:

- ob im Repository weiterhin regelmäßig Backup-Commits auftauchen
- ob neu angelegte Unterseiten automatisch erscheinen
- ob Datenbankeinträge wie erwartet exportiert werden
- ob die Umbenennungslogik mit gemischten Sprachen und Sonderzeichen sauber umgeht

Wenn ich das System weiter verbessern müsste, wären meine ersten zwei kleinen Upgrades:

1. Fehlermeldungen in der Action-Ausgabe expliziter machen
2. die Struktur des Backup-Repositories sauber halten, damit das Archiv langfristig lesbar bleibt

## Für wen dieser Ansatz gedacht ist – und für wen nicht

Gut passt er, wenn:

- Notion als ernsthafte Wissensbasis genutzt wird
- ein stabiler Backup-Pfad ohne Browser-Cookies gewünscht ist
- GitHub ohnehin genutzt wird und ein Repository die Export-Historie speichern darf

Wahrscheinlich übertrieben ist er, wenn:

- nur gelegentlich manuell exportiert wird
- GitHub Actions und Python komplett vermieden werden sollen
- Unterseiten, Datenbanken und eine saubere Ausgabestruktur keine Rolle spielen

Für einen gelegentlichen Einmal-Export reicht der eingebaute Notion-Export weiterhin. Automatisierung lohnt sich dann, wenn die eigenen Notizen so groß geworden sind, dass manueller Export bereits zu einer Routine wird, die man irgendwann auslässt.
