developer docs

Frameworks

Laravel

A controller for webhooks, and an Artisan command that uploads a chapter.

Laravel 11 or newer, with its own HTTP client and nothing to install: a controller that tinypica's webhooks are sent to, and an Artisan command that uploads a chapter. Both were run in a Laravel 13 app, against the API, before they were put here.

Keys

The key and the endpoint's signing secret, from the API screen, in config/services.php:

config/services.php
'tinypica' => [
    'key' => env('TINYPICA_KEY'),
    'webhook_secret' => env('TINYPICA_WEBHOOK_SECRET'),
],
.env
TINYPICA_KEY=tp_…
TINYPICA_WEBHOOK_SECRET=whsec_…

Receive webhooks

The controller checks the signature against $request->getContent(), the body as it arrived, remembers each event's id so a repeat does nothing, and downloads the file after the answer.

app/Http/Controllers/TinypicaWebhookController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;

// tinypica's webhooks. The endpoint to add on the API screen is
// https://your-site.example/api/tinypica
class TinypicaWebhookController extends Controller
{
    public function __invoke(Request $request): Response
    {
        abort_unless($this->signed($request), 401, 'Not signed by tinypica');

        // An event can come twice: each one is taken once, and remembered for a week.
        if (Cache::add('tinypica:' . $request->header('webhook-id'), true, now()->addWeek())) {
            $event = $request->json()->all();
            if ($event['type'] === 'export.ready') {
                // After the answer, which tinypica waits 10 seconds for. Under
                // a queue worker, a queued job does this better.
                dispatch(fn () => $this->save($event['data']))->afterResponse();
            }
        }
        return response()->noContent();
    }

    // Standard Webhooks: an HMAC-SHA256 of "id.timestamp.body", keyed with the
    // secret, over the body exactly as it arrived.
    private function signed(Request $request): bool
    {
        $id = (string) $request->header('webhook-id');
        $timestamp = (string) $request->header('webhook-timestamp');
        // Five minutes either way, so an old delivery cannot be replayed.
        if ($id === '' || abs(time() - (int) $timestamp) > 300) {
            return false;
        }
        $key = base64_decode(substr(config('services.tinypica.webhook_secret'), strlen('whsec_')));
        $expected = base64_encode(hash_hmac('sha256', "$id.$timestamp." . $request->getContent(), $key, true));
        foreach (explode(' ', (string) $request->header('webhook-signature')) as $signature) {
            if (hash_equals($expected, preg_replace('/^v1,/', '', $signature))) {
                return true;
            }
        }
        return false;
    }

    private function save(array $file): void
    {
        // Your key goes to tinypica and nowhere else.
        if (!str_starts_with($file['downloadUrl'], 'https://tinypica.com/api/v1/')) {
            return;
        }
        $response = Http::withToken(config('services.tinypica.key'))->timeout(300)->get($file['downloadUrl'])->throw();
        // The default disk here; S3 or any other disk works the same.
        Storage::put('tinypica/' . basename($file['filename']), $response->body());
    }
}

Its route goes in routes/api.php, where CSRF protection does not apply. Add https://your-site.example/api/tinypica as an endpoint on the API screen.

routes/api.php
use App\Http\Controllers\TinypicaWebhookController;

Route::post('/tinypica', TinypicaWebhookController::class);

An app without routes/api.php gets one with:

Shell
php artisan install:api

Upload a chapter

An Artisan command that sends a folder of page images as one chapter, and starts it. Laravel finds it in app/Console/Commands by itself.

app/Console/Commands/UploadChapter.php
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

// php artisan tinypica:upload PROJECT_ID FOLDER
class UploadChapter extends Command
{
    protected $signature = 'tinypica:upload {project : The project\'s id} {folder : A folder of page images}';

    protected $description = 'Upload a folder of page images to tinypica as one chapter, and start it';

    private const TYPES = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'webp' => 'image/webp'];

    public function handle(): int
    {
        $folder = $this->argument('folder');
        // Pages in file-name order: 1.png, 2.png, …, 10.png.
        $pages = collect(scandir($folder))
            ->filter(fn ($name) => isset(self::TYPES[strtolower(pathinfo($name, PATHINFO_EXTENSION))]))
            ->sort(SORT_NATURAL)
            ->values();

        // The chapter, empty: staged keeps it waiting for its pages.
        $chapter = $this->tinypica()->asMultipart()
            ->post("/projects/{$this->argument('project')}/chapters", ['staged' => '1'])
            ->json();

        // Its pages, one request each. A page sent again with the same
        // position replaces itself, so one that failed is simply sent again.
        foreach ($pages as $index => $name) {
            $type = self::TYPES[strtolower(pathinfo($name, PATHINFO_EXTENSION))];
            $this->tinypica()
                ->attach('pages', file_get_contents("$folder/$name"), $name, ['Content-Type' => $type])
                ->post("/chapters/{$chapter['id']}/pages", ['hold' => '1', 'position' => (string) ($index + 1)]);
        }

        // The run. Credits are checked here, for every page in every language.
        $this->tinypica()->post("/chapters/{$chapter['id']}/start");
        $this->info("Chapter {$chapter['position']} is running: {$chapter['id']}");
        return self::SUCCESS;
    }

    // A fresh request each time, with the key, throwing on any refusal.
    private function tinypica(): PendingRequest
    {
        return Http::withToken(config('services.tinypica.key'))
            ->baseUrl('https://tinypica.com/api/v1')
            ->throw();
    }
}
Shell
php artisan tinypica:upload 0cf217a0-c404-4fd7-8732-5f83b9e766ca ./pages
Output
Chapter 13 is running: f56d2008-9c47-4554-9deb-7edc73255de9

How deliveries are signed, retried and ordered is in webhooks.