コンテンツへスキップ
ドキュメント

配信Webhook:自社サイトで記事を受け取る

AutoPSEO の記事を自社サイトへ送る署名付き Webhook のリファレンスです。ヘッダー、各イベントとペイロード、返すべきレスポンス、リトライ、保留中のコマンド、IndexNow を解説します。

プロパティ → コンテンツ → 記事の公開先読了まで9分

AutoPSEO は、公開・更新・非公開にしたすべての記事を、署名付きの HTTPS POST で自社サイトへ送信します。サイト側で記事を保存し、独自のテンプレートで表示するため、コンテンツは自社のシステムに置かれ、AutoPSEO をやめた後も表示され続けます。このページは、受信側を実装する開発者向けのリファレンスです。署名、各イベントとペイロード、AutoPSEO が読み取るレスポンス、リトライ、保留中のコマンド、IndexNow を説明します。

Webhook を設定する

  1. プロパティの コンテンツ ページを開き、記事の公開先 に進みます。
  2. サイト上のWebhookアドレス を入力します。パブリックなホスト名の https:// URL である必要があり、IP アドレスと localhost は受け付けられません。AutoPSEO はリダイレクトをたどらないため、最終的なアドレスを指定してください。
  3. サイトのアドレス を入力します(例:https://example.com)。各コンテンツタイプのパスはこの配下に置かれるため、/answers タイプの記事は https://example.com/answers/<slug> になります。
  4. 保存してテストを送信 を選びます。AutoPSEO が署名シークレット(whsec_ に続く 48 文字の 16 進数)を作成し、指定したアドレスに ping を送ります。署名を検証する受信側は、まだシークレットを持っていないため、この最初の ping には失敗します。
  5. 署名シークレット を 表示 または コピー で取り出し、サイト側にシークレットとして保存してから、テストを送信 を選びます。
  6. サイトが対応している内容 を確認します。各コマンドには 接続済み、未接続、宣言済み・未テスト、未テスト のいずれかが表示されます。

新しいプロパティでは、最初は AutoPSEO が自身のアドレスで記事をホスティングします(ホスティングを参照)。ホスティングと Webhook は並行して使えます。サイトに記事が表示されるようになったら AutoPSEO でのホスティングを停止 を選びます。公開中のすべての記事がサイトのアドレスへ移り、article.published として Webhook に送信されます。このボタンを使うには、直近のテストに応答した Webhook とサイトのアドレスが必要です。ホスティングがオフの間は、Webhook を削除できません。

新しいシークレット を作ると、署名シークレットがその場で置き換わり、古いシークレットでは即座に検証できなくなります。新しいシークレットは、作成後すぐにサイトへ設定してください。

AutoPSEO が配信をキューに入れるのは、直近の ping に 2xx で応答した Webhook だけです。ping が失敗すると(保存時、テストを送信 時、サイトスキャンの開始時)、その Webhook はエラー状態になり、エラー中に公開された記事はその Webhook 向けにキューへ入りません。ping には必ず応答できる状態を保ち、障害を直したら テストを送信 を選んでください。

ヘッダーと署名

すべてのリクエストは JSON 本文を持つ POST で、次のヘッダーが付きます。

ヘッダー値
Content-Typeapplication/json
User-AgentAutoPSEO-Webhook/1
X-AutoPSEO-Eventイベント名。本文にも event として含まれます
X-AutoPSEO-Delivery配信 ID。本文にも id として含まれます。1 つの配信を再試行しても同じ値です
X-AutoPSEO-Timestampこの試行に署名した時刻(Unix 時間、秒)
X-AutoPSEO-Signaturesha256= に続く、<timestamp>.<raw body> の HMAC-SHA256 を 16 進数にした値

リクエストを検証する手順は次のとおりです。

  1. タイムスタンプヘッダー、ピリオド、そして届いたままのリクエスト本文(JSON のパース前)をこの順につなぎます。
  2. その文字列に対して HMAC-SHA256 を計算します。鍵は、アプリに表示されるとおりの署名シークレット全体(whsec_ プレフィックスを含む)を UTF-8 バイト列にしたものです。
  3. 結果を 16 進数にエンコードし、X-AutoPSEO-Signature の sha256= 以降の部分と、定数時間で比較します。一致しなければリクエストを拒否してください。

各試行は、それぞれのタイムスタンプで新たに署名されます。そのため、タイムスタンプが自分の時計から大きくずれているリクエスト(5 分が一般的な許容幅)を拒否しても、リトライには影響しません。HMAC 自体は RFC 2104 で定義されています。

すべての本文に id、event、occurredAt(ISO 8601、UTC)、site: { propertyId, domain } が含まれます。

イベント

イベント種別AutoPSEO が送信するタイミング
pingチェックWebhook の保存時、テストを送信 時、すべてのサイトスキャンの開始時
testチェックping が成功した直後に、コマンドごとに 1 件
article.published配信記事が初めて公開されたとき。ホスティングを停止したときは公開中のすべての記事
article.updated配信新しいリビジョンが公開されたとき(リフレッシュ、最適化、ロールバック、非公開後の再公開、言語の追加)、またはカバー画像が追加されたとき
article.unpublished配信記事が非公開になったとき、または別のページに統合されたとき
path.add配信型付きコンテンツタイプの新しい値が承認されたとき
path.remove配信ある値のパスを削除するよう依頼されたとき
path.checkコールサイトにページがないアドレスにページを計画する前、または新しい値を提案する前
site.exportコールサイトスキャン中、毎日のトピック同期中、公開中の記事を AutoPSEO が変更する前
articles.adoptedコールスキャンで、サイトがエクスポートした記事を取り込んだ後
site.handoverコールコンテンツタイプの執筆を AutoPSEO に引き渡すとき、またはサイトに戻すとき

配信はアウトボックスを経由します。変更と同じデータベースのバッチに記録され、すぐに送信され、再試行され、サイトの対応を待つこともあります。コールは単純なリクエストとレスポンスで、AutoPSEO はその場で応答を読み取ります。パス関連のコマンドが送信されるタイミングは、型付きページと新しいパスで説明しています。

記事のペイロード

article.published と article.updated には、すべての言語を含む記事全体が入っています。

{
  "id": "0f6d2c1e-7a51-4c1b-9d0e-5b8f3a2e9c41",
  "event": "article.published",
  "occurredAt": "2026-10-12T08:30:00.000Z",
  "site": { "propertyId": "prop_123", "domain": "example.com" },
  "article": {
    "id": "a4c09e2b-6f1d-4e57-8b3a-1d2e3f4a5b6c",
    "slug": "plan-a-content-calendar",
    "path": "/answers/plan-a-content-calendar",
    "type": { "key": "answers", "name": "Answers", "path": "/answers" },
    "lang": "en",
    "langs": ["en", "de"],
    "rev": 1,
    "url": "https://example.com/answers/plan-a-content-calendar",
    "datePublished": "2026-10-12",
    "dateModified": "2026-10-12",
    "contentHash": "9f2c4e1a7b3d5f6081a2c3d4",
    "writer": "platform",
    "category": "Planning",
    "copy": {
      "title": "How to plan a content calendar",
      "metaDescription": "A content calendar in five steps, with a template.",
      "h1": "How to plan a content calendar",
      "directAnswer": "List your topics, give each a date and an owner, then review the plan weekly.",
      "keyTakeaways": ["Plan a quarter at a time.", "Give every piece one owner."],
      "sections": [{ "heading": "Start from your topics", "bodyHtml": "<p>Begin with the searches you want to win.</p>" }],
      "faq": [{ "q": "How far ahead should I plan?", "a": "One quarter is enough for most teams." }],
      "sources": [{ "title": "Creating helpful content", "url": "https://developers.google.com/search/docs/fundamentals/creating-helpful-content" }]
    },
    "translations": { "de": { "title": "Einen Redaktionsplan erstellen", "h1": "Einen Redaktionsplan erstellen" } },
    "cover": {
      "url": "https://app.autopseo.com/media/covers/prop_123/<sha256>.jpg",
      "sha256": "<sha256>",
      "contentType": "image/jpeg",
      "width": 1536,
      "height": 1024
    }
  }
}

ドイツ語のエントリはここでは省略してあります。各翻訳は copy と同じ構造です。

フィールド意味
idAutoPSEO が記事に付ける ID。変わることがないため、キーとして安全に使えます
slugアドレスの末尾部分。型付きページでは、google/software-engineer のように / で区切ったセグメントが入ります
pathサイトからの相対アドレス。タイプのパスにスラッグを足したものです。ページが作成された後は固定されます
type{ key, name, path }。key は不変で、path はそのタイプの記事を置く場所です(サイトのルートの場合は空)
lang, langsこのリビジョンの主言語と、含まれるすべての言語
revリビジョン番号
urlAutoPSEO が把握している完全なアドレス。ホスティングをオフにした後はサイトのアドレス、または受信側が最後に返した url です
datePublished, dateModifiedYYYY-MM-DD 形式。公開日は、その後のリビジョンでも変わりません
contentHashリビジョンのフィンガープリント
writerリビジョンの執筆者:platform(AutoPSEO)、mcp(自社の AI)、user、import(サイト自身のテキスト)、aggregate(一覧ページ)
copy主言語の記事
translationsその他の言語。言語コードをキーとします
categoryサイト側のカテゴリー。フォーマットでカテゴリーを管理している場合のみ
topicサイトから渡されたトピックのために書かれた記事の場合の { externalId, keyword }。そのトピックを完了にするために使えます
parts型付きページで、各パートの値を示します。例:{ "company": "google", "role": "software-engineer" }
kind一覧ページでは "list"。その copy は { title, h1, items: [{ from, text }] } で、from は各項目の元になったページのパス、parts はまとめている値を示します
coverAutoPSEO が記事のために作成したカバー画像。一度取得して自前のコピーを保管してください。ファイル名が画像の SHA-256 なので、アドレスは変わりません
basedOnarticle.updated で、このリビジョンの元になったページのバージョン(下の競合の項を参照)
redirectTo統合後の article.unpublished で、旧アドレスの新しい転送先。301 で応答し、そのアドレスには何も新しく置かないでください

article.unpublished には、copy、translations、category、cover を除いた同じ article フィールドが含まれます。

copy の中身

フィールド意味
title, metaDescription, h1ページタイトル、メタディスクリプション、見出し
directAnswer最初に表示される短い回答
keyTakeaways文字列のリスト
sections[{ heading, bodyHtml }]
faq[{ q, a }]
sources[{ title, url, lastVerified? }]
question任意:検索者の質問を、その人の言葉で。フォーマットで求められている場合のみ
category任意:サイトのカテゴリーのうちの 1 つ
lists任意:キーごとのプレーンテキストのリスト。必要とするコンテンツタイプ向け(面接ガイドのサンプル質問など)

AutoPSEO が書く記事では、bodyHtml に使われるのは p、ul、li、strong、em、a、code だけで、リンクには href のみが残ります。サイト自身のテキストを取り込んだ場合は、そのサイトの HTML がそのまま残ります。テンプレートが必要とするなら、受信側でも独自のサニタイザーを通してください。

受信側が返すレスポンス

2xx であれば配信は完了します。AutoPSEO は、レスポンス本文の任意の JSON オブジェクトも読み取ります。64 KB を超えるレスポンスは失敗として扱われるため、それ未満に収めてください。

フィールド効果
url記事が現在置かれている https:// のアドレス。AutoPSEO はこれを記事の URL として記録します
versionページのバージョン。128 文字以内の不透明な文字列です。次回の更新で basedOn として返ってきます
urlsこの配信で変更された https:// のアドレス。IndexNow 用です(すべての言語、または提供を停止したアドレス)。[] は変更なしを意味します
cover記事のカバー画像がなく、配信にも含まれていなかった場合は "wanted"。AutoPSEO がカバーを作成し、article.updated で送ります

エラー応答は、それぞれ次の意味に解釈されます。

返す応答AutoPSEO の解釈結果
{ "error": "unknown_event" } を伴う 4xx、または 501このコマンドにはまだ対応していない配信は保留され、再試行されません
{ "error": "conflict" } を伴う 409更新の元になったバージョンが、ページにはもうない配信は再試行なしで失敗し、次の変更の前にサイトから記事が読み直されます
path.add または path.remove に対する、408 と 429 以外の 4xx で { "error": "<code>", "details": [] } を伴うもの理由付きの拒否記録されます。型付きページと新しいパスを参照
それ以外:10 秒以内に応答なし、ネットワークエラー、リダイレクト、408、429、5xx、その他の 4xx一時的な失敗バックオフ付きで再試行されます

リトライと置き換えられた配信

配信は変更が保存されるとすぐに送信され、5 分ごとのスケジュールで、期限が来たものが古い順に送信されます。一時的な失敗の後、AutoPSEO は試行の間隔を 1 分、5 分、15 分、1 時間、3 時間、6 時間、12 時間の順に空けます。8 回目の試行が失敗すると、その配信は失敗扱いになり、コンテンツページでは Webhook の横に赤いバッジとして数えられます。

同じ記事に対してより新しい配信がある場合、まだ保留中の古い配信は「置き換え済み」とされ、送信されません。どの配信にも記事全体が含まれるため、サイトに必要なのは最新の配信だけです。同じ値に関するパスのコマンドも同様に扱われます。

受信側が同じ配信を 2 回受け取ることもあります。たとえば、記事を保存したものの 10 秒のタイムアウト後に応答した場合です。X-AutoPSEO-Delivery で重複を排除するか、article.id をキーに保存して、すべての書き込みを冪等にしてください。また、article.unpublished の後に削除した記事に対して article.updated が届くことがあります。非公開にした記事を再公開すると更新が送られるためです。更新は「作成または置換」として扱ってください。

サイトがまだ対応していないコマンド

サイトは、プロトコルをコマンド単位で少しずつ実装できます。AutoPSEO は各コマンドの状態を保持し、次の 3 つの方法で把握します。

  • テスト。 ping が成功するたびに、AutoPSEO はコマンドごとに 1 件の test を送ります。本文は { id, event: "test", occurredAt, site, test: { event: "<command>", ... } } で、test には実際のコマンドと同じ形のサンプルと、どのサイトにも存在しないアドレス <type path>/autopseo-connection-test に関する内容が入っています。実際のコマンドと同じように確認し、何も変更せずに 2xx で応答してください。接続済み にするには { "test": "<command>", "ok": true }、未接続 にするには { "test": "<command>", "ok": false, "error": "unknown_event" } を返します。ok: false で別のエラーコードを返した場合(存在しないアドレスの非公開など)も、接続済みとして扱われます。サンプルは常に test の下にあり、トップレベルには置かれないため、テストを知らない受信側がそこに記事を見つけることはありません。
  • 実際の配信とコール。 2xx ならそのコマンドは接続済み、unknown_event なら未接続になります。
  • ping の応答。 autopseo.events に列挙したコマンドは、テストや配信でさらに状態がわかるまで 宣言済み・未テスト と表示されます。

接続されていないコマンドの配信は保留されます。コンテンツページには、保留中の項目が「サイトはまだ記事の非公開に対応していません」のように一覧表示され、手作業で対応した分には サイト側で完了 ボタンが表示されます。これを押した配信は、AutoPSEO が二度と送信しません。その後のテストや配信でコマンドが接続済みと判明すると、そのコマンドで保留されていたものがすべて送信されます。

ping の応答

ping には 2xx で応答します。記事を受け取るだけのサイトなら、それ以上は必要ありません。サイトが対応している他の機能を AutoPSEO に伝えるには、autopseo オブジェクトを含めます。

{
  "ok": true,
  "autopseo": {
    "version": 1,
    "events": ["article.published", "article.updated", "article.unpublished"],
    "covers": true,
    "indexNow": { "key": "3f1c9a7e5b2d4f60", "keyLocation": "https://example.com/3f1c9a7e5b2d4f60.txt" },
    "export": ["articles", "topics", "profile"],
    "adopt": true,
    "handover": true,
    "types": ["answers"],
    "writer": "site"
  }
}
フィールド意味
version1。1 以上のバージョンがない場合、このオブジェクトは無視されます
events対応しているコマンド。テストされるまでは「宣言済み」と表示されます
coversページに各記事のカバー画像を表示する場合は true。AutoPSEO のライターが執筆と同時にカバーを作成するので最初の配信に含まれ、自社の AI にもカバーの提供が求められます。カバー画像を参照
indexNow{ key, keyLocation? }:サイトで公開している IndexNow キー(英字・数字・ダッシュで 8〜128 文字)と、キーファイルが /<key>.txt にない場合はその https:// の場所
exportsite.export で渡す内容:articles、topics、profile
adoptarticles.adopted を受け付けることを示します。フルスキャンで既存の記事を引き継げるようになります
handoversite.handover を受け付けることを示します
types受け付けるコンテンツタイプのキー(最大 50 個)
writer現在、新しい記事を書くのは誰か:site または autopseo

提供するアドレスの IndexNow

ping の応答で indexNow を宣言していると、AutoPSEO は、受け付けた配信の後に api.indexnow.org へ IndexNow の ping を送ります。サイト側で ping を送る必要はありません。

  • 応答に含めた urls を送信します。urls がなければ応答の url、それもなければ記事の URL を送ります。urls: [] の場合は何も送りません。
  • IndexNow プロトコルの要件に従い、自分のホストのアドレスだけが対象です。ホストは keyLocation のホスト、なければサイトのアドレスのホストです。
  • 配信の実行ごとに、サイトにつき 1 回、最大 10,000 件のアドレスを送ります。
  • 200 と 202 は受理されたものとして扱います。429、5xx、または応答なしの場合は、アドレスを保持して 1 時間後に再試行し、以降は 1 日を上限に倍々で間隔を延ばします(Retry-After が求める場合はそれ以上)。その間に後続の配信で追加されたアドレスも一緒に送ります。403(キーが無効)と 422(アドレスがホスト上にない)は記録され、破棄されます。
  • コンテンツページには、直近の ping の日時、アドレス数、HTTP ステータス、次回の試行が表示されます。
  • AutoPSEO が記事もホスティングしている間は、ホスティングしているページは AutoPSEO 自身のキーで ping され、サイトのキーは使われません。

インデックス登録の IndexNow ツールは、これとは別の手動送信ツールです。IndexNowを参照してください。

競合:正本はサイト側のテキスト

応答の version(またはエクスポートした記事の version)は、AutoPSEO が「ページはこのバージョンである」と認識している値です。すべての article.updated には、これが basedOn として含まれます。たとえば編集者が修正を加えるなどして、その後サイト側でページが変更されていた場合は、何も保存せずに { "error": "conflict" } を伴う 409 で応答してください。配信は再試行なしで失敗します。

公開中の記事に対するキュー済みの変更を書き込む前に、サイトが site.export に対応していれば、AutoPSEO は site.export と paths でその記事をサイトから読み直します。サイト側のバージョンが進んでいれば、サイトのテキストが記事の次のリビジョン(writer は import)となり、続く更新には現在のバージョンが basedOn として含まれます。

path.add と path.remove

この 2 つの配信は、型付きコンテンツタイプの 1 つの値に関するものです。たとえば /interviews/{company}/{role} の company などです。article の代わりに path オブジェクトが含まれます。

{
  "id": "7c1e9d2a-3b4f-4e8a-9c0d-1e2f3a4b5c6d",
  "event": "path.add",
  "occurredAt": "2026-10-12T08:30:00.000Z",
  "site": { "propertyId": "prop_123", "domain": "example.com" },
  "path": {
    "path": "/interviews/jane-street",
    "type": { "key": "interviews", "name": "Interviews", "path": "/interviews" },
    "part": "company",
    "value": {
      "value": "jane-street",
      "name": "Jane Street",
      "aliases": [],
      "url": "https://<its own site>/",
      "sameAs": ["https://<its careers page>/"],
      "description": "<what it is, in a few words>",
      "facts": [
        { "text": "<one sentence, as the source states it>", "source": "https://<the page that says it>", "lastVerified": "2026-10-01", "tier": "official" }
      ]
    },
    "takes": { "role": ["software-engineer"] }
  }
}
フィールド意味
pathサイト上のその値自身のアドレス
typeコンテンツタイプ。{ key, name, path } の形式
partその値が入るアドレスのパート
value{ value, name }。path.add ではさらに、AutoPSEO のライターが集めた aliases、url、sameAs、description、facts が入ります。各ファクトには text、source、lastVerified、そして official または reported の tier があります
takespath.add で、その値と合わせて検索される他のパートの値
redirectTopath.remove で、その値のアドレスを今後どこへ 301 で転送するか

コマンドを受け付けたら 2xx で応答します。path.add ではその値が現在置かれている url を、path.remove では提供を停止した urls を加えることができ、後者は IndexNow に送られます。拒否する場合は、{ "error": "<code>", "details": ["<why>"] } を伴う 4xx で応答してください。path.add では、invalid_value はあなたの理由を添えてその値をライターに差し戻し(3 回目の拒否で確定)、それ以外のコードは恒久的な拒否になります。path.remove では、not_found は完了として扱われ、409 の in_use など他のコードでは何も変更されません。保留中のパスコマンドを サイト側で完了 にすると、サイトが承諾したものとして扱われます。

コール:AutoPSEO が応答を読み取るリクエスト

コールは配信と同じ方法で署名されます。コールの待機時間は、通常は最大 60 秒、再読み込みとパス確認では 30 秒で、最大 24 MB の応答を読み取ります。2xx 以外の応答はすべてコールの失敗になります。

site.export

リクエストには export: { what, cursor, limit } が含まれ、トピックの場合は since(ISO 形式の時刻)、再読み込みの場合は paths も加わります。{ "items": [], "next": "<cursor>" } で応答してください。AutoPSEO は、next が空または null になるまで、cursor に next の値を設定して繰り返し問い合わせます。

  • what: "articles" は、export に articles を宣言し、かつ adopt: true の場合に、フルサイトスキャンで 1 ページ 10 件ずつ実行されます。400 で応答すると「未対応」の意味になります。paths: ["/answers/x"] と limit: 1 が付いている場合は 1 件の記事の再読み込みなので、その記事だけを返してください。
  • what: "topics" はすべてのスキャンと毎日の同期で、1 ページ 50 件ずつ実行されます。毎日の同期では、前回取得した時刻を since として送ります。
  • what: "profile" はすべてのスキャンで実行されます。{ "profile": { types, languages, rules, categories, format } } で応答します。いずれも任意で、types は [{ key, name, path }]、rules には brandTerms、synonyms、noise、onTopic、offTopic を含められます。

エクスポートする記事には、externalId、slug(あらゆる言語の文字と数字、およびハイフン。200 文字以内で、セグメントは / でつなぐ)、そのスラッグで終わる path、そして少なくとも title、h1、1 つのセクションを持つ copy が必要です。任意のフィールドは、lang(デフォルトは en)、type(コンテンツタイプのキー)、url、keyword、category、datePublished、dateModified、version、translations、owner(引き継ぎ後の AutoPSEO の記事 ID)、そして maintenance: { kind: "refresh" または "optimize", reason } です。maintenance を指定するとそのタスクがキューに入ります。エクスポートされた各記事は、サイトのテキストを最初のリビジョンとする管理対象の記事になり、サイトには何も送り返されません。再度エクスポートしても問題なく、AutoPSEO が非公開にした記事がエクスポートによって復活することはありません。

トピック項目は { keyword, status?, volume?, source?, externalId?, type?, reason? } の形で、status は queued、planned、rejected のいずれかです。

articles.adopted

取り込みの後、AutoPSEO は新しい記事を管理下に置いてよいかを尋ねます:{ "articles": [{ "externalId", "articleId", "slug", "path" }] }。{ "adopted": ["<externalId>"], "refused": [{ "externalId": "<id>", "reason": "<why>" }] } で応答してください。確認しなかった記事は、AutoPSEO がすでにその記事のリビジョンを書いている場合を除き、AutoPSEO から再び削除されます。

site.handover

{ "handover": { "types": ["answers"], "writer": "autopseo" } }。プロパティの サイト ページで AutoPSEO に書かせる または 執筆をサイトに戻す を選んだときに送信されます。{ "ok": true, "writer": "autopseo" } で応答してください。AutoPSEO に引き渡した後は、サイトは自分で書く代わりに、精査したトピックを AutoPSEO に渡します。

path.check

{ "paths": [{ "path": "/interviews/google/data-engineer" }, { "path": "/interviews/jane-street", "new": { "part": "company", "value": "jane-street", "name": "Jane Street" } }] }。パスごとに 1 件の結果を返します:{ "results": [{ "path": "/interviews/google/data-engineer", "ok": true }, { "path": "/interviews/jane-street", "ok": false, "error": "not_allowed", "details": ["We only list companies with a careers page."] }] }。拒否は理由とともに記録され、再度問い合わせられることはありません。応答から省いたパスは、後で再び問い合わせられます。

最小構成の受信側

Web Crypto API で署名を検証し、3 つの記事コマンドを処理し、それ以外を unknown_event として拒否する Cloudflare Worker です。記事は ARTICLES としてバインドした KV namespace に、署名シークレットは AUTOPSEO_SECRET という名前の Worker シークレットに保存します。

const HANDLED = ["article.published", "article.updated", "article.unpublished"];
const MAX_SKEW_SECONDS = 300;
const SITE = "https://example.com";

const json = (data, status = 200) =>
  new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });

