Tom Gould

Writing

Publishing to Apple News from PHP

What it takes to publish an article to Apple News from PHP, from signing requests and building multipart uploads to the JSON details Apple’s format is strict about.

Publishing to Apple News comes in two parts. One is the Apple News Publisher API, an HTTP API with its own way of signing requests. The other is Apple News Format, or ANF, a JSON document that describes the article: its components, styles and layout. My Apple News API library for PHP handles both. This is a walk through what each part involves, and a few details that are easy to get wrong.

Signing requests

The API doesn’t use OAuth or a bearer token. Each request carries an Authorization header signed with HMAC-SHA256, using a key ID and a secret from your Apple News channel.

The string to sign is the HTTP method, the full URL and the current date in UTC, joined with nothing in between. For a POST, the content type and the whole body are added on the end. The secret arrives base64-encoded, so it’s decoded before signing, and the signature is base64-encoded again:

$canonical = $method . $url . $date;

if ($method === 'POST' && $contentType !== null && $body !== null) {
    $canonical .= $contentType . $body;
}

$signature = base64_encode(hash_hmac('sha256', $canonical, base64_decode($secret, true), true));
$header = sprintf('HHMAC; key=%s; signature=%s; date=%s', $keyId, $signature, $date);

Two things to watch. The body you sign must be byte for byte the body you send, multipart boundaries included. And a secret that isn’t valid base64 should fail loudly, so the library decodes in strict mode and throws if it can’t.

Uploading an article

Creating an article is a multipart/form-data POST to the channel. The message must contain an article.json part. It can also have a metadata part, wrapped in a data key, and one part for each image that the article refers to with a bundle:// URL.

The client builds that body for you. You pass a map of bundle names to local files:

$article->addComponent(Photo::fromBundle('hero.jpg'));

$client->createArticle($channelId, $article, null, [
    'bundle://hero.jpg' => '/path/to/hero.jpg',
]);

Updating needs one more thing: the article’s current revision token, from the last response. The client adds it to the metadata part, so the call is updateArticle($articleId, $revision, $article). If the article has changed since, Apple answers with a 409 conflict, and you fetch a fresh revision and try again.

Building the document

ANF documents can be large: dozens of component types, named text styles, component styles, layouts, animations and conditional rules for dark mode or narrow screens. The library models all of it as PHP objects with a fluent interface:

$article = Article::create(identifier: 'story-123', title: 'A headline', language: 'en');

$article
    ->addComponent(new Title('A headline'))
    ->addComponent(new Heading('Background', level: 2))
    ->addComponent((new Body('The <strong>first</strong> paragraph.'))->setFormat('html'));

Each object knows its own ANF role and serialises itself. The article collects them, along with its styles and metadata, into the final document.

The empty object problem

This week’s release, 2.0.1, fixed a PHP problem that shows up with any strict JSON API. PHP has one array type for lists and maps, so json_encode([]) produces []. But in ANF an empty layout or style is an object, and Apple expects {}. Send [] and the document is invalid.

The fix walks the finished data and swaps every empty array for an empty stdClass, which encodes as {}:

private static function convertEmptyArraysToObjects(mixed $data): mixed
{
    if (is_array($data)) {
        if (empty($data)) {
            return new \stdClass();
        }
        return array_map(self::convertEmptyArraysToObjects(...), $data);
    }
    return $data;
}

When Apple says no

Apple’s error responses name the problem and the field: an error code such as INVALID_DOCUMENT and a key path such as components[0].text. The library turns them into an AppleNewsException with getErrorCode() and getKeyPath(), and a separate AuthenticationException for 401 and 403.

Bring your own HTTP client

The library doesn’t ship an HTTP client. It depends on the PSR-18 client and PSR-17 factory interfaces, so it works with Guzzle, Symfony’s client or whatever your application already uses, and has no framework dependency. It needs PHP 8.1 or later. PHPUnit tests and PHPStan at level 8 run on every push.

composer require tomgould/apple-news-api guzzlehttp/guzzle guzzlehttp/psr7

The documentation covers every class, and the code is on GitHub.