Webhook を受け取る
エントリーを公開したときやメディアを上げたときに、Hako CMS は、決めた URL に Webhook(HTTP の POST)を送ります。このページでは、自分のサーバーで Webhook を受け取り、本当に Hako CMS から届いたものかを確かめ、再送で同じ通知を 2 回処理しないようにする方法を説明します。
Vercel や Netlify のビルドを動かすだけなら、受け取るサーバーは要りません。Webhook の[種類]で[Vercel(Deploy Hook)]か[Netlify(Build Hook)]を選び、ビルドフックの URL を入れます。
- 管理画面で、Webhook を作ってあること。[種類]は[カスタム]にします
- Webhook の[署名のシークレット]を決めてあること(8〜256 文字)。シークレットは、受け取るサーバーにも置きます
- 受け取る URL が、インターネットから届くこと。
https://を勧めます。社内のネットワークなど、プライベートなアドレスには送れません
届くリクエスト
Section titled “届くリクエスト”Webhook は、次の形で届きます。
POST /your/endpoint HTTP/1.1Content-Type: application/jsonUser-Agent: Hako-Webhook/0.1.0X-HAKOCMS-Event: entry.publishedX-HAKOCMS-Delivery: d1e2f3a4b5c6X-HAKOCMS-Signature: 9f2c4e1b7a0d3c5e8f6a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f5a6本文は JSON です。形は、下の「本文:エントリーのイベント」などにあります。
| ヘッダー | 中身 |
|---|---|
X-HAKOCMS-Event |
イベントの名前 |
X-HAKOCMS-Delivery |
送信の ID。再送しても変わらない |
X-HAKOCMS-Signature |
署名。[署名のシークレット]を決めたときだけ付く |
| [カスタムヘッダー] | Webhook に足したヘッダー |
Webhook を受け取るには、次のようにします。
- 本文を、JSON として読む前に、受け取ったままのバイト列で取っておきます。
- 本文のバイト列とシークレットから HMAC-SHA256 を計算し、
X-HAKOCMS-Signatureと比べます。合わなければ401を返して終えます。 - 10 秒以内に
2xxを返します。重い処理は、返したあとに行います。 deliveryIdを見て、すでに処理したものなら何もしません。
Node.js(22 以上)で、この 4 つを行うサーバーの例です。外のパッケージは使いません。server.mjs として保存します。
import { createServer } from "node:http";import { createHmac, timingSafeEqual } from "node:crypto";
const secret = process.env.HAKO_WEBHOOK_SECRET;// 処理した deliveryId。本番では DB などに残すconst handled = new Set();
function verify(rawBody, signature) { if (typeof signature !== "string") return false; const expected = createHmac("sha256", secret) .update(rawBody) .digest("hex"); return ( expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature)) );}
function handle(payload) { // ここで、サイトの作り直しやキャッシュの削除などを行う console.log(payload.event, payload.contentType ?? "", payload.id ?? "");}
createServer((req, res) => { const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { const rawBody = Buffer.concat(chunks); if (!verify(rawBody, req.headers["x-hakocms-signature"])) { res.writeHead(401).end(); return; } const payload = JSON.parse(rawBody.toString("utf8")); // 先に 2xx を返し、重い処理はあとで行う res.writeHead(204).end(); // 再送で同じものが届いたときは、何もしない if (handled.has(payload.deliveryId)) return; handled.add(payload.deliveryId); handle(payload); });}).listen(3000);<シークレット> を、Webhook の[署名のシークレット]に置き換えて起動します。
HAKO_WEBHOOK_SECRET='<シークレット>' node server.mjs署名は、受け取ったままのバイト列で計算します。 JSON として読んでから文字列に戻したものでは、空白やキーの順が変わり、署名が合いません。Express などのフレームワークでは、JSON を読む前の本文を取れるようにします(Express なら express.raw({ type: "application/json" }))。
比べるときは、=== ではなく timingSafeEqual を使います。比べる時間から署名を推し量られないようにするためです。
サーバーを起動したまま、別の端末で、署名付きのリクエストを送ってみます。<シークレット> は、起動したときと同じ値に置き換えます。
body='{"event":"ping","space":"blog","deliveryId":"d1"}'sig=$(printf '%s' "$body" \ | openssl dgst -sha256 -hmac '<シークレット>' | sed 's/^.* //')curl -i -X POST http://localhost:3000 \ -H "Content-Type: application/json" \ -H "X-HAKOCMS-Signature: $sig" \ -d "$body"HTTP/1.1 204 No Content が返り、サーバーの端末に ping と表示されれば、署名を確かめられています。X-HAKOCMS-Signature を変えて送ると、401 が返ります。
サーバーをインターネットから届く場所に置き、Webhook の[送信先の URL]に入れたら、Hako CMS から送ってみます。
- 管理画面の左のメニューで、[Webhook]を押します。
- 受け取るサーバーの Webhook を押します。
- [テストの送信]を押します。
ping のイベントがすぐに送られ、結果が表示されます。[送信履歴]に[成功]と出れば、受け取れています。[失敗]と出たときは、[応答]に、サーバーが返した状態と本文の先頭が出ます。接続できなかったときは、理由(timed out、connection refused、private network address is not allowed など)が出ます。
Webhook がうまく届かなかったとき、Hako CMS は送り直します。
2xx以外が返った、接続できなかった、10 秒で返らなかったときは、失敗です。リダイレクト(3xx)も失敗とし、たどりません- 失敗すると、30 秒、2 分、10 分、1 時間、6 時間あけて、最大 5 回送り直します
- 送り直しても、本文(
deliveryIdを含む)はまったく同じです。deliveryIdかX-HAKOCMS-Deliveryで、同じ通知を見分けられます - 届く順は決まっていません。順が要るときは、
occurredAtで比べます - 操作がうまくいけば、通知は必ず送られます。操作が失敗すれば、送られません。操作から送るまで、ふつう 1〜2 秒かかります
再送を使い切ったあとでも、管理画面の[送信履歴]で[もう一度送る]を押すと、同じ本文を送り直せます。送信履歴は 30 日残ります。
| イベント | 画面の名前 | いつ |
|---|---|---|
entry.published |
エントリーの公開・公開中の更新 | 公開した。公開中のエントリーを公開し直した |
entry.draft_saved |
下書きの保存 | 下書きを保存した(作成を含む) |
entry.unpublished |
公開終了 | 公開をやめた |
entry.deleted |
エントリーの削除 | 削除した |
entry.schedule_failed |
予約公開の失敗 | 予約した公開を行えなかった(必須のフィールドが空など) |
media.uploaded |
メディアの追加 | メディアを上げた |
media.deleted |
メディアの削除 | メディアを削除した |
ping |
テスト | [テストの送信]を押した |
- 管理画面の操作でも、API キーでの書き込みでも送ります。下書きを捨てたときは送りません
- 予約した公開と公開終了が行われたときは、
entry.publishedとentry.unpublishedを、actorを{ "type": "system" }にして送ります - [対象のコンテンツタイプ]を 1 つに絞った Webhook には、メディアのイベントは送りません
本文:エントリーのイベント
Section titled “本文:エントリーのイベント”{ "event": "entry.published", "deliveryId": "d1e2f3a4b5c6", "occurredAt": "2026-10-09T03:00:00.000Z", "space": "blog", "contentType": "articles", "id": "hello", "actor": { "type": "user", "id": "u1" }, "entry": { "status": "published", "published": { "sys": { "id": "hello", "contentType": "articles" }, "title": "こんにちは(改)" }, "draft": null }, "previous": { "status": "published_with_draft", "published": { "sys": { "id": "hello", "contentType": "articles" }, "title": "こんにちは" }, "draft": { "sys": { "id": "hello", "contentType": "articles" }, "title": "こんにちは(改)" } }}例の sys は、一部を省いています。実際には、Content API のエントリーと同じ sys の項目が入ります。
| キー | 中身 |
|---|---|
event |
イベントの名前 |
deliveryId |
送信の ID。X-HAKOCMS-Delivery と同じ |
occurredAt |
操作の日時 |
space |
スペースのスラッグ |
contentType |
コンテンツタイプ ID |
id |
エントリー ID |
actor |
操作したもの。{ "type": "user", "id" }(人)、{ "type": "apiKey", "id" }(API キー)、{ "type": "system" }(予約) |
entry |
操作のあとの状態。削除のときは null |
previous |
操作の前の状態。作ったときは null |
schedule |
entry.schedule_failed のときだけ。{ "action": "publish", "scheduledAt", "error" } |
truncated |
本文が 1 MB を超えるときだけ true。published と draft が null になるので、Content API で取り直す |
entry と previous の中身です。
| キー | 中身 |
|---|---|
status |
draft(一度も公開していない)、published、published_with_draft(公開中で下書きあり)、closed(公開終了) |
published |
公開版。Content API のエントリーと同じ形で、参照は展開せず、リッチテキストは JSON。なければ null |
draft |
下書き。形は published と同じ。なければ null |
本文:メディアのイベント
Section titled “本文:メディアのイベント”{ "event": "media.uploaded", "deliveryId": "e5f6a7b8c9d0", "occurredAt": "2026-10-09T03:00:00.000Z", "space": "blog", "id": "k2m9x0a1b2c3", "actor": { "type": "user", "id": "u1" }, "media": { "sys": { "id": "k2m9x0a1b2c3" }, "url": "https://<メディアのホスト>/media/k2m9x0a1b2c3/hero.jpg", "fileName": "hero.jpg", "mimeType": "image/jpeg", "size": 482113, "kind": "image", "width": 1920, "height": 1080 }}media は、メディアの API で返る形の一部です。alt、tags、日時は入りません。
本文:テスト(ping)
Section titled “本文:テスト(ping)”{ "event": "ping", "space": "blog", "deliveryId": "d1e2f3a4b5c6", "occurredAt": "2026-10-09T03:00:00.000Z", "webhook": { "id": "w1", "name": "本番サイト" }}ほかの種類のとき
Section titled “ほかの種類のとき”| [種類] | 届く本文 |
|---|---|
| [カスタム] | このページの本文 |
| [Vercel(Deploy Hook)] | {}。まだ送っていない通知は 1 回にまとめる |
| [Netlify(Build Hook)] | {}。まだ送っていない通知は 1 回にまとめる |
| [Slack(Incoming Webhook)] | { "text": "..." }。「記事「こんにちは」を公開しました」のような文と、管理画面へのリンク |
どの種類でも、[署名のシークレット]を決めれば署名が付きます。