開発

Confluence REST APIのv1とv2の違いと移行手順|カーソル方式のページ送りとCQL検索の残し方

Confluence REST APIのv1とv2の違いと移行手順|カーソル方式のページ送りとCQL検索の残し方

Confluenceを外部システムから操作するとき、日本語で見つかる実装例はほぼすべて /wiki/rest/api で始まるv1のパスで書かれています。ところが公式リファレンスの主役はすでに /wiki/api/v2 のv2へ移っており、ページ送りの方式も本文の取り方も別物になりました。そのままv1のコードを写すと、件数が増えた途端に取りこぼしが出ます。この記事では、公式ドキュメントで実測したパス・パラメータ・スコープをもとに、v1とv2で何がどう変わり、どこを書き換えれば動くのかを、そのまま実行できるcurlとPythonで追います。あわせて、v2に用意されていないCQL検索をどう扱うか、受託開発の現場で移行順序をどう組むかまで整理しました。

まとめ|Confluence APIはv2へ寄せてCQL検索だけv1に残す構成にする

先に結論を置きます。2026年9月時点のConfluence Cloudでは、ページやスペースの読み書きはv2へ寄せ、CQLによる横断検索だけv1の /wiki/rest/api/search を残す二段構えが現実的な着地点です。理由は単純で、v2に対応する検索用APIグループが存在しないからです。

  • ベースパスが変わる:v1は /wiki/rest/api/content、v2は /wiki/api/v2/pages。コンテンツ種別ごとにエンドポイントが分かれます。
  • ページ送りの方式が変わる:v1の start と limit によるオフセット指定から、v2は cursor を受け渡すカーソル方式になりました。start を足し込むループはv2では動きません。
  • 本文の取り方が変わる:v1の expand=body.storage は、v2では body-format クエリの指定に置き換わります。
  • 検索だけv2に無い:CQLを使う横断検索はv1にしかありません。全面移行はできず、併用が前提になります。
  • 認証は据え置き:APIトークンによるBasic認証はv1とv2で共通のため、認証部分は書き換え不要です。

この5点のうち、既存スクリプトを壊すのはページ送りと本文フォーマットの2つです。逆に言えば、その2か所を直せば大半のバッチはv2で動きます。以降で1つずつ実物のコードに落とします。

v1とv2のベースパスとエンドポイント構成の違いを実物のパスで確かめる

まず地図を作ります。v1とv2はパスの階層設計そのものが違うため、対応表を持っておかないと書き換え作業が進みません。

v1とv2のAPIパスの違いとページ取得エンドポイントの対応関係

v1は v1リファレンスのとおり、ページもブログも添付ファイルも content という単一のリソースにまとめ、type パラメータで種別を分ける設計でした。v2はこれをやめ、Pageグループのように種別ごとの独立したエンドポイントへ分割しています。

下の対応表は接頭辞を省いて差分だけを並べたものです。実際に叩くときは、v1側に /wiki/rest/api/、v2側に /wiki/api/v2/ を頭に付けてください。

やりたいこと v1 v2
ページ1件を取得 GET content/{id} GET pages/{id}
ページ一覧を取得 GET content?type=page GET pages
スペース内のページ content?spaceKey=DEV spaces/{id}/pages
ページを作成 POST content POST pages
ラベル付きページ GET content/search GET labels/{id}/pages

ここで見落としやすいのが、スペースの指定方法です。v1はスペースキー(DEV のような文字列)で絞り込めましたが、v2の /spaces/{id}/pages は数値のスペースIDを要求します。キーしか持っていない既存コードは、先に GET /wiki/api/v2/spaces?keys=DEV でIDへ引き当てる処理を挟む必要があります。

v2のAPIグループ約30種とv1にしか残っていない機能の切り分け

v2リファレンスのAPIグループは、Page・Blog Post・Attachment・Comment・Space・Label・Version・Task・Whiteboard・Database・Folder・Smart Linkなど約30種に分かれています。ホワイトボードやデータベースといった新しいコンテンツ種別はv2側にしか用意がなく、この点ではv2が先行しています。

一方、v1のグループ一覧には検索・Template・Themes・Audit・Analytics・Long-running taskといった項目が並び、これらに対応するv2グループは見当たりません。つまり移行の可否は「使っているエンドポイントがどちらの集合に属するか」で決まります。棚卸しの段階で、この分類を先に済ませておくと後の判断が速くなります。

APIトークンでBasic認証を通して最初のリクエストを成功させる手順

認証はv1とv2で共通です。すでにv1で動いているスクリプトがあるなら、この節は読み飛ばして構いません。これから作る場合は、公式のBasic認証ガイドが示す手順どおりに進めます。

