Search by

pokoblog / client

rbouma

PHP client for the PokoBlog article API and publish webhook.

Package info

github.com/HeyPoko/pokoblog-php

pkg:composer/pokoblog/client

Statistics

Installs: 11

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.2.0 2026-09-06 14:40 UTC

This package is auto-updated.

Last update: 2026-09-06 15:26:48 UTC


README

Read a customer's published articles, and verify the webhook PokoBlog sends when it publishes one. No framework, no build step, PSR-4.

Installing

composer require pokoblog/client

Requires PHP 8.1+, ext-json and ext-curl.

Reading the blog

use PokoBlog\PokoBlog;

$poko = new PokoBlog(
    baseUrl: 'https://app.pokoblog.example',  // where PokoBlog is served
    token:   'your-embed-connector-token',    // Connections → Embed
);

// A blog index. One request; a card needs no second call.
foreach ($poko->page(limit: 20) as $article) {
    printf(
        '<article><h2><a href="/blog/%s">%s</a></h2><p>%s</p></article>',
        htmlspecialchars($article->slug),
        htmlspecialchars($article->title),
        htmlspecialchars((string) $article->excerpt),
    );
}

// One article. `html` is already sanitized -- echo it, do not escape it.
$article = $poko->article('wat-magento-onderhoud-kost');

echo '<h1>' . htmlspecialchars($article->title()) . '</h1>';
echo $article->html;

Every article, not the first fifty

foreach ($poko->articles() as $article) {
    // Pages are followed for you. Lazy: `break` and it stops fetching.
}

This is a generator, and it is the method to use whenever you want "all of them" — a sitemap, a static build, an import. There is deliberately no method returning the whole blog as one array, because the only honest implementations of that are this walk or the first page pretending to be all of it.

The walk is a consistent snapshot. An article published while it is running lands in front of the walk and is not seen by it; it arrives on the next walk. That is what a build wants, and it is why paging here is a cursor rather than ?page=2 — over a list that grows at the front, offsets serve one article twice and never serve another, and report neither.

Not re-downloading a blog that has not changed

$page = $poko->page(limit: 20);

// … later, from a cache …
$fresh = $poko->revalidatePage($page);

if ($fresh === $page) {
    // 304. Nothing was transferred; carry on with what you had.
}

PokoBlog sends a strong ETag over the exact bytes. revalidatePage() and revalidateArticle() send it back as If-None-Match and hand you the same object when the server says nothing changed, so === is the whole check. If you are on Laravel, pokoblog/laravel does this for you.

Receiving a publish

use PokoBlog\Webhook;
use PokoBlog\Exception\InvalidSignatureException;

$body = Webhook::rawBody();                  // php://input, unparsed

try {
    $event = Webhook::parse(
        secret:  getenv('POKOBLOG_WEBHOOK_SECRET'),  // the whole whsec_… string
        header:  Webhook::signatureFrom(),           // Poko-Signature
        rawBody: $body,
    );
} catch (InvalidSignatureException) {
    http_response_code(403);
    exit;
}

http_response_code(204);   // answer first, work afterwards
flush();

if ($event->isPublished()) {
    // $event->article->slug, ->title, ->image, ->imageAlt …
    // $event->delivery is stable across retries: use it as an idempotency key.
}

Three things this gets right that a hand-written verifier usually does not, and they are all worth copying rather than reimplementing:

  • The signature is over the raw bytes. json_decode then json_encode changes whitespace and key order, and the signature is over bytes. This is by a distance the commonest reason a correct-looking verifier rejects every call.
  • The timestamp is inside the HMAC, and calls more than five minutes old are refused in both directions. Without that, a call captured once can be replayed for as long as the secret lives, verifying perfectly every time.
  • The comparison is hash_equals. == and === stop at the first differing byte, so how long they take measures how much of the correct signature the caller already has — and someone who can make you verify repeatedly recovers the rest a byte at a time and can then forge publishes.

It is a notification, not the record

PokoBlog tries three times over about ten seconds and then gives up. A deploy that overlaps a publish loses the call. That is survivable only because the article list is the source of truth: reconcile from articles() on a slow timer and you will find anything a delivery missed.

Errors

Exception When
NotFoundException 404 — the address reaches nothing
InvalidRequestException 422 — a page size out of range, a bad cursor
ApiException any other non-2xx; ->errorCode is the stable part
MalformedResponseException a 200 that is not our JSON — usually a wrong base URL
TransportException no answer at all
InvalidSignatureException a webhook that is not ours, or is old

All of them extend PokoBlogException.

A 404 means the address reaches nothing and deliberately nothing more specific: a rotated token, a disconnected embed connector, a draft's slug and a slug that never existed all answer it, which is what stops a stranger confirming a draft's address by asking for it. When one surprises you, check the token first — and note that disconnecting the embed connector switches this API off as well, because the widget and the JSON API are the same connector row.

Tests

clients/php/bin/test                                   # everything
clients/php/bin/test --filter "constant_time"          # one test

Uses local php and composer when they exist, and containers when they do not, so the suite runs on a machine that has only ever built the TypeScript half of PokoBlog. Override with POKOBLOG_PHP_IMAGE and POKOBLOG_COMPOSER_IMAGE.