genko.meDocs

実践

PHPでgenko.meのページ送り、保存済み下書きのプレビュー、hash_hmac()によるWebhook署名検証を実装する手順。

導入で書いたgenko_get()を使い、実際のサイトで必要になる3つを組みます。

ページ送り

配信APIにページ番号のパラメータはありません。limitとoffsetを送り、返ってきたtotalCountからページ数を計算します。

src/Pagination.php
<?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の計算に通す前に検証します。

php
// 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を置き換えて開きます。受け取ったキーをそのまま詳細取得のクエリに足すと、保存済みの下書きが返ります。

public/post.php
<?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テンプレートを設定します。

text
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のリクエストでは埋まらず、埋まったとしても元の本文とは一致しません。

public/genko-webhook.php
<?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日

目次