Claude Media
Claude CodeでmicroCMSのWebhook再検証が反映されない原因を切り分ける

Claude CodeでmicroCMSのWebhook再検証が反映されない原因を切り分ける

microCMSで公開したのにNext.jsの画面が古いまま。署名検証、タグの対象ずれ、下書き保存の通知、再検証の効き方を順に確かめ、Claude Codeで直す手順です。

microCMSで記事を公開したのに、Next.jsのサイトが古いままになる。この症状は、Webhookが届いていない、届いたが署名で弾かれた、再検証したタグが違う、再検証の効き方がstale-while-revalidateだった、のどれかに落ちます。Claude Codeには「直して」と頼む前に、どこで止まっているかを1段ずつ確かめさせると、修正が1か所で済みます。

Webhookの届き方は4つの区間に分けて見る

更新が反映されるまでの道は、次の4区間です。

  1. microCMSがWebhookを発行する(通知のタイミング設定)
  2. Route Handlerがリクエストを受け、署名を検証する
  3. revalidateTagが、データ取得側のタグと同じ文字列で呼ばれる
  4. 次の訪問で、再検証されたデータが画面に出る

どこで止まったかは、Route Handlerにログを1行足すだけで分かります。リクエストが届かなければ1、届いて401を返していれば2、200を返しているのに古いなら3か4です。Claude Codeには最初にこの分け方を渡し、コードを直す前に「どの区間で止まっているか」を答えさせます。

microCMSで公開しても、サイトが古いままです。
app/api/revalidate/route.ts にログを足して、
(1) リクエストが届いたか (2) 署名検証の結果 (3) 呼んだタグ
の3つを出してください。修正はまだしないでください。

再現コマンドを渡し、症状が毎回か断続かを伝えると、原因の絞り込みが速くなります。

通知のタイミングが公開の操作と合っているか

microCMSのWebhook設定には、通知を出す条件が9つの区分で並びます。公開時・更新時、公開終了時、下書き保存時、公開中コンテンツの削除時、公開終了コンテンツの削除時、下書きコンテンツの削除時、公開中かつ下書き中コンテンツの下書き破棄時、APIの設定変更時、APIの削除時です。

反映されない原因として多いのは、必要な区分にチェックが入っていないことです。たとえば公開終了(記事を非公開に戻す操作)にチェックが無いと、記事を引っ込めてもサイトに残ります。

やった操作必要な通知の区分
新規公開、公開済みの更新必要な通知の区分公開時・更新時
公開中の記事を公開終了にする(非公開に戻す)必要な通知の区分公開終了時
公開中の記事を削除必要な通知の区分公開中コンテンツの削除時
一覧画面で並び替え必要な通知の区分公開時・更新時(公開済みコンテンツの並び替え)

並び替え、コンテンツIDの変更、公開日時の変更、カスタムステータスの変更も、それぞれの区分の中で通知の対象です。一覧ページの並びだけが古い場合は、公開した記事の詳細ではなく一覧側のタグが無効化されていない可能性があります。

操作の経路も区別されます。管理画面で公開した場合、APIで公開した場合、レビュー申請で公開した場合、予約公開の場合は、別々の項目として書かれています。予約公開で更新されない場合は、予約設定による操作の通知が出ているかを、検証用のURLで一度確かめます。

設定した種類が違う

Webhookの種類を選ぶ画面には、Slack、Chatwork、Netlify、Cloudflare Pages、Vercel、AWS Amplify、GitHub Actions、メール通知、カスタム通知の9種類があります。Next.jsのRoute Handlerを呼ぶのは「カスタム通知」です。Vercelの種類を選ぶとDeploy Hooks URLを呼んでビルドを始める動きになり、revalidateTagは呼ばれません。ビルドが走っているのにキャッシュが古い、という症状はこの選び違いでも起きます。

署名検証が全リクエストを弾いていないか

カスタム通知にシークレットを設定すると、リクエストヘッダにx-microcms-signatureが付きます。値は、シークレットとリクエストボディからSHA-256のHMACで作られます。シークレットを設定していなければヘッダは付きません。

つまり、検証コードを書いたのにmicroCMS側のシークレットが空なら、Route Handlerは毎回401を返します。逆にmicroCMS側だけ設定して検証コードが無ければ、誰でもURLを叩いて再検証を起こせます。

検証の例は、microCMSのドキュメントにあるNode.jsの形に沿って次のように書けます。

app/api/revalidate/route.ts
import crypto from 'node:crypto'
import { NextRequest } from 'next/server'
 