トークン発行からAuthorizationヘッダを組み立てて疎通するまでのコマンド

トークンは id.atlassian.com/manage/api-tokens で発行します。認証文字列は「Atlassianアカウントのメールアドレス」と「APIトークン」をコロンで連結し、Base64化したものです。パスワードではなくトークンを使う点に注意してください。

# 1) 認証文字列を作る(macOS / Linux)
echo -n '[email protected]:YOUR_API_TOKEN' | base64

# 2) v2でページを1件取得して疎通を確認する
curl -s -X GET \
  -H "Authorization: Basic YOUR_ENCODED_STRING" \
  -H "Accept: application/json" \
  "https://your-domain.atlassian.net/wiki/api/v2/pages?limit=1"

# 3) -u を使えばBase64化を自分でやらなくてもよい
curl -s -u '[email protected]:YOUR_API_TOKEN' \
  -H "Accept: application/json" \
  "https://your-domain.atlassian.net/wiki/api/v2/pages?limit=1"

Windows PowerShellでBase64化する場合は [System.Text.Encoding]::UTF8.GetBytes() でバイト列にしてから [Convert]::ToBase64String() に渡します。改行が混ざると認証が通らないため、echo には -n を付けます。

返却が401なら認証文字列そのもの、403ならスコープや権限の問題です。OAuthアプリとして組む場合、スコープの公式ガイドはclassic scope(read:confluence-content.all など)を第一候補とし、granular scope(read:page:confluence・write:page:confluence など)はclassicで賄えないときに使う位置づけだと記載しています。v2のページ操作に必要なスコープは各エンドポイントのリファレンスに明記されているため、403が出たらまずそこを突き合わせます。

startとlimitからcursorへ変わったページ送りの書き換え方

ここが移行で最も壊れやすい箇所です。v1の感覚のまま start を増やすループを書くと、v2では同じ1ページ目を延々と取り続けるか、途中で件数が合わなくなります。

v1のstartとlimitで全件取得していたループがv2で破綻する理由

v1のページ送りは GET /wiki/rest/api/content?start=4&limit=10 のように、開始位置をインデックスで指定するオフセット方式でした。取得件数を足し込みながら start を進めれば全件を舐められます。

v2で採用されるのは、これに代わるカーソル方式です。指定できるのは limit と cursor だけで、cursor の値は自分で計算するものではなく、レスポンスの Link ヘッダまたはボディの _links.next から受け取ります。したがって「次があるかどうか」は件数ではなく、_links.next の有無が判定基準です。v2では start は解釈されないため、旧ループはオフセットが効かないまま同じ結果を返し続けます。

cursorと_links.nextを使ってページを取り切るPythonコード

書き換え後の形はこうなります。_links.next は相対パスで返るため、ホスト名を補ってから次のリクエストへ渡すのが要点です。

import requests
from urllib.parse import urljoin

BASE = "https://your-domain.atlassian.net"
AUTH = ("[email protected]", "YOUR_API_TOKEN")

def fetch_all_pages(space_id=None, limit=100):
    """v2のカーソル方式で全ページを取り切る"""
    if space_id:
        path = f"/wiki/api/v2/spaces/{space_id}/pages?limit={limit}"
    else:
        path = f"/wiki/api/v2/pages?limit={limit}"

    results = []
    while path:
        r = requests.get(urljoin(BASE, path), auth=AUTH,
                         headers={"Accept": "application/json"}, timeout=30)
        r.raise_for_status()
        data = r.json()
        results.extend(data.get("results", []))
        # 次ページの有無は件数比較ではなく _links.next で判定する
        path = data.get("_links", {}).get("next")
    return results

if __name__ == "__main__":
    pages = fetch_all_pages()
    print(len(pages), "pages")
    for p in pages[:5]:
        print(p["id"], p["title"])

ループの終了条件を len(results) < limit のような件数判定にしないでください。カーソル方式では返却件数が limit を下回っても次ページが存在する場合があり、件数で打ち切ると取りこぼします。判定は _links.next の有無に一本化するのが安全です。

expandからbody-formatへ変わった本文取得の指定方法と詰まりどころ

ページ送りの次に壊れるのが本文の取得です。v1で慣れた書き方をそのまま持ち込むと、レスポンスに本文が入らず「取得できているのに中身が空」という状態になります。

storage形式とatlas_doc_formatのどちらを指定するかの判断

v1では expand=body.storage のようにドット記法で展開対象を指定していました。v2ではこの役割を body-format クエリパラメータが担い、指定できる値は storage と atlas_doc_format です。

