Skip to content

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:

examples/php/src/Videxo.php · plain HTTP
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]}");
}

The route returns the created Job with 202, so the page can follow it:

examples/php/public/api/jobs.php · plain HTTP
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);
});

Every API call uses one helper. API errors become an exception carrying the original problem details:

examples/php/src/Videxo.php · plain HTTP
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);
}

The error handler keeps the status and application/problem+json, including field errors, so the shared page can show them:

examples/php/src/respond.php · plain HTTP
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');
    }
}

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:

examples/php/src/Videxo.php · plain HTTP
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);
}
examples/php/public/webhooks/videxo.php · plain HTTP
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);
});

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:

examples/php/src/Videxo.php · plain HTTP
public function createUpload(array $file): array
{
    return $this->request('/v1/uploads', 'POST', $file);
}
examples/php/public/api/uploads.php · plain HTTP
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);
});

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:

examples/php/src/Videxo.php · plain HTTP
public function outputUrl(string $id, string $alias): string
{
    return $this->request('/v1/jobs/'.rawurlencode($id).'/outputs/'.rawurlencode($alias));
}
examples/php/public/api/download.php · plain HTTP
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);
});

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.php is the one file to change when replacing the HTTP client.