export async function POST(request: NextRequest) {
  const secret = process.env.MICROCMS_WEBHOOK_SECRET
  if (!secret) return new Response('secret not set', { status: 500 })
 
  // 署名はボディのバイト列から作られるため、先に文字列で取る
  const rawBody = await request.text()
  const signature = request.headers.get('x-microcms-signature')
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex')
 
  const a = Buffer.from(signature ?? '')
  const b = Buffer.from(expected)
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return new Response('invalid signature', { status: 401 })
  }
 
  const payload = JSON.parse(rawBody)
  // ここでタグを無効化する(次の節)
  return Response.json({ ok: true, type: payload.type })
}

注意点はrequest.text()で先に生の本文を取ることです。request.json()で読んでからJSON.stringifyし直すと、空白やキーの並びが元と変わり、同じ内容でも署名が合いません。検証の前にJSONへ変換していないかを、Claude Codeに確認させる価値があります。

シークレットは.env.localなどに置き、コードや設定ファイルに書きません。ドキュメントの例には、ruby -rsecurerandom -e 'puts SecureRandom.hex(20)'で作った推測されにくい値を使う例があります。

手元で署名付きのリクエストを作る

本番のmicroCMSを待たずに、署名付きのPOSTをcurlで再現できます。ローカルのnext startに向けて、ボディと同じ文字列からHMACを作ります。

BODY='{"service":"example","api":"news","id":"abc123","type":"edit","contents":{"old":null,"new":{"id":"abc123","status":["PUBLISH"]}}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$MICROCMS_WEBHOOK_SECRET" | awk '{print $NF}')
curl -i -X POST http://localhost:3000/api/revalidate \
  -H "Content-Type: application/json" \
  -H "x-microcms-signature: $SIG" \
  -d "$BODY"

200が返れば署名検証は通っています。ヘッダを消して401が返ることも、あわせて確かめます。この2つが揃えば、手元と本番で違いうるのは「シークレットの値」と「実際のボディ」の2点だけになります。

タグの対象がずれていないか

署名を通っても、無効化するタグがデータ取得側のタグと違えば、何も変わりません。データ取得側は、fetchのnext.tagsかuse cacheとcacheTagで名前を付けています。fetchのタグは1つが256文字まで、1リソースで128個までです。

microCMSの通知から使えるのは、ボディのapi(エンドポイント名)とid(コンテンツID)です。この2つから、次のようにタグを設計するのが素直です。

タグ用途無効化する場面
microcms:news用途一覧ページ、並び替え無効化する場面そのAPIのコンテンツに変更があったとき全部
microcms:news:abc123用途詳細ページ無効化する場面そのIDの変更があったとき

取得側がnewsだけを付けていて、無効化側がmicrocms:newsを呼んでいる、といった綴りの不一致が典型例です。Claude Codeには、タグを付けている箇所と無効化している箇所をgrepで突き合わせさせます。

cacheTag と next.tags と revalidateTag に出てくる文字列を
すべて列挙し、取得側と無効化側で一致しているか表にしてください。
一致しないものだけ、どちらに揃えるべきか理由を添えてください。

APIの設定変更とAPIの削除の通知では、idとcontentsがnullになります。payload.idをそのままタグに連結するとmicrocms:news:nullができるため、idが空のときは一覧のタグだけ無効化するようにします。

revalidatePathを使うときの対象

タグでなくパスで無効化している場合は、書き方にも決まりがあります。/pageや/layoutを付けず、動的セグメントのあるパターンならtypeが必須です。/news/[slug]はrevalidatePath('/news/[slug]', 'page')の形になります。rewritesを使っているサイトでは、ブラウザに見えるURLではなく書き換え後のルートを渡します。

さらに、Route HandlerからrevalidatePathを呼ぶと、そのパスに「次の訪問で再検証する」という印を付けるだけです。動的セグメントのパターンを渡しても、すべてのページがその場で再生成されるわけではありません。

再検証したのに最初の訪問で古いのは仕様かもしれない

Route HandlerでrevalidateTag(tag, 'max')を呼ぶと、タグのデータはstaleになり、次のリクエストが再検証を始めます。その訪問には古い内容が返ります。maxは古い内容を返せる期間が1年で、要するに「必ず古い内容を返しつつ裏で更新する」設定です。

つまり、公開直後の最初の1回だけ古いのは異常ではありません。2回目の訪問で新しくなるなら、これが原因です。編集者が公開ボタンを押した直後にサイトを確かめる運用だと、最初の1回で「反映されない」と見えます。

即時に消したいときは、第2引数に{ expire: 0 }を渡します。古い内容は返されず、次のリクエストが再取得を待ちます。

import { revalidateTag } from 'next/cache'
 
// Webhook経由では updateTag が使えないため expire: 0 を渡す
revalidateTag('microcms:news', { expire: 0 })
revalidateTag(`microcms:news:${payload.id}`, { expire: 0 })