# v1:expandで本文を展開していた
curl -s -u '[email protected]:TOKEN' \
  "https://your-domain.atlassian.net/wiki/rest/api/content/123456?expand=body.storage,version"

# v2:body-format で表現形式を指定する
curl -s -u '[email protected]:TOKEN' \
  "https://your-domain.atlassian.net/wiki/api/v2/pages/123456?body-format=storage"

# ADF(JSON構造)で受け取りたい場合
curl -s -u '[email protected]:TOKEN' \
  "https://your-domain.atlassian.net/wiki/api/v2/pages/123456?body-format=atlas_doc_format"

選び方の目安は用途で分かれます。既存のHTML相当のマークアップを文字列として扱い、置換や差分比較をかけるなら storage です。ブロック単位で構造を歩いて特定の要素だけ差し替える用途には、JSONツリーとして扱える atlas_doc_format が適した形式です。図表を扱う場合、Confluence上でMermaid記法のマクロを埋め込んでいるなら、その中身は storage 側にマクロのパラメータとして現れます。記法そのものの書き方はMermaid記法の使い方をまとめた記事が参考になります。

v1のexpandに慣れた実装がv2で本文を取れなくなる原因

v2に expand を付けても無視されるだけでエラーにはなりません。ここが厄介で、リクエストは200で返り、title や id は取れているのに body だけが空という状態になります。移行後にテキスト抽出の結果が全件空になったら、まず body-format の指定漏れを疑ってください。

もう1つ、v1の expand は version や space をまとめて展開できましたが、v2では別エンドポイントへ分離されました。バージョン履歴はVersionグループ、祖先ページはAncestorsグループというように、1リクエストで済んでいた処理が複数リクエストに分かれる箇所が出ます。呼び出し回数が増えるため、移行後は取得処理のループ回数を見積もり直すことをおすすめします。

v2にないCQL検索をv1のsearchエンドポイントで残す実装の書き方

全面移行ができない理由がここです。条件を指定した横断検索は、v2のAPIグループに該当するものがありません。

cqlパラメータの組み立て方と検索対象を絞る条件式の実例と注意点

v1の検索用APIグループには /wiki/rest/api/search(コンテンツ検索)と /wiki/rest/api/search/user(ユーザー検索)があり、いずれも cql パラメータが必須です。OAuthで使う場合のスコープは search:confluence が推奨とされています。

import requests

BASE = "https://your-domain.atlassian.net"
AUTH = ("[email protected]", "YOUR_API_TOKEN")

def search_by_cql(cql, limit=50):
    """CQL検索はv2に無いためv1のsearchを使う"""
    url = f"{BASE}/wiki/rest/api/search"
    params = {"cql": cql, "limit": limit}
    r = requests.get(url, auth=AUTH, params=params,
                     headers={"Accept": "application/json"}, timeout=30)
    r.raise_for_status()
    return r.json().get("results", [])

# 例1:DEVスペースの中で、直近30日に更新されたページ
hits = search_by_cql('space = "DEV" AND type = page AND lastmodified >= now("-30d")')

# 例2:特定ラベルが付いたページをタイトル昇順で
hits = search_by_cql('label = "api-doc" AND type = page ORDER BY title ASC')

for h in hits:
    print(h["content"]["id"], h["content"]["title"])

条件式の書き方はCQLの公式ガイドにまとまっています。なお公式の検索APIリファレンスには、user・user.fullname・user.accountid・user.userkey といったユーザー固有フィールドがすでにサポート外である旨の注記があります。古い記事のCQLをコピーすると、この手の廃止済みフィールドで落ちることがあるため、条件式は公式ガイド側で照合してから使ってください。画面のUI上で同じ条件を試して感触を掴んでから実装へ落とすなら、Confluenceの検索方法をまとめた記事に検索構文とフィルタの対応が整理してあります。

v1併用を前提にしたクライアント設計とラッパー関数の分け方と保守方法

v1とv2が混在する以上、呼び出し側にパスを直書きするのは避けたほうが後が楽です。実装としては、バージョンごとにベースパスを持つ薄いクライアントを2つ用意し、業務ロジックからはどちらを使っているか意識させない形にします。