function hexToBytes(hex) {
  if (!/^[0-9a-f]{64}$/.test(hex)) return null;
  const out = new Uint8Array(32);
  for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
  return out;
}

async function verified(request, body, secret) {
  const timestamp = request.headers.get("X-AutoPSEO-Timestamp") || "";
  const signature = (request.headers.get("X-AutoPSEO-Signature") || "").replace(/^sha256=/, "");
  if (!/^\d+$/.test(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_SKEW_SECONDS) return false;
  const bytes = hexToBytes(signature);
  if (!bytes) return false;
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
  // verify() compares in constant time.
  return crypto.subtle.verify("HMAC", key, bytes, enc.encode(`${timestamp}.${body}`));
}

export default {
  async fetch(request, env) {
    if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
    const body = await request.text();
    if (!(await verified(request, body, env.AUTOPSEO_SECRET))) return json({ error: "bad_signature" }, 401);
    let msg;
    try {
      msg = JSON.parse(body);
    } catch {
      return json({ error: "bad_json" }, 400);
    }

    if (msg.event === "ping") return json({ ok: true, autopseo: { version: 1, events: HANDLED } });

    if (msg.event === "test") {
      const command = msg.test && msg.test.event;
      // Check the sample as you would the real command, and store nothing.
      return HANDLED.includes(command)
        ? json({ test: command, ok: true })
        : json({ test: command, ok: false, error: "unknown_event" });
    }

    if (msg.event === "article.published" || msg.event === "article.updated") {
      const a = msg.article;
      const stored = JSON.parse((await env.ARTICLES.get(a.id)) || "null");
      if (a.basedOn && stored && stored.version && stored.version !== a.basedOn) {
        return json({ error: "conflict", details: ["The page changed on the site."] }, 409);
      }
      // Any change to the page, here or in your own editor, gets a new version.
      const version = crypto.randomUUID();
      await env.ARTICLES.put(a.id, JSON.stringify({ ...a, version }));
      return json({ url: SITE + a.path, version });
    }

    if (msg.event === "article.unpublished") {
      const a = msg.article;
      if (a.redirectTo) await env.ARTICLES.put(a.id, JSON.stringify({ path: a.path, redirectTo: a.redirectTo }));
      else await env.ARTICLES.delete(a.id);
      return json({ ok: true });
    }

    return json({ error: "unknown_event" }, 400);
  },
};

Node.js では、フレームワークが受け取った生の本文に対して、createHmac と timingSafeEqual を使い、同じ方法で検証します。

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyAutopseo(rawBody, headers, secret) {
  const timestamp = String(headers["x-autopseo-timestamp"] || "");
  const given = String(headers["x-autopseo-signature"] || "");
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = "sha256=" + createHmac("sha256", secret).update(timestamp + ".").update(rawBody).digest("hex");
  return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}

次のステップ:Webhook に何が届くかは、執筆とレビューとメンテナンスで決まります。全体の流れはコンテンツエンジンの概要で確認できます。

SEOコンテンツを自動運転に

Search Consoleとサイトを接続してください。AutoPSEOがサイトをスキャンし、最初のトピックをすぐに計画します。クレジットカードは不要です。