Back to blog
TutorialAsync JobsWebhooksNode.jsMedia APIBackend Development

Node.js Tutorial: Build an Async Media Pipeline with Signed Webhooks

A production-oriented Node.js tutorial for uploading media, creating an asynchronous MediaRuntime job, verifying its signed terminal webhook, handling retries safely, and consuming the completed output bundle.

MediaRuntime Team·August 12, 2026

Media processing is asynchronous by nature. An upload may finish in seconds, while transcoding, adaptive packaging, transcription, moderation, or image generation can continue after the original application request has ended. A production integration therefore needs more than a POST request: it needs a durable way to remember the job and react when MediaRuntime reports the terminal result. In this tutorial, we will build that complete server-side path with Node.js, Express, and PostgreSQL. The application will upload a source file, create a MediaRuntime job, receive its signed webhook, tolerate duplicate delivery, and record the output bundle without exposing an API key or webhook secret to the browser.

What we are building

The example uses a server-side API key for the data-plane requests and a separate view-once webhook secret for incoming event verification. These credentials have different jobs and should be stored independently. The browser can ask your application to begin an upload, but only your backend should call MediaRuntime with the API key. The webhook is also a backend endpoint. MediaRuntime calls it when the job becomes COMPLETED, FAILED, or REJECTED.

Your application backend
        │
        ├── POST /v1/upload-url ───────────────► MediaRuntime
        │◄─ upload_url + file_uri
        │
        ├── PUT source bytes ──────────────────► short-lived upload target
        │
        ├── POST /v1/transcode with file_uri ─► MediaRuntime
        │◄─ job_id + QUEUED
        │
        ├── persist job_id and return to user
        │
        ◄── signed terminal webhook ─────────── MediaRuntime
        │
        ├── verify raw body + record event once
        └── process bundle or failure asynchronously

Before you begin

Create an API key and configure a webhook URL from your MediaRuntime account. The webhook secret is shown once when the destination is created, so copy it directly into your server-side secret manager. Production webhook destinations must be public HTTPS endpoints. Set these environment variables for the example: MEDIARUNTIME_API_URL=https://mediaruntime.com MEDIARUNTIME_API_KEY=your_server_side_api_key MEDIARUNTIME_WEBHOOK_SECRET=your_view_once_webhook_secret DATABASE_URL=your_postgres_connection_string Never put either secret in frontend JavaScript, a mobile application, a public repository, a job payload, or a support message.

Install the tutorial dependencies

npm install express pg

# Node.js 20 or newer provides the built-in fetch API used by this tutorial.

Step 1: upload the source and create a job

First request a short-lived upload target. Upload the file bytes with every header returned in upload_headers, then pass the returned file_uri to the transcode endpoint exactly as received. The file_uri is an opaque input reference; do not construct a bucket name or storage path yourself. The helper below runs on your server. It intentionally sends the API key only to mediaruntime.com, not to the returned upload URL.

import { readFile } from "node:fs/promises";

const MEDIA_RUNTIME_URL = (
  process.env.MEDIARUNTIME_API_URL || "https://mediaruntime.com"
).replace(/\/$/, "");
const API_KEY = process.env.MEDIARUNTIME_API_KEY;

if (!API_KEY) throw new Error("MEDIARUNTIME_API_KEY is required");

