developer docs

Languages

PHP

Upload a chapter with curl, and receive its webhooks in plain PHP.

Plain PHP 8.1 or newer, with the curl extension that nearly every host has, and no Composer packages. Each script was run against the API as it is shown here. Set the key and the secret from the API screen in the environment.

Upload a chapter

Every image in a folder becomes one page of a new chapter, in file-name order. The script starts the run, waits for it, and saves the lettered pages as a zip. It needs a comic project that goes through every step: see downloading files for what other projects have.

upload-chapter.php
<?php
// Uploads a folder of page images to tinypica as one chapter, waits for it to
// be translated, and saves the lettered pages as a zip.
//
//   TINYPICA_KEY=tp_… php upload-chapter.php PROJECT_ID FOLDER [LANGUAGE]
//
// PHP 8.1 or newer, with the curl extension. Pages go in file-name order:
// 001.png, 002.png and so on.

const API = 'https://tinypica.com/api/v1';
const TYPES = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'webp' => 'image/webp'];

[, $projectId, $folder] = $argv;
$language = $argv[3] ?? 'en';

// One request, answered as JSON. An array of fields is sent as
// multipart/form-data, which the upload routes take. A refusal says why, in
// statusMessage.
function tinypica(string $method, string $path, ?array $fields = null): array
{
    $curl = curl_init(API . $path);
    curl_setopt_array($curl, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('TINYPICA_KEY')],
        CURLOPT_RETURNTRANSFER => true,
    ]);
    if ($method === 'POST') {
        curl_setopt($curl, CURLOPT_POSTFIELDS, $fields ?? '');
    }
    $body = curl_exec($curl);
    if ($body === false) {
        throw new RuntimeException(curl_error($curl));
    }
    $json = json_decode($body, true);
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    if ($status >= 400) {
        throw new RuntimeException("$method $path: $status " . ($json['statusMessage'] ?? ''));
    }
    return $json;
}

function type_of(string $name): ?string
{
    return TYPES[strtolower(pathinfo($name, PATHINFO_EXTENSION))] ?? null;
}

// 1. The chapter, empty: staged keeps it waiting for its pages.
$chapter = tinypica('POST', "/projects/$projectId/chapters", ['staged' => '1']);
echo "chapter {$chapter['position']}: {$chapter['id']}\n";

// 2. Its pages, one request each. A page sent again with the same position
//    replaces itself, so one that failed is simply sent again.
$pages = array_values(array_filter(scandir($folder), 'type_of'));
foreach ($pages as $index => $name) {
    tinypica('POST', "/chapters/{$chapter['id']}/pages", [
        'pages' => new CURLFile("$folder/$name", type_of($name), $name),
        'hold' => '1',
        'position' => (string) ($index + 1),
    ]);
}

// 3. The run. Credits are checked here, for every page in every language.
tinypica('POST', "/chapters/{$chapter['id']}/start");

// 4. Wait for the translation. A webhook saves the asking: see webhook.php.
do {
    sleep(10);
    $status = tinypica('GET', "/chapters/{$chapter['id']}");
} while ($status['processing']);
if ($status['failed']) {
    throw new RuntimeException("the run failed: {$status['failureReason']}");
}

// 5. The file follows the translation by a few seconds, and is a 409 until then.
$file = "chapter-{$chapter['position']}-$language.zip";
for ($attempt = 1; ; $attempt++) {
    $out = fopen($file, 'w');
    $curl = curl_init(API . "/chapters/{$chapter['id']}/export/download?language=$language&format=zip");
    curl_setopt_array($curl, [
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('TINYPICA_KEY')],
        CURLOPT_FILE => $out,
    ]);
    curl_exec($curl);
    $code = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    fclose($out);
    if ($code === 200) {
        echo "saved $file\n";
        break;
    }
    if ($code !== 409 || $attempt === 60) {
        throw new RuntimeException("download: $code " . file_get_contents($file));
    }
    sleep(5);
}
Shell
TINYPICA_KEY=tp_… php upload-chapter.php 0cf217a0-c404-4fd7-8732-5f83b9e766ca ./pages en
Output
chapter 13: f56d2008-9c47-4554-9deb-7edc73255de9
saved chapter-13-en.zip

An array of fields makes curl send multipart/form-data, and a CURLFile in it is a file part with its own type: the upload routes take nothing else.

Receive webhooks