class ConfluenceClient:
    """v2を既定にし、CQL検索のときだけv1へ落とす"""

    def __init__(self, base, auth):
        self.base = base.rstrip("/")
        self.auth = auth
        self.s = requests.Session()
        self.s.auth = auth
        self.s.headers.update({"Accept": "application/json"})

    def _get(self, path, **params):
        r = self.s.get(self.base + path, params=params, timeout=30)
        r.raise_for_status()
        return r.json()

    # ---- v2側(既定) ----
    def get_page(self, page_id, body_format="storage"):
        return self._get(f"/wiki/api/v2/pages/{page_id}",
                         **{"body-format": body_format})

    # ---- v1側(CQLのみ) ----
    def search(self, cql, limit=50):
        return self._get("/wiki/rest/api/search", cql=cql, limit=limit)

こう分けておくと、将来v2に検索が用意されたときに差し替えるのは search の中身だけで済みます。逆に、業務コードのあちこちに /wiki/rest/api が散っている状態だと、そのときにまた全文検索して置換する作業が発生します。

ページ作成と更新をv2で行うときのリクエストボディとversion指定

読み取りが通ったら書き込みです。書き込みはv1とv2でボディの構造が変わるため、単純なパス置換では通りません。

POST /pages のボディ構造とspaceId・parentIdの指定方法

v2のページ作成は POST /wiki/api/v2/pages で、ボディに title・spaceId・status・body・parentId を渡します。v1がスペースキー文字列を受け付けたのに対し、v2は数値のスペースIDを要求する点が最大の差です。

# スペースキーから数値IDを引き当てる
curl -s -u '[email protected]:TOKEN' \
  "https://your-domain.atlassian.net/wiki/api/v2/spaces?keys=DEV"

# 取得したIDを spaceId に入れてページを作成する
curl -s -X POST -u '[email protected]:TOKEN' \
  -H "Content-Type: application/json" \
  "https://your-domain.atlassian.net/wiki/api/v2/pages" \
  -d '{
    "spaceId": "98765",
    "status": "current",
    "title": "APIから作成したリリース手順",
    "parentId": "123456",
    "body": {
      "representation": "storage",
      "value": "<p>この段落はAPIから投入しました。</p>"
    }
  }'

status に draft を指定すれば下書きとして作成されます。承認フローを挟む運用なら、まず下書きで投入して人が確認してから公開へ切り替える形が組めます。

PUT /pages/{id} で必要なversion.numberの扱いと競合の避け方

更新は PUT /wiki/api/v2/pages/{id} です。ここで見落としがちなのが version.number で、現在の版番号に1を足した値を渡す必要があります。固定値を書くと2回目以降の更新が通りません。

def update_page(client, page_id, title, html):
    """現在の版番号を読んでから +1 して更新する"""
    cur = client.get_page(page_id)
    next_version = cur["version"]["number"] + 1

    payload = {
        "id": page_id,
        "status": "current",
        "title": title,
        "body": {"representation": "storage", "value": html},
        "version": {"number": next_version, "message": "batch update"},
    }
    r = client.s.put(f"{client.base}/wiki/api/v2/pages/{page_id}",
                     json=payload, timeout=30)
    if r.status_code == 409:
        # 読んでから書くまでの間に他者が更新した場合は読み直してやり直す
        return update_page(client, page_id, title, html)
    r.raise_for_status()
    return r.json()

読み取りから書き込みまでの間に他の利用者が同じページを編集すると版番号がずれ、409が返ります。バッチで大量更新をかける場合は、上のように409を捕まえて読み直す処理を入れておくと、夜間実行が途中で止まる事故を減らせます。

移行を進める順序と受託開発でv2化を見送ってよい条件を判断する線引き

ここからは判断の話です。v1の廃止アナウンスは何度も延期されており、期日を前提に計画を立てると振り回されます。

既存スクリプトの棚卸しから書き換え順と着手範囲を決める進め方

Atlassianの開発者コミュニティにある廃止タイムラインの告知を追うと、当初の期日から複数回にわたって延期されてきた経緯が読み取れます。延期の理由として挙がっているのは、v1にあってv2に無い機能が残っている点への開発者からの指摘です。Atlassian側は、v2で機能の同等性が確保されてから一定期間を置くまでv1を止めないと表明しています。

したがって現実的な進め方は、期日から逆算するのではなく、次の順序で影響の大きい箇所から潰していくことです。

  1. 棚卸し:リポジトリ全体を /wiki/rest/api で検索し、呼び出し箇所とエンドポイント種別を一覧化する。
  2. 分類:v2に対応があるもの(ページ・スペース・添付・コメント・ラベル)と、v1にしか無いもの(検索・テンプレート・監査)に仕分ける。
  3. 読み取りから着手:影響範囲が小さく、失敗しても書き戻しが不要な取得系から書き換える。ページ送りと body-format の2点で大半が片付く。
  4. 書き込みは後:作成・更新は版番号の扱いが絡むため、取得系がv2で安定してから移す。
  5. 検索は据え置き:CQL部分はラッパーに閉じ込めたうえでv1のまま残す。

