コンテンツにスキップ

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:// を勧めます。社内のネットワークなど、プライベートなアドレスには送れません

Webhook は、次の形で届きます。

POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Hako-Webhook/0.1.0
X-HAKOCMS-Event: entry.published
X-HAKOCMS-Delivery: d1e2f3a4b5c6
X-HAKOCMS-Signature: 9f2c4e1b7a0d3c5e8f6a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f5a6

本文は JSON です。形は、下の「本文:エントリーのイベント」などにあります。

ヘッダー 中身
X-HAKOCMS-Event イベントの名前
X-HAKOCMS-Delivery 送信の ID。再送しても変わらない
X-HAKOCMS-Signature 署名。[署名のシークレット]を決めたときだけ付く
[カスタムヘッダー] Webhook に足したヘッダー

Webhook を受け取るには、次のようにします。

  1. 本文を、JSON として読む前に、受け取ったままのバイト列で取っておきます。
  2. 本文のバイト列とシークレットから HMAC-SHA256 を計算し、X-HAKOCMS-Signature と比べます。合わなければ 401 を返して終えます。
  3. 10 秒以内に 2xx を返します。重い処理は、返したあとに行います。
  4. 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 から送ってみます。

  1. 管理画面の左のメニューで、[Webhook]を押します。
  2. 受け取るサーバーの Webhook を押します。
  3. [テストの送信]を押します。

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 には、メディアのイベントは送りません
{
"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
{
"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、日時は入りません。

{
"event": "ping",
"space": "blog",
"deliveryId": "d1e2f3a4b5c6",
"occurredAt": "2026-10-09T03:00:00.000Z",
"webhook": { "id": "w1", "name": "本番サイト" }
}
[種類] 届く本文
[カスタム] このページの本文
[Vercel(Deploy Hook)] {}。まだ送っていない通知は 1 回にまとめる
[Netlify(Build Hook)] {}。まだ送っていない通知は 1 回にまとめる
[Slack(Incoming Webhook)] { "text": "..." }。「記事「こんにちは」を公開しました」のような文と、管理画面へのリンク

どの種類でも、[署名のシークレット]を決めれば署名が付きます。