A page to serve over https and add as an endpoint on the API screen. It checks the signature, writes the event to a queue, and answers at once, so tinypica never waits on a download.

webhook.php
<?php
// Receives tinypica's webhooks, in plain PHP. Serve it over https and add its
// URL as an endpoint on the API screen, with TINYPICA_WEBHOOK_SECRET set in
// the web server's environment.
//
// A verified event is written to queue/<webhook-id>.json and answered at once:
// process-queue.php does the work, from cron, so nothing slow happens while
// tinypica waits. An event already taken is answered and not written again.

$secret = getenv('TINYPICA_WEBHOOK_SECRET'); // whsec_…
$body = file_get_contents('php://input'); // exactly as it arrived
$id = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$signatures = explode(' ', $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '');

// Standard Webhooks: an HMAC-SHA256 of "id.timestamp.body", keyed with the secret.
$key = base64_decode(substr($secret, strlen('whsec_')));
$expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true));
$signed = false;
foreach ($signatures as $signature) {
    $signed = $signed || hash_equals($expected, preg_replace('/^v1,/', '', $signature));
}
// Five minutes either way, so an old delivery cannot be replayed. The id
// names a file below, so it has to look like one of ours.
if (!$signed || abs(time() - (int) $timestamp) > 300 || !preg_match('/^[\w-]+$/', $id)) {
    http_response_code(401);
    exit;
}

// An event is sent again until it is answered, and an answer can be lost on
// the way back: one that is queued or done already is not queued twice.
@mkdir(__DIR__ . '/queue');
if (!file_exists(__DIR__ . "/queue/$id.json") && !file_exists(__DIR__ . "/done/$id.json")) {
    file_put_contents(__DIR__ . "/queue/$id.json", $body);
}
http_response_code(204);

The queue is a folder beside it, one file per event, named after its webhook-id: an event sent twice lands on the same file. If the web server would serve that folder, deny it in its configuration: the events name your chapters.

Work through the queue

A script for cron to run every minute. It downloads each announced file into files/, and moves each event to done/, where the webhook page looks for repeats.

process-queue.php
<?php
// Works through the events webhook.php queued: downloads every file an
// export.ready announces into files/. Run it from cron, every minute:
//
//   * * * * * TINYPICA_KEY=tp_… php /path/to/process-queue.php
//
// A download that gets no answer stays in queue/ and is tried again the next
// minute; everything else moves to done/, which webhook.php checks for repeats.

const API = 'https://tinypica.com/api/v1/';

@mkdir(__DIR__ . '/files');
@mkdir(__DIR__ . '/done');

foreach (glob(__DIR__ . '/queue/*.json') as $path) {
    $event = json_decode(file_get_contents($path), true);

    if ($event['type'] === 'export.ready') {
        $file = $event['data'];
        // Your key goes to tinypica and nowhere else.
        if (!str_starts_with($file['downloadUrl'], API)) {
            rename($path, __DIR__ . '/done/' . basename($path));
            continue;
        }
        $target = __DIR__ . '/files/' . basename($file['filename']);
        $out = fopen("$target.part", 'w');
        $curl = curl_init($file['downloadUrl']);
        curl_setopt_array($curl, [
            CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('TINYPICA_KEY')],
            CURLOPT_FILE => $out,
        ]);
        curl_exec($curl);
        $code = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        fclose($out);
        if ($code === 200) {
            rename("$target.part", $target);
            echo "saved {$file['filename']}\n";
        }
        else {
            unlink("$target.part");
            // No answer, or a 5xx: tried again the next minute. Any other
            // answer would be the same next time.
            if ($code === 0 || $code >= 500) {
                continue;
            }
            error_log("tinypica: {$file['filename']}: $code");
        }
    }
    elseif ($event['type'] === 'chapter.failed') {
        error_log("tinypica: chapter {$event['data']['position']} failed: {$event['data']['failureReason']}");
    }

    rename($path, __DIR__ . '/done/' . basename($path));
}
crontab
* * * * * TINYPICA_KEY=tp_… php /var/www/hooks/process-queue.php

In a framework

The check is the same wherever it runs, and so is the one thing to get right: check the signature against the body as it arrived. In Laravel, that is $request->getContent(), in a route in routes/api.php, where CSRF protection does not apply; in Symfony, $request->getContent() too. How deliveries are signed, retried and ordered is in webhooks.