仕様変更の告知はConfluence Cloudのchangelogに集約されています。ここは廃止だけでなく既存v2エンドポイントの挙動変更も流れてくるため、四半期に一度は目を通す運用にしておくと、ある日突然バッチが落ちる事態を避けられます。

今すぐv2へ動くべき条件と、当面は移行を見送ってよい判断条件

言い切ります。これから新規に組む連携は、迷わずv2で書いてください。新しいコンテンツ種別はv2側にしか用意がなく、v1で書くと最初から負債になります。既存資産についても、ページの一括取得を回しているバッチ、対象ページ数が今後増える見込みのある処理、社外へ提供している連携機能は、優先して着手する対象です。

一方で、当面は見送ってよい条件も明確です。対象ページが数十件で固定され、CQL検索が処理の中心を占め、社内の一部だけが使う使い捨てに近いスクリプトであれば、いま書き換える利益は小さいと判断してよいと考えます。ラッパーで呼び出し口だけ一本化しておき、changelogで自分の使うエンドポイントに削除告知が出た時点で動く、という構えで足ります。判断の分かれ目は「対象件数が将来増えるか」です。件数が増えないなら、オフセット方式の破綻という最大のリスクが顕在化しません。

なお、Confluenceを情報の集約先として使うなら、APIで機械的に流し込む前に、どの情報をどこで正とするかの整理が前提です。データの一元化そのものの設計はSSOTの考え方と実装をまとめた記事で扱っています。自社にAPI連携の実装リソースが足りない、あるいは移行の設計から任せたいという段階であれば、API開発・システム連携で既存システムとの接続を含めてご相談を受け付けています。

よくある質問

Confluence REST APIのv1はいつ使えなくなりますか?

確定日を断定できる状態にはありません。開発者コミュニティの告知を追うと、廃止期日は複数回にわたって延期されてきました。Atlassian側はv2で機能の同等性が確保されるまで削除しないという方針を示しており、CQL検索のようにv2へ移せない機能が残っている間は全面停止に踏み切りにくい構造です。実務としては、日付を追いかけるより公式changelogの告知を定期的に確認する運用へ切り替えることをおすすめします。

v1とv2を同じスクリプトの中で混ぜて呼んでも問題ありませんか?

問題ありません。認証方式が共通のため、同じ資格情報のまま両方を呼べます。CQL検索だけv1、それ以外はv2という併用は、v2に検索用APIグループが無い以上むしろ標準的な構成です。ただしパスが混在するとどちらを使っているか追えなくなるため、バージョンごとにメソッドを分けたクライアントへ閉じ込めておくとよいです。

v2で本文が空になるのはなぜですか?

body-format の指定漏れが原因である場合がほとんどです。v1の expand=body.storage はv2では解釈されず、しかもエラーにならずに200が返るため気づきにくい挙動になっています。?body-format=storage または ?body-format=atlas_doc_format を明示的に付けてください。

Data Center版でもこの移行手順は同じですか?

いいえ、別物として扱ってください。この記事で扱っているのはCloud版のREST APIです。Data Center版はエンドポイントの体系もリリースサイクルも異なり、v2への移行という文脈自体が当てはまりません。参照するドキュメントもCloud向けとは別に用意されているため、自社の契約形態を先に確認してから手順を選んでください。

スペースキーしか持っていない既存コードはどう直せばよいですか?

GET /wiki/api/v2/spaces?keys=DEV でキーから数値IDを引き当て、そのIDを以降の呼び出しへ渡します。キーとIDの対応はスペースを作り直さない限り変わらないため、起動時に一度だけ引いてメモリ上に持たせる形にすれば、リクエスト数の増加は抑えられます。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.07 テックブログ ファインズの不正アクセスと153万件の予約者情報|予約システムを使う店舗の報告と通知の手順
  2. 2026.10.19 テックブログ コミックシーモア不正アクセスで会員情報流出:再開時期・パスワード再設定と運営側のログ点検
  3. 2026.10.16 テックブログ バーガーキング情報漏洩どうしたらいい?約1,118万件の対象確認・補償・退会とメールの真偽
  4. 2026.10.15 テックブログ 銀のさら不正アクセス:最大650万件の対象確認と窓口、パスワード変更・退会の手順
  5. 2026.10.15 テックブログ リゾートトラスト不正アクセス:会員2.6万件・カハラ横浜3.6万件の対象確認と窓口

RELATED POSTS 関連記事

目次