genko.meDocs
Frameworks and languagesPHP

Tutorial

Implement pagination, preview saved drafts, and verify webhook signatures with hash_hmac() in PHP.

Use genko_get() from Get started to add three features needed on a real site.

Pagination

The delivery API has no page-number parameter. Send limit and offset, then calculate the page count from the returned 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,    ];}

The page number comes from the URL, so validate it before calculating offset.

php
// filter_input returns false or null when the value does not match. Clamp values below 1 to 1.$page = max(1, (int) filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT) ?: 1);$result = genko_page($page);
Pagination links
<nav aria-label="Pagination"><?php if ($result['page'] > 1): ?>    <a href="?page=<?= $result['page'] - 1 ?>" rel="prev">Previous page</a><?php endif; ?>    <span><?= $result['page'] ?> / <?= $result['totalPages'] ?></span><?php if ($result['page'] < $result['totalPages']): ?>    <a href="?page=<?= $result['page'] + 1 ?>" rel="next">Next page</a><?php endif; ?></nav>

limit must be an integer from 1 to 100. Values outside that range return 400. If offset exceeds the total count, contents is an empty array rather than an error.

Do not fetch every entry in a loop on each page view. Requests per page would grow with the entry count and reach the 300 requests per minute limit. Run jobs that need all entries, such as sitemap or feed generation, on a schedule and store the result.

If an entry is published or deleted while you page through results with offset, positions shift and you may get duplicates or miss entries. This is unsuitable for a point-in-time backup.

Preview drafts

The dashboard's Preview action opens a configured URL template with its draftKey substituted. Add that key to the detail request query to fetch the saved draft.

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('Entry not found.');    }    throw $error;}
if ($queries !== []) {    // Keep previews out of search indexes and referrer headers.    header('X-Robots-Tag: noindex, nofollow');    header('Referrer-Policy: no-referrer');    header('Cache-Control: private, no-store');}

Set the preview URL template in the dashboard's API settings (/w/your-workspace/apis/blog/settings).

text
https://your-site.example/post.php?id={id}&draftKey={key}

Keep these constraints in mind:

  • Preview works for a single entry or an object API only. Adding draftKey to a list request does not produce a draft list.
  • The key is 1–64 characters long. An empty string returns 400; a well-formed key without a matching draft returns 404. That is why the code above excludes an empty string.
  • Do not put preview results in a shared cache. They differ from the published version.
  • Set noindex, nofollow and no-referrer on the page. Hide share buttons and canonical URLs that point to a public page during preview.
  • Unsaved edits are not included. Save in the dashboard before opening the preview.

Rebuild or revalidate with webhooks

genko.me sends a small JSON POST when content changes. It does not include the entry body; fetch the entry from the delivery API after receiving the webhook if needed.

A webhook with a secret includes x-genko-timestamp (Unix seconds) and an HMAC-SHA256 hexadecimal signature, prefixed with sha256=, in x-genko-signature. Sign timestamp.rawBody and reject timestamps outside a 300-second window. In PHP, generate the same value with hash_hmac() and compare it with hash_equals().

Verify the signature against the raw request body before parsing JSON. In PHP, read it with file_get_contents('php://input'). $_POST is not populated for a JSON request, and even if it were, it would not match the original body.

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 prevents timing attacks. Do not compare with ==.if (!hash_equals($expected, $signature)) {    http_response_code(401);    exit('Invalid signature');}
$payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
// Acknowledge receipt first; do not wait for slow work here.http_response_code(200);header('Content-Type: application/json');echo json_encode(['received' => true], JSON_THROW_ON_ERROR);
// The payload only reports a change. Fetch the entry from the delivery API if you need its body.if ($payload['api'] === 'blog') {    $entryId = $payload['entry']['id'] ?? null; // entry is null for a test delivery    // Delete cached files or enqueue a rebuild here.}

In PHP, header names are prefixed with HTTP_, uppercased, and use underscores (x-genko-signature becomes $_SERVER['HTTP_X_GENKO_SIGNATURE']). If it is missing, check the received headers in your logs; some Nginx configurations drop headers.

In Laravel and Symfony, $request->getContent() returns the raw body. Exempt this receiver route from the framework's CSRF protection.

Receiver considerations

ItemDetails
Timeout10 seconds. There are no automatic retries
DuplicatesManual resend from delivery logs can deliver the same event more than once. Make handling safe to repeat
EventsSaving an entry by manually clicking Publish sends entry.created or entry.updated, not entry.published
Unpublishentry.updated sets entry.status to draft. Invalidate the old public page too
Test deliveryevent is webhook.test and entry is null. Handle the missing entry

PHP keeps the connection open until the script finishes, even after sending a response. A slow rebuild here can exceed the 10-second timeout. Only do quick work, such as deleting a cache file, inline; hand rebuilding to cron or a queue.

See Webhooks for registration, delivery logs, and troubleshooting.

Last updated: October 14, 2026

On this page