DocsIntegrations
PHP without a framework
A PHP without a framework 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 src/Videxo.php, with plain HTTP.
The whole project: videxo-php.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, the cURL extension and Composer.
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#
From the downloaded project, run composer install. It installs
standard-webhooks/standard-webhooks (opens in a new tab)
for signature verification. src/respond.php loads Composer's autoloader and the service.
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(__DIR__."/../requests/$kind.json"), true, 512, JSON_THROW_ON_ERROR);
if ($kind === 'jobs') $body['inputs'] = ['talk' => $input];
else $body['input'] = $input;
if ($url = getenv('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:
respond(function (Videxo $videxo): void {
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
jsonResponse($videxo->getJob(is_string($_GET['id'] ?? null) ? $_GET['id'] : ''));
return;
}
$body = requestBody();
$kind = ($body['kind'] ?? null) === 'jobs' ? 'jobs' : 'transcode';
$input = is_string($body['input_url'] ?? null) ? $body['input_url'] : '';
jsonResponse($videxo->createJob($kind, $input), 202);
});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 = getenv('VIDEXO_API_KEY') ?: throw new RuntimeException('Set VIDEXO_API_KEY on the server.');
$headers = ['Authorization: Bearer '.$apiKey, 'Accept: application/json'];
if ($body !== null) $headers[] = 'Content-Type: application/json';
if ($key !== null) $headers[] = 'Idempotency-Key: '.$key;
$location = null;
$curl = curl_init(rtrim(getenv('VIDEXO_API_URL') ?: 'https://api.videxo.io', '/').$path);
curl_setopt_array($curl, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false, // The output endpoint returns a Location, not the file.
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 30,
CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$location): int {
if (stripos($line, 'Location:') === 0) $location = trim(substr($line, 9));
return strlen($line);
},
]);
if ($body !== null) curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($body, JSON_THROW_ON_ERROR));
$raw = curl_exec($curl);
if ($raw === false) throw new RuntimeException('Could not reach the API.');
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$problem = json_decode($raw, true);
if ($status >= 400) {
throw new VidexoProblem(is_array($problem) && isset($problem['code']) ? $problem : [
'type' => 'about:blank', 'title' => 'The API answered with an error.',
'status' => $status, 'detail' => "HTTP $status", 'code' => 'http_error',
]);
}
if ($status >= 300) return $location ?? throw new RuntimeException('The API returned no download link.');
return json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
}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:
function respond(callable $route): void
{
try {
$route(new Videxo());
} catch (VidexoProblem $error) {
jsonResponse($error->problem, $error->problem['status'], 'application/problem+json');
} catch (Throwable $error) {
// Keep diagnostics free of request bodies, signed URLs and arbitrary exception messages.
error_log('Videxo endpoint error: '.$error::class);
jsonResponse(['title' => 'Something went wrong.', 'status' => 500], 500, '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 = getenv('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.
respond(function (Videxo $videxo): void {
$max = 2_097_152; // 2 MiB of raw bytes, before signature verification or JSON parsing.
if (isset($_SERVER['CONTENT_LENGTH']) && (float) $_SERVER['CONTENT_LENGTH'] > $max) {
http_response_code(413);
return;
}
$stream = fopen('php://input', 'rb');
if ($stream === false) 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) {
http_response_code(413);
return;
}
// Verify these exact bytes, never parsed and serialized again.
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
try {
$event = $videxo->verifyWebhook($raw, $headers);
} catch (\StandardWebhooks\Exception\WebhookVerificationException) {
http_response_code(400);
return;
}
// A file for this single-server demo. In your app use a unique webhook-id column in the
// same transaction as your business update, so retries and concurrent deliveries are skipped.
$directory = __DIR__.'/../../storage';
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
throw new RuntimeException('Could not create webhook storage.');
}
$file = fopen($directory.'/webhooks.json', 'c+');
if (!$file) throw new RuntimeException('Could not open webhook storage.');
try {
if (!flock($file, LOCK_EX)) throw new RuntimeException('Could not lock webhook storage.');
$saved = stream_get_contents($file);
$handled = $saved === '' ? [] : json_decode($saved, true, 512, JSON_THROW_ON_ERROR);
$id = $headers['webhook-id'];
if (!isset($handled[$id])) {
// Your code: mark the order done or enqueue slow work. Log only ids, never signed URLs.
error_log($event['type'].' '.$event['data']['id']);
$handled[$id] = true;
rewind($file);
$json = json_encode($handled, JSON_THROW_ON_ERROR);
if (fwrite($file, $json) !== strlen($json) || !ftruncate($file, strlen($json)) || !fflush($file)) {
throw new RuntimeException('Could not save webhook storage.');
}
}
} finally {
fclose($file); // Releases the lock too.
}
http_response_code(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 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.
respond(function (Videxo $videxo): void {
$body = requestBody();
$upload = $videxo->createUpload([
'filename' => $body['filename'] ?? null,
'size_bytes' => $body['size_bytes'] ?? null,
'content_type' => $body['content_type'] ?? null,
]);
jsonResponse(['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.
respond(function (Videxo $videxo): void {
$id = is_string($_GET['id'] ?? null) ? $_GET['id'] : '';
$alias = is_string($_GET['alias'] ?? null) ? $_GET['alias'] : '';
header('Location: '.$videxo->outputUrl($id, $alias), true, 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#
Copy .env.example to .env, fill in the secrets, and export its shell assignments with
set -a; . ./.env; set +a. Set PORT to a free local port, then run
php -S "127.0.0.1:$PORT" -t public public/router.php. Open http://127.0.0.1:$PORT.
The router maps the page's extensionless paths to the endpoint files. The README also gives
production nginx rewrites; set its document root to public/ only and keep storage/ writable
outside it.
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.
src/Videxo.phpis the one file to change when replacing the HTTP client.