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:
'tinypica' => [
'key' => env('TINYPICA_KEY'),
'webhook_secret' => env('TINYPICA_WEBHOOK_SECRET'),
],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.
<?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.
use App\Http\Controllers\TinypicaWebhookController;
Route::post('/tinypica', TinypicaWebhookController::class); An app without routes/api.php gets one with:
php artisan install:apiUpload 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.
<?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();
}
}
php artisan tinypica:upload 0cf217a0-c404-4fd7-8732-5f83b9e766ca ./pagesChapter 13 is running: f56d2008-9c47-4554-9deb-7edc73255de9How deliveries are signed, retried and ordered is in webhooks.