async function mediaRuntimePost(path, payload) {
  const response = await fetch(MEDIA_RUNTIME_URL + path, {
    method: "POST",
    headers: {
      "X-API-Key": API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(payload),
  });

  const body = await response.json().catch(() => ({}));
  if (!response.ok) {
    throw new Error(
      `MediaRuntime ${response.status}: ${JSON.stringify(body.detail ?? body)}`
    );
  }
  return body;
}

export async function submitMediaJob(filePath) {
  const target = await mediaRuntimePost("/v1/upload-url", {
    filename: "launch-trailer.mp4",
    content_type: "video/mp4",
  });

  const upload = await fetch(target.upload_url, {
    method: "PUT",
    headers: target.upload_headers,
    body: await readFile(filePath),
  });
  if (!upload.ok) {
    throw new Error(`Media upload failed with HTTP ${upload.status}`);
  }

  return await mediaRuntimePost("/v1/transcode", {
    file_url: target.file_uri,
    metadata: {
      producer: "tutorial-api",
      entity_id: "asset_0426",
      media_type: "video",
    },
    outputs: [
      {
        type: "mp4",
        preset: "mp4_720p_h264_aac",
        path_suffix: "playback",
        poster_time_sec: 2,
      },
    ],
  });
}

const job = await submitMediaJob("./launch-trailer.mp4");
console.log(job.job_id, job.status); // job_... queued

Persist the job before waiting for the result

Save the returned job_id beside your own asset, upload, or workflow record. Do not keep the original HTTP request open while the media executes. Return a processing state to the browser and let the webhook move your record to its terminal state later. Your application identifier belongs in metadata. MediaRuntime returns that object under meta.request_metadata in the terminal webhook, which makes it easier to correlate the event without encoding application state into a URL. The MediaRuntime job_id should still be the authoritative external job reference.

Step 2: receive the terminal webhook

MediaRuntime signs the exact JSON bytes it sends. The signature cannot be verified against an object that Express has already parsed and serialized again, because whitespace and key order may change. Register the webhook route with express.raw before any global express.json middleware. The signature input is timestamp + '.' + event ID + '.' + raw request body. It is signed with HMAC-SHA256 using the webhook secret.

X-Transcoder-Id: webhook_evt_job_1320c28b72104811b075a26a99496cf6
X-Transcoder-Timestamp: 1786435283
X-Transcoder-Signature: t=1786435283,v1=4a1f...
Content-Type: application/json

Give every event a durable idempotency key

A webhook sender may deliver the same event more than once. That is expected behavior, not an exceptional condition. Make event_id unique in your database and treat an already-recorded event as success. The following minimal tables store the MediaRuntime job and the verified event. In a larger system, add an outbox row in the same transaction and let a background worker perform slower work such as copying outputs, sending email, or updating search indexes.

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, parse second, record once

This receiver rejects missing or stale signatures, compares the HMAC in constant time, verifies that the signed header event ID matches the JSON event ID, accepts only documented terminal states, and records the state transition in a database transaction. A duplicate insert returns HTTP 204 without repeating the update.

import crypto from "node:crypto";
import express from "express";
import pg from "pg";

const { Pool } = pg;
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const webhookSecret = process.env.MEDIARUNTIME_WEBHOOK_SECRET;
const terminalStatuses = new Set(["COMPLETED", "FAILED", "REJECTED"]);

if (!webhookSecret) {
  throw new Error("MEDIARUNTIME_WEBHOOK_SECRET is required");
}

function signatureParts(header) {
  const parts = new Map();
  for (const item of String(header || "").split(",")) {
    const separator = item.indexOf("=");
    if (separator > 0) {
      parts.set(item.slice(0, separator).trim(), item.slice(separator + 1).trim());
    }
  }
  return parts;
}

function hasValidSignature(req) {
  const eventId = req.get("X-Transcoder-Id") || "";
  const timestampHeader = req.get("X-Transcoder-Timestamp") || "";
  const parts = signatureParts(req.get("X-Transcoder-Signature"));
  const timestamp = parts.get("t") || "";
  const receivedHex = parts.get("v1") || "";

  if (!eventId || !/^\d+$/.test(timestamp)) return false;
  if (timestampHeader && timestampHeader !== timestamp) return false;
  if (!/^[a-f0-9]{64}$/i.test(receivedHex)) return false;

  const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (ageSeconds > 300) return false;

  const expected = crypto
    .createHmac("sha256", webhookSecret)
    .update(Buffer.from(`${timestamp}.${eventId}.`, "utf8"))
    .update(req.body) // the original Buffer
    .digest();
  const received = Buffer.from(receivedHex, "hex");

  return received.length === expected.length &&
    crypto.timingSafeEqual(received, expected);
}

const app = express();

// This route must appear before app.use(express.json()).
app.post(
  "/webhooks/mediaruntime",
  express.raw({ type: "application/json", limit: "2mb" }),
  async (req, res) => {
    if (!hasValidSignature(req)) return res.sendStatus(401);

    let event;
    try {
      event = JSON.parse(req.body.toString("utf8"));
    } catch {
      return res.sendStatus(400);
    }

    const headerEventId = req.get("X-Transcoder-Id");
    const status = String(event.status || "").toUpperCase();
    if (event.event_id !== headerEventId || !event.job_id) {
      return res.sendStatus(400);
    }
    if (!terminalStatuses.has(status)) return res.sendStatus(400);

    const client = await pool.connect();
    try {
      await client.query("BEGIN");
      const inserted = await client.query(
        `INSERT INTO mediaruntime_webhook_events
           (event_id, mediaruntime_job_id, status, payload)
         VALUES ($1, $2, $3, $4::jsonb)
         ON CONFLICT (event_id) DO NOTHING
         RETURNING event_id`,
        [event.event_id, event.job_id, status, JSON.stringify(event)]
      );

      if (inserted.rowCount === 1) {
        const bundleUrl =
          event.delivery?.bundle?.download?.url || null;
        await client.query(
          `UPDATE media_jobs
              SET status = $2, bundle_url = $3, error = $4::jsonb,
                  updated_at = now()
            WHERE mediaruntime_job_id = $1`,
          [
            event.job_id,
            status,
            bundleUrl,
            event.error ? JSON.stringify(event.error) : null,
          ]
        );
      }

      await client.query("COMMIT");
      return res.sendStatus(204);
    } catch (error) {
      await client.query("ROLLBACK");
      console.error("Unable to record MediaRuntime webhook", error);
      return res.sendStatus(500);
    } finally {
      client.release();
    }
  }
);

// Other JSON routes can safely use the parsed body after the raw webhook route.
app.use(express.json());
app.listen(process.env.PORT || 3000);

Step 3: acknowledge quickly and move slow work out of the handler

Return a 2xx response after the verified event is durably recorded. MediaRuntime interprets a non-2xx response or connection failure as an unsuccessful delivery and may send the event again. Because the event ID is unique, the repeated request remains harmless. Do not transcode another file, download a large bundle, call several downstream services, or send user notifications before acknowledging the webhook. Record an outbox task in the same database transaction, return 204, and let a background process perform those actions. If the database is unavailable, return 500 so the delivery is not falsely acknowledged.

Understand the event your handler receives

A completed event identifies the account and job, returns final billing and usage information, and includes delivery metadata. The request metadata is the same application context sent with the original job. The bundle URL is short-lived and job-scoped; use the returned address instead of constructing a storage URL.

{
  "event_id": "webhook_evt_job_1320c28b72104811b075a26a99496cf6",
  "job_id": "job_1320c28b72104811b075a26a99496cf6",
  "account_id": "acc_xxx",
  "status": "COMPLETED",
  "completedAt": "2026-08-11T08:41:23Z",
  "billing": { "status": "PAID" },
  "usage": { "units_total": 31, "breakdown": {} },
  "delivery": {
    "mode": "PULL",
    "retentionDays": 7,
    "expiresAt": "2026-08-18T08:41:23Z",
    "bundle": {
      "type": "zip",
      "filename": "outputs.zip",
      "download": {
        "url": "https://mediaruntime.com/v1/jobs/job_.../bundle?token=...",
        "expiresAt": "2026-08-18T08:41:23Z"
      }
    }
  },
  "meta": {
    "engine_result_url": "https://...",
    "request_metadata": {
      "producer": "tutorial-api",
      "entity_id": "asset_0426",
      "media_type": "video"
    }
  }
}

Handle all three terminal states

COMPLETED means the requested processing finished. Read delivery.bundle.download.url and any job result metadata from the event, then copy outputs into your own storage if your product needs retention beyond the delivery window. FAILED means execution began but could not complete. Persist the returned error for operators and present a safe product message to the user. Decide whether your application should offer a retry after inspecting the failure. REJECTED means the request could not proceed, for example because a pre-execution rule prevented it. Treat it as terminal and surface an actionable state rather than waiting indefinitely.

Webhook security mistakes to avoid

Do not authenticate the webhook with your MediaRuntime API key. Incoming events use the separate webhook secret and X-Transcoder-Signature. Do not call JSON.stringify on an already parsed body and expect the HMAC to match. Verify the original bytes. Do not compare signature strings with ordinary equality. Validate their encoding and use a constant-time comparison. Do not omit the timestamp tolerance. Without it, a captured valid request could be replayed much later. The tutorial allows five minutes. Do not trust an event merely because it contains a plausible job ID. Verify the signature first, then confirm the job belongs to the expected application record or account boundary. Do not log secrets, signature headers, signed bundle tokens, or complete payloads containing application metadata. Log the event ID, job ID, status, and internal trace ID needed for support.

Test the receiver before depending on it

Exercise the receiver with a real test-mode MediaRuntime job and a test webhook destination. Confirm that the first delivery changes the job state, replaying the same event does not duplicate work, a modified body returns 401, an old timestamp returns 401, and a temporary database failure returns 500. Also test both failure paths. A webhook implementation that has only seen COMPLETED is not production-ready. In local development, expose the receiver only through a trusted development tunnel or the supported emulator path, and rotate the webhook secret if it appears in terminal history or logs.

Use polling as recovery, not the primary completion path

Webhooks should drive normal state transitions, but production systems still need reconciliation. A scheduled process can find application jobs that have remained QUEUED or PROCESSING beyond an expected window, query their current state, and repair a missed local transition. That recovery loop protects against configuration mistakes, expired destinations, application outages, and database incidents. It should complement the webhook rather than replace it with aggressive polling.

Production checklist

1. Keep the API key and webhook secret server-side and store them separately. 2. Use every upload header returned by /v1/upload-url. 3. Submit the exact returned file_uri and persist the returned job_id. 4. Register the raw-body webhook route before JSON middleware. 5. Verify the timestamp, event ID, raw body, and HMAC before parsing or acting. 6. Make event_id unique in durable storage. 7. Record the event before returning 2xx; return non-2xx when recording fails. 8. Move slow work to an outbox or queue. 9. Handle COMPLETED, FAILED, and REJECTED. 10. Use returned delivery URLs and copy required artifacts before their retention window ends. 11. Reconcile jobs that remain non-terminal unexpectedly. 12. Test duplicate, invalid-signature, stale-signature, failure, and outage scenarios.

The webhook is the completion boundary

A reliable asynchronous integration has two clear boundaries. The job-creation response confirms that MediaRuntime accepted the work. The signed terminal webhook confirms how that work ended. Everything between those boundaries belongs to the media runtime, not an open request in your application. Once this pattern is in place, the same receiver can handle jobs that produce MP4, HLS, audio, transcripts, posters, GIF previews, image derivatives, moderation reports, and watermarked outputs. The output recipe changes; the application lifecycle does not. Explore the complete endpoint and payload reference at https://mediaruntime.com/docs.