DocsIntegrations
Laravel
A Laravel example that creates jobs from JSON, uploads from the browser, verifies webhooks and redirects downloads.
What you build#
An app that turns an uploaded file into an MP4, or into an MP4, a poster and a GIF preview in one
job. The browser uploads straight to Videxo with a one-time URL. The server keeps the API key,
creates and reads the job, verifies the webhook and redirects downloads to fresh signed links.
Every call to Videxo sits in app/Services/Videxo.php, with plain HTTP.
These files are additions to a fresh Laravel 12 app, not a whole application skeleton.
The whole project: videxo-laravel.zip, or browse it in the examples repository (opens in a new tab).
Before you start#
- An API key from the dashboard's API keys page, in
VIDEXO_API_KEY. - The signing secret from the dashboard's Webhooks page, in
VIDEXO_WEBHOOK_SECRET. - PHP 8.2 or newer, Composer and a fresh Laravel 12 app.
Both secrets stay in the server environment. These demo routes share one API key; add your app's authentication and per-user checks for uploads and jobs before exposing them to users.
Install#
Create an app with composer create-project laravel/laravel ../videxo-laravel '^12.0'. Inside it,
run composer require standard-webhooks/standard-webhooks:^1.0.2. Copy the example's app/,
requests/ and public/index.html into the app. Merge the routes into routes/web.php, replacing
the welcome route. Merge this entry into the existing config/services.php array:
'videxo' => [
'url' => env('VIDEXO_API_URL') ?: 'https://api.videxo.io',
'key' => env('VIDEXO_API_KEY'),
'webhook_secret' => env('VIDEXO_WEBHOOK_SECRET'),
'webhook_url' => env('VIDEXO_WEBHOOK_URL'),
],Copy failed. The text is selected; copy it with your keyboard.
Create a job#
The JSON bodies are read from requests/transcode.json and requests/jobs.json at runtime.
transcode makes one MP4; jobs makes an MP4, a poster and a GIF preview together. Only a
completed upload's videxo://uploads/<id> is accepted. The key is transcode-<upload id> or
jobs-<upload id>, so retrying the same upload and kind within 24 hours returns the same job:
public function createJob(string $kind, string $input): array
{
if (!preg_match('~^videxo://uploads/(up_[a-f0-9]{24})$~D', $input, $match)) {
throw new VidexoProblem([
'type' => 'about:blank', 'title' => 'The request is not valid.', 'status' => 400,
'detail' => 'input_url must be the input_url of an upload.', 'code' => 'invalid_request',
]);
}
$kind = $kind === 'jobs' ? 'jobs' : 'transcode';
$body = json_decode(file_get_contents(base_path("requests/$kind.json")), true, 512, JSON_THROW_ON_ERROR);
if ($kind === 'jobs') $body['inputs'] = ['talk' => $input];
else $body['input'] = $input;
if ($url = config('services.videxo.webhook_url')) $body['webhook_url'] = $url;
return $this->request('/v1/'.$kind, 'POST', $body, "$kind-{$match[1]}");
}Copy failed. The text is selected; copy it with your keyboard.
The route returns the created Job with 202, so the page can follow it:
public function create(Request $request): Response
{
return $this->respond(function () use ($request) {
$kind = $request->input('kind') === 'jobs' ? 'jobs' : 'transcode';
$input = $request->input('input_url');
// The page needs the Job now. CreateVidexoJob is the background variant for your own code.
$job = $this->videxo->createJob($kind, is_string($input) ? $input : '');
return response()->json($job, 202);
});
}Copy failed. The text is selected; copy it with your keyboard.
For a browser request the controller calls the service directly. For your own background work,
use CreateVidexoJob::dispatch('transcode', $inputUrl) (or jobs) and run
php artisan queue:work. The queued job creates the same Videxo Job on a retry and logs its id;
replace that log with saving the id on your order. A queued dispatch does not return the Videxo
Job to the page.
final class CreateVidexoJob implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $backoff = 30;
public function __construct(public string $kind, public string $input) {}
public function handle(Videxo $videxo): void
{
// The service derives the same key from the upload on each retry.
$job = $videxo->createJob($this->kind, $this->input);
// Your code: store this id on the order so your webhook can update it later.
Log::info('Videxo job created', ['job_id' => $job['id']]);
}
}Copy failed. The text is selected; copy it with your keyboard.
Every API call uses one helper. API errors become an exception carrying the original problem details:
private function request(string $path, string $method = 'GET', ?array $body = null, ?string $key = null): array|string
{
$apiKey = config('services.videxo.key') ?: throw new RuntimeException('Set VIDEXO_API_KEY on the server.');
$http = Http::baseUrl(rtrim(config('services.videxo.url'), '/'))
->withToken($apiKey)->acceptJson()->withoutRedirecting()->connectTimeout(5)->timeout(30);
if ($key !== null) $http = $http->withHeaders(['Idempotency-Key' => $key]);
$response = $http->send($method, $path, $body === null ? [] : ['json' => $body]);
if ($response->failed()) {
$problem = $response->json();
throw new VidexoProblem(is_array($problem) && isset($problem['code']) ? $problem : [
'type' => 'about:blank', 'title' => 'The API answered with an error.',
'status' => $response->status(), 'detail' => 'HTTP '.$response->status(), 'code' => 'http_error',
]);
}
if ($response->redirect()) return $response->header('Location') ?: throw new RuntimeException('The API returned no download link.');
return $response->json();
}Copy failed. The text is selected; copy it with your keyboard.
The error handler keeps the status and application/problem+json, including field errors, so the
shared page can show them:
private function respond(callable $route): Response
{
try {
return $route();
} catch (VidexoProblem $error) {
return response()->json($error->problem, $error->problem['status'], ['Content-Type' => 'application/problem+json']);
} catch (Throwable $error) {
report($error);
return response()->json(['title' => 'Something went wrong.', 'status' => 500], 500, ['Content-Type' => 'application/problem+json']);
}
}Copy failed. The text is selected; copy it with your keyboard.
Receive the webhook#
Videxo sends a signed POST when the job ends (Webhooks). Verify the raw body
exactly as it arrived. The library checks the signature and rejects timestamps more than five
minutes away:
public function verifyWebhook(string $raw, array $headers): array
{
$secret = config('services.videxo.webhook_secret') ?: throw new RuntimeException('Set VIDEXO_WEBHOOK_SECRET on the server.');
// Composer 1.0.2 can throw a runtime error for a signature entry without a comma.
// Accept the space-separated signatures used during secret rotation too.
$signatures = $headers['webhook-signature'] ?? '';
if (!is_string($signatures) || !preg_match('~\Av[0-9]+,[A-Za-z0-9+/]+={0,2}(?: v[0-9]+,[A-Za-z0-9+/]+={0,2})*\z~', $signatures)) {
throw new WebhookVerificationException('Invalid signature header.');
}
return (new Webhook($secret))->verify($raw, $headers);
}Copy failed. The text is selected; copy it with your keyboard.
public function webhook(Request $request): Response
{
return $this->respond(function () use ($request) {
$max = 2_097_152;
if ((float) $request->header('Content-Length', '0') > $max) return response('', 413);
// Bootstrap skips JSON transformations here so nothing eagerly reads the full body.
$stream = $request->getContent(true);
if (!is_resource($stream)) throw new \RuntimeException('Could not read the webhook.');
try {
$raw = stream_get_contents($stream, $max + 1);
} finally {
fclose($stream);
}
if ($raw === false) throw new \RuntimeException('Could not read the webhook.');
if (strlen($raw) > $max) return response('', 413);
try {
$event = $this->videxo->verifyWebhook($raw, [
'webhook-id' => $request->header('webhook-id'),
'webhook-timestamp' => $request->header('webhook-timestamp'),
'webhook-signature' => $request->header('webhook-signature'),
]);
} catch (WebhookVerificationException) {
return response('', 400);
}
// Explicit file store: works on a fresh app, even without a cache database migration.
// In your app use a unique webhook-id column in the business update's transaction.
$cache = Cache::store('file');
$key = 'videxo-webhook:'.$request->header('webhook-id');
$cache->lock($key.':lock', 30)->block(5, function () use ($cache, $key, $event) {
if ($cache->has($key)) return;
// Your code: mark the order done or enqueue slow work. Log only ids.
Log::info('Videxo job ended', ['type' => $event['type'], 'job_id' => $event['data']['id']]);
$cache->forever($key, true);
});
return response('', 204);
});
}Copy failed. The text is selected; copy it with your keyboard.
The Composer library 1.0.2 can throw for a malformed signature header, so the helper checks the
signature-list format first and responds with 400, including for v1 or garbage.
A delivery can arrive more than once with the same webhook-id. The single-server demo remembers
handled ids under a lock and responds with 204. In your app, use a unique column in the same
transaction as the business update. Keep slow work in your own queue so the response arrives within
seconds.
The handler uses the file cache explicitly. It needs no cache table on a fresh app, but clearing
the cache forgets those ids. Merge this call into the existing withMiddleware callback in
bootstrap/app.php, preserving the rest of that file:
// The identical static demo page sends no session CSRF token. Exempt only these demo POSTs
// and the signed webhook. In a signed-in app protect the browser routes with auth and CSRF.
$middleware->validateCsrfTokens(except: ['api/uploads', 'api/jobs', 'webhooks/videxo']);
// Avoid eager JSON reads before the webhook controller applies its bounded raw-body limit.
$middleware->trimStrings(except: [fn ($request) => $request->is('webhooks/videxo')]);
$middleware->convertEmptyStringsToNull(except: [fn ($request) => $request->is('webhooks/videxo')]);Copy failed. The text is selected; copy it with your keyboard.
The webhook also skips JSON transformations so they cannot read its body before the size check. Only the signed webhook and the two demo POST routes skip CSRF. The static demo page sends no session token. In a signed-in application, use authentication and CSRF on the browser routes.
The route rejects bodies over 2 MiB (2,097,152 bytes) with 413, checking Content-Length first and
otherwise reading at most 2 MiB plus one byte before verification. PHP's post_max_size and any
reverse proxy limit also apply; keep them at least 2M (PHP defaults to 8M).
Upload a file#
The server creates a one-time upload URL and gives the browser only that URL and the input URL:
public function createUpload(array $file): array
{
return $this->request('/v1/uploads', 'POST', $file);
}Copy failed. The text is selected; copy it with your keyboard.
public function upload(Request $request): Response
{
return $this->respond(function () use ($request) {
$upload = $this->videxo->createUpload([
'filename' => $request->input('filename'),
'size_bytes' => $request->input('size_bytes'),
'content_type' => $request->input('content_type'),
]);
return response()->json(['input_url' => $upload['input_url'], 'upload_url' => $upload['upload_url']], 201);
});
}Copy failed. The text is selected; copy it with your keyboard.
The browser then sends the file with PUT to upload_url, with no API key, and creates the job
with input_url. See Files for limits and expiry.
Download the outputs#
For each click, ask GET /v1/jobs/{id}/outputs/{alias} for a fresh link without following the
redirect in the HTTP client. Then redirect the browser to its Location:
public function outputUrl(string $id, string $alias): string
{
return $this->request('/v1/jobs/'.rawurlencode($id).'/outputs/'.rawurlencode($alias));
}Copy failed. The text is selected; copy it with your keyboard.
public function download(string $id, string $alias): Response
{
return $this->respond(fn () => redirect()->away($this->videxo->outputUrl($id, $alias), 302));
}Copy failed. The text is selected; copy it with your keyboard.
A job's outputs[].download_url is also a signed link. It expires at
download_url_expires_at, within an hour.
Run it#
Append the names from .env.example to the Laravel app's existing .env, preserving its other
values. Fill in the secrets. Set PORT to a free local port and run
php artisan serve --host=127.0.0.1 --port="$PORT". Open http://127.0.0.1:$PORT. No frontend
build or queue worker is needed for the demo page; the README gives the exact copy and merge steps.
Open the page, pick a file and choose the job. The page follows the job every 2 seconds and lists
its outputs as download links. VIDEXO_API_URL defaults to https://api.videxo.io; set it to your
local API's origin when using the local stack.
Webhooks need a public https URL. Tunnel this app's port, set VIDEXO_WEBHOOK_URL to the tunnel
address plus /webhooks/videxo, and restart the app. Without it the page still follows the job by
polling. Or set your account's default endpoint on the Webhooks page.
Next steps#
- Operations and Presets: more JSON job options.
- Webhooks: events, retries and delivery logs.
- Errors: problem codes and field errors.
app/Services/Videxo.phpis the one file to change when replacing the HTTP client.