Ittybit webhooks with Laravel queues and events

View Markdown

When Ittybit finishes processing a task, it POSTs a webhook to your application. Instead of doing all the work inline in the controller, you can dispatch a Laravel event and let queued listeners handle each concern independently — updating the database, generating follow-up tasks, notifying users. The webhook handler stays fast, your queue workers do the heavy lifting, and adding new behavior is just another listener.

Configuration

Add your Ittybit credentials to .env:

ITTYBIT_API_KEY=your_ittybit_api_key
ITTYBIT_WEBHOOK_SECRET=your_webhook_secret

Register them in your services config:

// config/services.php
'ittybit' => [
    'api_key' => env('ITTYBIT_API_KEY'),
    'webhook_secret' => env('ITTYBIT_WEBHOOK_SECRET'),
],

Webhook route

Register the route in routes/api.php so it bypasses CSRF verification automatically.

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

Route::post('/webhooks/ittybit', [IttybitWebhookController::class, 'handle']);

Validate the signature

The controller verifies the HMAC signature, then dispatches an event and returns immediately. The queue handles everything else.

// app/Http/Controllers/IttybitWebhookController.php
namespace App\Http\Controllers;

use App\Events\MediaProcessed;
use Illuminate\Http\Request;

class IttybitWebhookController extends Controller
{
    public function handle(Request $request)
    {
        $secret = config('services.ittybit.webhook_secret');
        $signature = $request->header('Ittybit-Signature');

        $computed = hash_hmac('sha256', $request->getContent(), $secret);

        if (!$signature || !hash_equals($computed, $signature)) {
            abort(401, 'Invalid signature');
        }

        $payload = $request->all();

        MediaProcessed::dispatch($payload);

        return response()->json(['status' => 'ok'], 200);
    }
}

The event

The event is a simple data carrier. Listeners receive it and decide what to do.

// app/Events/MediaProcessed.php
namespace App\Events;

use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class MediaProcessed
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(public array $payload)
    {
    }
}

Queued listeners

Each listener implements ShouldQueue so it runs on your queue worker rather than blocking the webhook response. Generate them with artisan:

php artisan make:listener UpdateMediaRecord --event=MediaProcessed
php artisan make:listener GenerateThumbnail --event=MediaProcessed
php artisan make:listener NotifyUser --event=MediaProcessed

UpdateMediaRecord

Updates your database with the task output.

// app/Listeners/UpdateMediaRecord.php
namespace App\Listeners;

use App\Events\MediaProcessed;
use App\Models\Media;
use Illuminate\Contracts\Queue\ShouldQueue;

class UpdateMediaRecord implements ShouldQueue
{
    public function handle(MediaProcessed $event): void
    {
        $payload = $event->payload;

        $media = Media::where('task_id', $payload['id'])->first();

        if (!$media) {
            return;
        }

        if ($payload['status'] === 'completed') {
            $media->update([
                'status' => 'completed',
                'output_url' => $payload['output']['url'],
                'metadata' => [
                    'duration' => $payload['output']['duration'] ?? null,
                    'width' => $payload['output']['width'] ?? null,
                    'height' => $payload['output']['height'] ?? null,
                    'format' => $payload['output']['format'] ?? null,
                ],
            ]);
        } else {
            $media->update(['status' => 'failed']);
        }
    }
}

GenerateThumbnail

Chains a follow-up Ittybit task to extract a thumbnail from the completed video.

// app/Listeners/GenerateThumbnail.php
namespace App\Listeners;

use App\Events\MediaProcessed;
use App\Models\Media;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Http;

class GenerateThumbnail implements ShouldQueue
{
    public function handle(MediaProcessed $event): void
    {
        $payload = $event->payload;

        if ($payload['status'] !== 'completed') {
            return;
        }

        if (($payload['kind'] ?? null) !== 'video') {
            return;
        }

        $media = Media::where('task_id', $payload['id'])->first();

        if (!$media) {
            return;
        }

        $response = Http::withToken(config('services.ittybit.api_key'))
            ->post('https://api.ittybit.com/jobs', [
                'input' => $payload['output']['url'],
                'kind' => 'image',
                'options' => [
                    'start' => 2,
                    'width' => 640,
                    'format' => 'webp',
                ],
            ]);

        $media->update([
            'thumbnail_task_id' => $response->json('id'),
        ]);
    }
}

NotifyUser

Sends a notification when processing finishes.

// app/Listeners/NotifyUser.php
namespace App\Listeners;

use App\Events\MediaProcessed;
use App\Models\Media;
use App\Notifications\MediaReady;
use Illuminate\Contracts\Queue\ShouldQueue;

class NotifyUser implements ShouldQueue
{
    public function handle(MediaProcessed $event): void
    {
        $payload = $event->payload;

        if ($payload['status'] !== 'completed') {
            return;
        }

        $media = Media::where('task_id', $payload['id'])->first();

        if (!$media || !$media->user) {
            return;
        }

        $media->user->notify(new MediaReady($media));
    }
}

Register the listeners

Bind the event to its listeners in your EventServiceProvider.

// app/Providers/EventServiceProvider.php
use App\Events\MediaProcessed;
use App\Listeners\UpdateMediaRecord;
use App\Listeners\GenerateThumbnail;
use App\Listeners\NotifyUser;

protected $listen = [
    MediaProcessed::class => [
        UpdateMediaRecord::class,
        GenerateThumbnail::class,
        NotifyUser::class,
    ],
];

If you’re on Laravel 11+ with automatic event discovery, you can skip this step — Laravel resolves the listeners from their type-hinted handle method signatures.

Running the queue

Start a queue worker so the listeners actually process:

php artisan queue:work
# Supervisor config for production
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/html/storage/logs/worker.log

What happens at runtime

  1. Ittybit POSTs to /api/webhooks/ittybit when a task completes
  2. The controller validates the HMAC signature and returns 200 immediately
  3. MediaProcessed is dispatched onto the queue
  4. UpdateMediaRecord writes the output URL and metadata to the database
  5. GenerateThumbnail fires a follow-up Ittybit task for a thumbnail
  6. NotifyUser sends a notification to the media owner
  7. When the thumbnail task completes, another webhook arrives and the cycle repeats

See also