実践
PHPでgenko.meのページ送り、保存済み下書きのプレビュー、hash_hmac()によるWebhook署名検証を実装する手順。
導入で書いたgenko_get()を使い、実際のサイトで必要になる3つを組みます。
ページ送り
配信APIにページ番号のパラメータはありません。limitとoffsetを送り、返ってきたtotalCountからページ数を計算します。
<?php
declare(strict_types=1);
const GENKO_PER_PAGE = 10;
/** * @return array{contents: list<array<string, mixed>>, totalCount: int, totalPages: int, page: int} */function genko_page(int $page): array{ $list = genko_get('/blog', [ 'limit' => GENKO_PER_PAGE, 'offset' => ($page - 1) * GENKO_PER_PAGE, 'orders' => '-publishedAt', ]);
return [ 'contents' => $list['contents'], 'totalCount' => $list['totalCount'], 'totalPages' => max(1, (int) ceil($list['totalCount'] / GENKO_PER_PAGE)), 'page' => $page, ];}ページ番号はURLから届くので、offsetの計算に通す前に検証します。
// filter_input はマッチしないと false / null を返す。1未満は1に寄せる。$page = max(1, (int) filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT) ?: 1);$result = genko_page($page);<nav aria-label="ページ送り"><?php if ($result['page'] > 1): ?> <a href="?page=<?= $result['page'] - 1 ?>" rel="prev">前のページ</a><?php endif; ?> <span><?= $result['page'] ?> / <?= $result['totalPages'] ?></span><?php if ($result['page'] < $result['totalPages']): ?> <a href="?page=<?= $result['page'] + 1 ?>" rel="next">次のページ</a><?php endif; ?></nav>limitは1〜100の整数です。範囲外を渡すと400になります。offsetが総件数を超えた場合はエラーではなく、contentsが空配列で返ります。
全件をループで取り切る処理を、ページ表示のたびに走らせないでください。 1ページあたりのリクエストが記事数に比例して増え、1分300リクエストの制限に届きます。サイトマップやフィードの生成など、全件が必要な処理は定期実行にして結果を保存します。
なお、offsetでページを辿る途中に記事が公開・削除されると一覧の位置がずれ、重複や取りこぼしが起きます。時点を固定したバックアップの用途には使えません。
保存済み下書きのプレビュー
管理画面の「プレビュー」は、設定したURLテンプレートのdraftKeyを置き換えて開きます。受け取ったキーをそのまま詳細取得のクエリに足すと、保存済みの下書きが返ります。
<?php
require __DIR__ . '/../src/Genko.php';
$id = $_GET['id'] ?? '';$draftKey = $_GET['draftKey'] ?? null;
$queries = [];if (is_string($draftKey) && $draftKey !== '') { $queries['draftKey'] = $draftKey;}
try { $post = genko_get('/blog/' . rawurlencode($id), $queries);} catch (GenkoApiError $error) { if ($error->getCode() === 404) { http_response_code(404); exit('記事が見つかりません。'); } throw $error;}
if ($queries !== []) { // プレビューは検索エンジンにもリファラにも渡さない。 header('X-Robots-Tag: noindex, nofollow'); header('Referrer-Policy: no-referrer'); header('Cache-Control: private, no-store');}管理画面のAPI設定(/w/your-workspace/apis/blog/settings)で、プレビューURLテンプレートを設定します。
https://your-site.example/post.php?id={id}&draftKey={key}押さえておく点です。
- 対象は単一コンテンツとオブジェクト形式だけです。一覧に
draftKeyを付けても下書き一覧にはなりません。 - キーは1〜64文字です。空文字を送ると
400、形式は正しくても一致する下書きがなければ404になります。上のコードで空文字を除いているのはこのためです。 - プレビューの結果を共有キャッシュに載せないでください。 公開表示とは別の内容です。
- ページに
noindex, nofollowとno-referrerを設定し、公開URLを組み立てる共有ボタンやcanonicalはプレビュー中に出さないでください。 - 未保存の編集内容は反映されません。管理画面で保存してから開きます。
Webhookで再生成・再検証する
genko.meは、コンテンツが変わったことを小さなJSONでPOSTします。記事本文は送られてこないので、必要なら受信後に配信APIから取り直します。
シークレットを設定したWebhookはx-genko-timestamp(Unix秒)とx-genko-signature(timestamp.rawBodyのHMAC-SHA256、sha256=付き)を送ります。現在時刻との差が300秒を超えたら拒否します。PHPではhash_hmac()で同じ値を作り、hash_equals()で比べます。
署名はJSONへ変換する前の、生の本文に対して検証します。 PHPではfile_get_contents('php://input')がそれにあたります。$_POSTはJSONのリクエストでは埋まらず、埋まったとしても元の本文とは一致しません。
<?php
declare(strict_types=1);
$secret = getenv('GENKO_WEBHOOK_SECRET');if ($secret === false || $secret === '') { http_response_code(500); exit('Webhook secret is not configured');}
$rawBody = file_get_contents('php://input');$signature = $_SERVER['HTTP_X_GENKO_SIGNATURE'] ?? '';$timestamp = $_SERVER['HTTP_X_GENKO_TIMESTAMP'] ?? '';if (!preg_match('/^(0|[1-9][0-9]*)$/D', $timestamp) || strlen($timestamp) > 10 || abs(time() - (int) $timestamp) > 300) { http_response_code(401); exit('Invalid timestamp');}$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
// hash_equals はタイミング攻撃を避ける比較。== で比べないこと。if (!hash_equals($expected, $signature)) { http_response_code(401); exit('Invalid signature');}
$payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
// 受け取ったことを先に返し、重い処理はここで待たない。http_response_code(200);header('Content-Type: application/json');echo json_encode(['received' => true], JSON_THROW_ON_ERROR);
// 送られてくるのは変更の知らせだけ。本文が要るなら配信APIから取り直す。if ($payload['api'] === 'blog') { $entryId = $payload['entry']['id'] ?? null; // テスト送信では entry が null // ここでキャッシュファイルの削除や、再生成ジョブの登録を行う。}ヘッダー名は、PHPではHTTP_を付けて大文字・アンダースコア区切りになります(x-genko-signatureは$_SERVER['HTTP_X_GENKO_SIGNATURE'])。Nginxの設定によっては一部のヘッダーが落ちることがあるので、届かない場合は受信ログでヘッダー一覧を確認してください。
Laravelでは$request->getContent()、Symfonyでは$request->getContent()が生の本文です。フレームワークのCSRF保護は、この受信ルートでは除外します。
受信側で気をつけること
| 事項 | 内容 |
|---|---|
| タイムアウト | 10秒。自動リトライはありません |
| 重複 | 配信ログからの手動再送で同じイベントが複数回届きます。何度処理しても壊れない作りにします |
| イベント | 手動で「公開」にした保存はentry.publishedではなくentry.created/entry.updatedです |
| 非公開への変更 | entry.updatedでentry.statusがdraftになります。古い公開ページの無効化も必要です |
| テスト送信 | eventはwebhook.test、entryはnullです。entryがない場合を必ず扱ってください |
PHPは応答を返してもスクリプトが終わるまで接続を保つため、重い再生成をこの中で走らせると10秒を超えます。キャッシュファイルの削除のような軽い処理だけをその場で行い、再生成はcronやキューに渡してください。
登録・配信ログ・失敗時の調べ方はWebhookにまとめています。
関連
最終更新: 2026年10月14日