PHP Tutorial: Build an Async Media Pipeline with Signed Webhooks
Use PHP, cURL, PDO, and PostgreSQL to upload media, create a MediaRuntime job, verify the signed terminal webhook from php://input, deduplicate retries, and record output delivery.
A PHP request should not remain open while a media engine transcodes video, packages HLS, extracts audio, produces transcripts, or creates image derivatives. The reliable integration pattern is asynchronous: submit the job, persist its ID, return a processing state, and let a signed webhook report the terminal result. This tutorial builds the full server-side flow using PHP 8.2, cURL, PDO, and PostgreSQL. It verifies the signature against php://input before decoding JSON and makes duplicate delivery harmless with a unique event ID.
What the PHP application will do
The backend requests a short-lived upload URL, uploads the bytes using every returned header, and submits the returned file_uri to /v1/transcode. It stores job_id and finishes the application request. MediaRuntime later calls a public HTTPS PHP endpoint with a signed COMPLETED, FAILED, or REJECTED event. The handler verifies the signature, records event_id once, updates the local job, and acknowledges quickly.
PHP backend
│
├── POST /v1/upload-url ─────────► MediaRuntime
│◄─ upload_url, upload_headers, file_uri
├── PUT file bytes ──────────────► upload target
├── POST /v1/transcode ─────────► MediaRuntime
│◄─ job_id, queued
├── persist job_id
│
◄── signed terminal webhook ───── MediaRuntime
├── verify php://input
├── insert event_id once
└── update the application jobBefore you begin
Create a MediaRuntime API key and configure your public HTTPS webhook URL from the account page. The webhook secret is view-once, so store it immediately in your server-side secret manager. The example expects MEDIARUNTIME_API_KEY, MEDIARUNTIME_WEBHOOK_SECRET, and DATABASE_DSN in PDO PostgreSQL form, such as pgsql:host=127.0.0.1;port=5432;dbname=app. MEDIARUNTIME_API_URL defaults to https://mediaruntime.com. Enable the PHP cURL and PDO PostgreSQL extensions.
php --version # PHP 8.2 or newer
php -m | grep -E 'curl|pdo_pgsql'Step 1: upload a source and create the job
Use X-API-Key only when calling the MediaRuntime API. The upload target has its own short-lived authorization, represented by upload_url and upload_headers. Send every returned upload header with the PUT and do not add the API key. The file_uri returned by the first call is opaque. Pass it to /v1/transcode exactly as received rather than constructing a storage address.
<?php
declare(strict_types=1);
$baseUrl = rtrim(getenv('MEDIARUNTIME_API_URL') ?: 'https://mediaruntime.com', '/');
$apiKey = getenv('MEDIARUNTIME_API_KEY');
if (!$apiKey) throw new RuntimeException('MEDIARUNTIME_API_KEY is required');
function apiPost(string $path, array $payload): array
{
global $baseUrl, $apiKey;
$curl = curl_init($baseUrl . $path);
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 30,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . $apiKey,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
]);
$body = curl_exec($curl);
if ($body === false) {
throw new RuntimeException(curl_error($curl));
}
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
if ($status < 200 || $status >= 300) {
throw new RuntimeException("MediaRuntime HTTP $status: $body");
}
return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
}
$sourcePath = __DIR__ . '/launch-trailer.mp4';
$target = apiPost('/v1/upload-url', [
'filename' => basename($sourcePath),
'content_type' => 'video/mp4',
]);
$uploadHeaders = [];
foreach ($target['upload_headers'] as $name => $value) {
$uploadHeaders[] = $name . ': ' . $value;
}
$media = fopen($sourcePath, 'rb');
if ($media === false) throw new RuntimeException('Unable to open source file');
$upload = curl_init($target['upload_url']);
curl_setopt_array($upload, [
CURLOPT_UPLOAD => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 30,
CURLOPT_TIMEOUT => 600,
CURLOPT_HTTPHEADER => $uploadHeaders,
CURLOPT_INFILE => $media,
CURLOPT_INFILESIZE => filesize($sourcePath),
]);
$uploadBody = curl_exec($upload);
$uploadError = $uploadBody === false ? curl_error($upload) : null;
$uploadStatus = curl_getinfo($upload, CURLINFO_RESPONSE_CODE);
curl_close($upload);
fclose($media);
if ($uploadError !== null) throw new RuntimeException($uploadError);
if ($uploadStatus < 200 || $uploadStatus >= 300) {
throw new RuntimeException("Upload failed with HTTP $uploadStatus");
}
$job = apiPost('/v1/transcode', [
'file_url' => $target['file_uri'],
'metadata' => [
'producer' => 'php-tutorial-api',
'entity_id' => 'asset_0426',
'media_type' => 'video',
],
'outputs' => [[
'type' => 'mp4',
'preset' => 'mp4_720p_h264_aac',
'path_suffix' => 'playback',
'poster_time_sec' => 2,
]],
]);
printf("%s %s\n", $job['job_id'], $job['status']); // job_... queuedPersist job_id before returning
Save the returned MediaRuntime job_id beside your own asset record and return a queued or processing state to the user. Do not wait for completion in the job-creation request. Include stable application identifiers in metadata. The terminal webhook echoes them under meta.request_metadata, making correlation clear without putting private state in the webhook URL.
Step 2: receive and verify the PHP webhook
Read php://input exactly once and keep the resulting string unchanged until signature verification finishes. MediaRuntime signs timestamp + '.' + event ID + '.' + raw body with HMAC-SHA256. JSON decoding must happen after verification. Use hash_equals for constant-time digest comparison and reject timestamps outside a five-minute window.
X-Transcoder-Id: webhook_evt_job_1320c28b72104811b075a26a99496cf6
X-Transcoder-Timestamp: 1786435283
X-Transcoder-Signature: t=1786435283,v1=4a1f...
Content-Type: application/jsonUse PostgreSQL as the idempotency boundary
A webhook can be delivered more than once. Make event_id the primary key so the database, not an in-memory PHP variable, decides whether the event is new. A duplicate should still receive a successful response after its signature is verified.
CREATE TABLE media_jobs (
id bigserial PRIMARY KEY,
mediaruntime_job_id text NOT NULL UNIQUE,
status text NOT NULL,
bundle_url text,
error jsonb,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE mediaruntime_webhook_events (
event_id text PRIMARY KEY,
mediaruntime_job_id text NOT NULL,
status text NOT NULL,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);Verify first, decode second, record once
<?php
declare(strict_types=1);
function respond(int $status): never
{
http_response_code($status);
exit;
}
function signatureParts(string $header): array
{
$parts = [];
foreach (explode(',', $header) as $item) {
$position = strpos($item, '=');
if ($position !== false && $position > 0) {
$key = trim(substr($item, 0, $position));
$parts[$key] = trim(substr($item, $position + 1));
}
}
return $parts;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') respond(405);
$secret = getenv('MEDIARUNTIME_WEBHOOK_SECRET');
$databaseDsn = getenv('DATABASE_DSN');
if (!$secret || !$databaseDsn) respond(500);
$rawBody = file_get_contents('php://input');
if ($rawBody === false || strlen($rawBody) > 2 * 1024 * 1024) respond(400);
$eventId = $_SERVER['HTTP_X_TRANSCODER_ID'] ?? '';
$timestampHeader = $_SERVER['HTTP_X_TRANSCODER_TIMESTAMP'] ?? '';
$signature = signatureParts($_SERVER['HTTP_X_TRANSCODER_SIGNATURE'] ?? '');
$timestamp = $signature['t'] ?? '';
$received = $signature['v1'] ?? '';
if ($eventId === '' || !ctype_digit($timestamp)) respond(401);
if ($timestampHeader !== '' && $timestampHeader !== $timestamp) respond(401);
if (!preg_match('/^[a-f0-9]{64}$/i', $received)) respond(401);
if (abs(time() - (int) $timestamp) > 300) respond(401);
$expected = hash_hmac(
'sha256',
$timestamp . '.' . $eventId . '.' . $rawBody,
$secret
);
if (!hash_equals($expected, strtolower($received))) respond(401);
try {
$event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException) {
respond(400);
}
$status = strtoupper((string) ($event['status'] ?? ''));
$jobId = (string) ($event['job_id'] ?? '');
$terminal = ['COMPLETED', 'FAILED', 'REJECTED'];
if (($event['event_id'] ?? '') !== $eventId || $jobId === '') respond(400);
if (!in_array($status, $terminal, true)) respond(400);
try {
$pdo = new PDO($databaseDsn, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->beginTransaction();
$insert = $pdo->prepare(
'INSERT INTO mediaruntime_webhook_events
(event_id, mediaruntime_job_id, status, payload)
VALUES (:event_id, :job_id, :status, CAST(:payload AS jsonb))
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id'
);
$insert->execute([
':event_id' => $eventId,
':job_id' => $jobId,
':status' => $status,
':payload' => json_encode($event, JSON_THROW_ON_ERROR),
]);
$inserted = $insert->fetchColumn();
if ($inserted !== false) {
$bundleUrl = $event['delivery']['bundle']['download']['url'] ?? null;
$errorJson = isset($event['error'])
? json_encode($event['error'], JSON_THROW_ON_ERROR)
: null;
$update = $pdo->prepare(
'UPDATE media_jobs
SET status = :status, bundle_url = :bundle_url,
error = CAST(:error AS jsonb), updated_at = now()
WHERE mediaruntime_job_id = :job_id'
);
$update->execute([
':status' => $status,
':bundle_url' => $bundleUrl,
':error' => $errorJson,
':job_id' => $jobId,
]);
}
$pdo->commit();
respond(204);
} catch (Throwable $error) {
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
error_log('Unable to record MediaRuntime webhook: ' . $error->getMessage());
respond(500);
}Step 3: acknowledge after the transaction commits
Return HTTP 204 after the verified event is committed. Return 500 when PostgreSQL is unavailable so the delivery is not falsely acknowledged. ON CONFLICT makes a repeated event a successful no-op. Keep the handler short. If you need to download a bundle, copy artifacts, notify users, or call another API, create an outbox record in the same transaction and let a queue worker perform that slower work.
Use the delivery URL from the completed event
A COMPLETED event exposes the job-scoped bundle at delivery.bundle.download.url and returns your original application metadata under meta.request_metadata. Use the URL exactly as returned. Copy required files into your own long-term storage before the stated delivery retention expires.
$bundleUrl = $event['delivery']['bundle']['download']['url'] ?? null;
$requestMetadata = $event['meta']['request_metadata'] ?? [];
$assetId = $requestMetadata['entity_id'] ?? null;Handle every terminal status
COMPLETED carries delivery and final usage information. FAILED means execution could not finish and can include an operator-facing error. REJECTED means the job was prevented from completing. Persist all three so your product never leaves a terminal job displayed as processing.
PHP webhook mistakes to avoid
Do not decode and re-encode JSON before computing the HMAC. Do not authenticate the incoming request with the API key. Do not compare digests with ordinary string equality. Do not omit timestamp validation or the header-to-body event ID check. Do not log the webhook secret, signature header, complete signed bundle URL, or sensitive application metadata. Keep logs to the event ID, job ID, status, and safe trace identifiers.
Test retries and failures
Send a real test-mode job through the complete flow. Confirm that the first event updates one job and repeating it causes no second side effect. Change one request byte and expect 401. Send an old timestamp and expect 401. Stop PostgreSQL and expect 500. Exercise FAILED and REJECTED in addition to COMPLETED. Add scheduled reconciliation for jobs that remain QUEUED or PROCESSING unexpectedly. That recovery process is a safeguard for outages and configuration errors, not a replacement for the webhook.
PHP production checklist
1. Store the API key and webhook secret only on the server. 2. Send every upload_headers entry and submit the exact file_uri. 3. Persist job_id before ending job creation. 4. Verify php://input before json_decode. 5. Validate timestamp and event identity, then use hash_equals. 6. Enforce a unique event_id in PostgreSQL. 7. Return 2xx only after commit and 5xx when recording fails. 8. Move slow downstream work to an outbox or queue. 9. Handle COMPLETED, FAILED, and REJECTED. 10. Reconcile unexpectedly old non-terminal jobs.
One PHP integration, every media output
The requested recipe can create MP4, HLS, audio, transcripts, posters, GIF previews, images, moderation reports, or watermarked outputs. Your PHP application uses the same durable lifecycle every time: upload, submit, persist, verify, record, and react. Explore the complete endpoint and payload reference at https://mediaruntime.com/docs.