updateTagは、Server Actionの中でしか呼べません。Route Handlerに置くと動かないため、WebhookではrevalidateTagを使います。第2引数を省いた呼び方は非推奨で、{ expire: 0 }と同じ動きです。revalidateTagはClient ComponentやProxyの中でも呼べません。ProxyにWebhookの処理を置いている場合は、Route Handlerへ移します。

下書き保存の通知で何を無効化するか

下書き保存の通知は、公開の通知とは別の区分です。公開中の記事を直しているときは、状態が「公開中かつ下書き中」になり、ボディのcontents.new.statusにPUBLISHとDRAFTの両方が入ります。

ここで再検証を呼ぶと、公開側のデータは変わっていないのに無駄な再取得が起きます。逆に、下書きの通知を弾く条件が広すぎると、公開の通知まで落としてしまいます。判断に使えるのは、次の項目です。

  • typeはnew、edit、deleteのいずれか。公開状態の変更とは無関係で、データに対する変更の種類
  • contents.new.statusとcontents.old.statusは、公開中がPUBLISH、下書き中がDRAFT、公開終了がCLOSED
  • 削除ではcontents.newがnull、新規作成ではcontents.oldがnull

判定の骨格は次のようになります。

const oldStatus: string[] = payload.contents?.old?.status ?? []
const newStatus: string[] = payload.contents?.new?.status ?? []
 
// 公開側に関係があるのは、前後どちらかに PUBLISH があるとき
const touchesPublished =
  oldStatus.includes('PUBLISH') || newStatus.includes('PUBLISH')
 
if (!touchesPublished) {
  return Response.json({ ok: true, skipped: 'draft only' })
}

「公開中かつ下書き中」の保存でもこの条件は真になるため、完全に無駄を省く実装ではありません。それでも公開済みの記事の下書きを何度保存しても、最悪で再検証が余計に呼ばれるだけです。公開側の見た目が変わる前に消えてしまうよりは安全な向きです。

下書きのプレビューは別の話です。通知のボディには、下書き(または予約公開)状態のときだけdraftKeyが入ります。プレビューをキャッシュさせない構成にしておけば、Webhookで追いかける対象になりません。

切り分けの順番

切り分け

microCMSの更新が反映されないときの順番

  1. Q検証用のURLにmicroCMSの通知が届いているか?
    答えが届いていない

    Webhookの種類がカスタム通知か、操作に合った通知の区分が選ばれているかを見ます。予約公開やAPI経由の公開は別項目です。

    答えがはい
  2. QRoute Handlerは200を返しているか?
    答えが401や500

    シークレットの設定有無、環境変数の読み込み、request.text()で生の本文を取っているかを確認します。

    答えがはい
  3. Q取得側のタグと、無効化したタグは同じ文字列か?
    答えが違っている

    cacheTag、next.tags、revalidateTagの3か所を突き合わせます。idがnullの通知にも注意します。

    答えがはい

2回目の訪問で新しくなるならstale-while-revalidate

即時に消したい用途だけ{ expire: 0 }にします。

Claude Codeに直させるときの指示の置き方

修正の指示は、計画だけを出させてから実装に進むと、タグの設計変更が混ざりにくくなります。claude --permission-mode planで起動すると、承認するまで編集されません。

claude --permission-mode plan

依頼文には、症状の区間(上の4区間のどれか)と、直したい範囲を書きます。

署名検証は通っていて、200も返っています。
公開して最初の訪問だけ古く、2回目で新しくなります。
revalidateTag の呼び方だけを見直してください。
タグの命名と、取得側の変更は今回の対象外です。

検証用のcurlの手順は、CLAUDE.mdではなくスクリプトとして置いておくと、再現を毎回頼まずに済みます。シークレットの値は.env.localに残し、スクリプトは環境変数から読むようにします。型から連携を組んだ経緯はClaude CodeでmicroCMSと連携したサイトを作るに、キャッシュ層の切り分けはClaude CodeでNext.jsのキャッシュを見直すにまとめています。環境変数が読まれていない疑いがあるときは、Claude Code設定が反映されない原因の探し方も参考になります。

まとめ

反映されない原因は、4区間のどこで止まっているかで決まります。まず検証用のURLで通知が出ているかを見て、次に署名とタグの文字列、最後に再検証の第2引数を確かめます。公開直後の1回目だけ古いのは、maxを渡したときの仕様どおりの動きです。即時に消すか、1回の遅れを許すかは、編集者がどの画面でいつ確かめるかで決めます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. document.microcms.io/manual/webhook-setting
  2. nextjs.org/docs/app/api-reference/functions/revalidateTag
  3. nextjs.org/docs/app/api-reference/functions/revalidatePath
  4. nextjs.org/docs/app/api-reference/functions/updateTag
  5. nextjs.org/docs/app/api-reference/functions/fetch
  6. code.claude.com/docs/en/common-workflows
この記事を共有:XはてブLinkedIn