Notion APIの404エラーに関するイラスト。男性キャラクターが、データベースIDとデータソースIDの2種類のIDと権限の有無によって404が返される「罠」を2x2マトリクスで視覚的に解説しています。

はじめに

ブログ記事を WordPress に公開したあと、Notion 側の管理DBのステータスを自動更新する——という、ごく普通の後処理を実装していました。

Notion のデータベースIDは分かっています。トークンも設定済みです。クエリを投げます。

HTTP Error 404: Not Found

IDは合っているはずなのに 404。ここから原因にたどり着くまでに、2つの別々の罠を踏んでいました。どちらも同じ 404 を返すので、片方を直しても症状が変わらず、余計に混乱します。

こんな人におすすめ

  • Notion API を叩く自動化スクリプトを書いている方
  • MCP 経由で Notion を触っていて、同じ処理をスクリプト化したい方
  • 「IDは合っているのに 404」で止まっている方

罠1:IDには2種類ある

Notion のデータベースを扱っていると、見た目の似た2つのIDに出会います。

  • database_id1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
  • data source id9f8e7d6c-5b4a-4392-8172-6f5e4d3c2b1a

前者はデータベースそのもののIDで、Notion のページURLに現れます。後者はデータベース配下の「データソース(コレクション)」のIDです。1つのデータベースが複数のデータソースを持つこともあります。

やっかいなのは、MCP サーバー経由で Notion を触ると、後者のほうが目に入りやすいことです。データソースは collection://9f8e7d6c-... という形で表示され、クエリのテーブル名としてそのまま使えます。「これがこのDBのIDだな」と思って REST API に渡すと、静かに 404 が返ります。

MCP経由のAPI連携でハマりやすいポイントは他にもあります。WordPress MCP Abilities API実装ガイド|エラー解決と動作確認までの完全版 でも、ツール定義の見た目と実際の挙動がずれるケースを扱っています。

REST API(Notion-Version: 2022-06-28)のエンドポイントはこうです。

POST /v1/databases/{database_id}/query

ここに渡すべきは database_id のほうです。data source id を渡すと 404 になります。MCP のツール定義には「これは data source URL です」と書いてあるのですが、作業中はIDの見た目が同じなので気づきにくいところです。

罠2:権限が無くても404が返る

IDを database_id に直しても、まだ 404 でした。

原因はトークンでした。手元の環境変数には Notion のトークンが2つあり、片方は対象のデータベースに接続されていなかったのです。

ここが2つめの罠で、Notion API は権限が無いオブジェクトに対して 403 ではなく 404 を返します。オブジェクトの存在自体を秘匿する設計なので、API の振る舞いとしては妥当なのですが、デバッグ時には「IDが違うのか、権限が無いのか」が区別できません。

Notion のインテグレーションは、ワークスペース内の全ページに自動でアクセスできるわけではなく、ページやデータベースごとに接続を許可する必要があります。この接続を忘れていると、正しいIDを正しく渡しても 404 です。

切り分けは2×2のマトリクスで一発

原因が2軸あるなら、2軸で総当たりするのが早いです。実際に筆者がやったのがこれです。

for db in 1a2b3c4d5e6f4a7b8c9d0e1f2a3b4c5d 9f8e7d6c5b4a439281726f5e4d3c2b1a; do
  printf "%s -> " "$db"
  curl -s -o /dev/null -w "%{http_code}\n" \
    -X POST "https://api.notion.com/v1/databases/$db/query" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Notion-Version: 2022-06-28" \
    -H "Content-Type: application/json" \
    -d '{"page_size":1}'
done

トークンを変えて2回まわした結果がこちらです。

database_id data source id
トークンA(未接続) 404 404
トークンB(接続済み) 200 404

これで一目瞭然でした。200 になる組み合わせがちょうど1つだけ存在し、そこが正解です。

このマトリクスの良いところは、どちらの軸が原因か分からなくても答えが出ることです。「IDを直す → まだ404 → 実は権限も違った」という直列のデバッグだと、1回の試行で1つの仮説しか潰せません。総当たりなら4回のリクエストで両方の軸を同時に潰せます。所要は10秒です。

原因が複数ありうる状況では、仮説を1つずつ検証するより、組み合わせを全部並べて叩くほうが速いことが多いと改めて感じました。

実装時のポイント

判明したことをコードに残しておきます。まずIDの取り違えは、コメントで明示しておくのが一番効きます。

# 公開結果を書き戻す先(Notion ブログ下書きDB)の database_id。
# 注意: MCP 等で見える collection://9f8e7d6c-... は data source id であって
# REST API (Notion-Version 2022-06-28) では 404 になる。database_id を使うこと。
NOTION_BLOG_DRAFT_DB_ID_DEFAULT = "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"

トークンのほうは、環境変数名が揺れがちなので複数の慣用名を見に行くようにしました。

def _notion_token() -> str:
    for key in ("NOTION_API_KEY", "NOTION_TOKEN", "NOTION_SECRET"):
        val = os.environ.get(key, "").strip()
        if val:
            return val
    return ""

ただしこれには落とし穴があって、優先順位を間違えると権限の無いトークンを拾います。筆者の環境ではシェルに NOTION_TOKEN(未接続)が居座っていたため、正しい NOTION_API_KEY を先に見るようにしました。「見つかった最初のもの」を使う設計にするなら、順序が仕様の一部になります。

そして、トークンが無いときに黙って何もしないのは避けます。

if not token:
    print(
        "[skip] NOTION_API_KEY (or NOTION_TOKEN) 未設定のため Notion 書き戻しをスキップ。"
        " .env に追加すると公開のたびに自動更新されます。",
        file=sys.stderr,
    )
    return

書き戻しが動いていないことに気づけないと、あとから「実体とインデックスがずれている」という形で表面化します。実際にこの自動化を作ったきっかけが、まさにそのズレの後始末でした。

まとめ

  • Notion REST API の 404 は「IDが違う」と「権限が無い」の両方で出る。エラーメッセージからは区別できない
  • POST /v1/databases/{id}/query に渡すのは database_id。MCP で見える collection://... は data source id で 404 になる
  • インテグレーションは対象のデータベースに個別に接続する必要がある。接続漏れも 404
  • 原因の軸が2つあるときは、直列に潰すより 2×2 のマトリクスで総当たりするほうが速い
  • 環境変数のフォールバックを書くときは、優先順位が仕様になる。権限の弱いトークンを先に拾わないこと

404 を見た瞬間に「IDが間違っている」と決めつけたのが、遠回りの原因でした。エラーコードが語ってくれる情報は、思っているより少ないです。