ドキュメント
配信Webhook:自社サイトで記事を受け取る
AutoPSEO の記事を自社サイトへ送る署名付き Webhook のリファレンスです。ヘッダー、各イベントとペイロード、返すべきレスポンス、リトライ、保留中のコマンド、IndexNow を解説します。
AutoPSEO は、公開・更新・非公開にしたすべての記事を、署名付きの HTTPS POST で自社サイトへ送信します。サイト側で記事を保存し、独自のテンプレートで表示するため、コンテンツは自社のシステムに置かれ、AutoPSEO をやめた後も表示され続けます。このページは、受信側を実装する開発者向けのリファレンスです。署名、各イベントとペイロード、AutoPSEO が読み取るレスポンス、リトライ、保留中のコマンド、IndexNow を説明します。
Webhook を設定する
- プロパティの コンテンツ ページを開き、記事の公開先 に進みます。
- サイト上のWebhookアドレス を入力します。パブリックなホスト名の
https://URL である必要があり、IP アドレスとlocalhostは受け付けられません。AutoPSEO はリダイレクトをたどらないため、最終的なアドレスを指定してください。 - サイトのアドレス を入力します(例:
https://example.com)。各コンテンツタイプのパスはこの配下に置かれるため、/answersタイプの記事はhttps://example.com/answers/<slug>になります。 - 保存してテストを送信 を選びます。AutoPSEO が署名シークレット(
whsec_に続く 48 文字の 16 進数)を作成し、指定したアドレスに ping を送ります。署名を検証する受信側は、まだシークレットを持っていないため、この最初の ping には失敗します。 - 署名シークレット を 表示 または コピー で取り出し、サイト側にシークレットとして保存してから、テストを送信 を選びます。
- サイトが対応している内容 を確認します。各コマンドには 接続済み、未接続、宣言済み・未テスト、未テスト のいずれかが表示されます。
新しいプロパティでは、最初は AutoPSEO が自身のアドレスで記事をホスティングします(ホスティングを参照)。ホスティングと Webhook は並行して使えます。サイトに記事が表示されるようになったら AutoPSEO でのホスティングを停止 を選びます。公開中のすべての記事がサイトのアドレスへ移り、article.published として Webhook に送信されます。このボタンを使うには、直近のテストに応答した Webhook とサイトのアドレスが必要です。ホスティングがオフの間は、Webhook を削除できません。
新しいシークレット を作ると、署名シークレットがその場で置き換わり、古いシークレットでは即座に検証できなくなります。新しいシークレットは、作成後すぐにサイトへ設定してください。
AutoPSEO が配信をキューに入れるのは、直近の ping に 2xx で応答した Webhook だけです。ping が失敗すると(保存時、テストを送信 時、サイトスキャンの開始時)、その Webhook はエラー状態になり、エラー中に公開された記事はその Webhook 向けにキューへ入りません。ping には必ず応答できる状態を保ち、障害を直したら テストを送信 を選んでください。
ヘッダーと署名
すべてのリクエストは JSON 本文を持つ POST で、次のヘッダーが付きます。
| ヘッダー | 値 |
|---|---|
Content-Type | application/json |
User-Agent | AutoPSEO-Webhook/1 |
X-AutoPSEO-Event | イベント名。本文にも event として含まれます |
X-AutoPSEO-Delivery | 配信 ID。本文にも id として含まれます。1 つの配信を再試行しても同じ値です |
X-AutoPSEO-Timestamp | この試行に署名した時刻(Unix 時間、秒) |
X-AutoPSEO-Signature | sha256= に続く、<timestamp>.<raw body> の HMAC-SHA256 を 16 進数にした値 |
リクエストを検証する手順は次のとおりです。
- タイムスタンプヘッダー、ピリオド、そして届いたままのリクエスト本文(JSON のパース前)をこの順につなぎます。
- その文字列に対して HMAC-SHA256 を計算します。鍵は、アプリに表示されるとおりの署名シークレット全体(
whsec_プレフィックスを含む)を UTF-8 バイト列にしたものです。 - 結果を 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 と同じ構造です。
| フィールド | 意味 |
|---|---|
id | AutoPSEO が記事に付ける ID。変わることがないため、キーとして安全に使えます |
slug | アドレスの末尾部分。型付きページでは、google/software-engineer のように / で区切ったセグメントが入ります |
path | サイトからの相対アドレス。タイプのパスにスラッグを足したものです。ページが作成された後は固定されます |
type | { key, name, path }。key は不変で、path はそのタイプの記事を置く場所です(サイトのルートの場合は空) |
lang, langs | このリビジョンの主言語と、含まれるすべての言語 |
rev | リビジョン番号 |
url | AutoPSEO が把握している完全なアドレス。ホスティングをオフにした後はサイトのアドレス、または受信側が最後に返した url です |
datePublished, dateModified | YYYY-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 はまとめている値を示します |
cover | AutoPSEO が記事のために作成したカバー画像。一度取得して自前のコピーを保管してください。ファイル名が画像の SHA-256 なので、アドレスは変わりません |
basedOn | article.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"
}
}
| フィールド | 意味 |
|---|---|
version | 1。1 以上のバージョンがない場合、このオブジェクトは無視されます |
events | 対応しているコマンド。テストされるまでは「宣言済み」と表示されます |
covers | ページに各記事のカバー画像を表示する場合は true。AutoPSEO のライターが執筆と同時にカバーを作成するので最初の配信に含まれ、自社の AI にもカバーの提供が求められます。カバー画像を参照 |
indexNow | { key, keyLocation? }:サイトで公開している IndexNow キー(英字・数字・ダッシュで 8〜128 文字)と、キーファイルが /<key>.txt にない場合はその https:// の場所 |
export | site.export で渡す内容:articles、topics、profile |
adopt | articles.adopted を受け付けることを示します。フルスキャンで既存の記事を引き継げるようになります |
handover | site.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 があります |
takes | path.add で、その値と合わせて検索される他のパートの値 |
redirectTo | path.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 に何が届くかは、執筆とレビューとメンテナンスで決まります。全体の流れはコンテンツエンジンの概要で確認できます。