Skip to content

DocsIntegrations

Node with Express

An Express app that creates jobs from JSON, takes uploads straight from the browser, verifies webhooks and hands out downloads.

What you build#

An Express app that turns an uploaded file into an MP4, or into an MP4, a poster and a GIF preview in one job. The browser sends the file straight to Videxo with a one-time URL, so it never passes through your server. Your server keeps the API key, creates the job, verifies the webhook and sends each download to a fresh signed link. Every call to Videxo sits in one file, src/videxo.ts, with the TypeScript SDK.

The whole project: videxo-express.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 webhook signing secret from the dashboard's Webhooks page, in VIDEXO_WEBHOOK_SECRET.
  • Node 22.18 or newer: it runs the TypeScript files as they are.

Both values stay on the server, in environment variables. Never put them in code that runs in a browser.

Install#

bash
npm install express @videxo/sdk

@videxo/sdk sends the requests, retries a request when a retry is safe and verifies the webhook's signature. The example's package.json also lists the TypeScript types.

Create a job#

The JSON bodies sit in requests/, next to the code: one MP4 through POST /v1/transcode, and an MP4, a poster and a GIF preview in one job through POST /v1/jobs. The functions put the uploaded file in as the input and send an Idempotency-Key:

examples/express/src/videxo.ts · SDK
/** One MP4 from one input: `POST /v1/transcode` with the body in requests/transcode.json. */
export function transcode(input: string, idempotencyKey: string): Promise<Job> {
  const body = { ...transcodeBody, input, ...webhook() } as TranscodeRequest;
  return call((videxo) => videxo.transcode(body, { idempotencyKey }));
}

/** Several outputs from one input in one job: `POST /v1/jobs` with the body in requests/jobs.json. */
export function createJob(input: string, idempotencyKey: string): Promise<Job> {
  const body = { ...jobsBody, inputs: { talk: input }, ...webhook() } as JobRequest;
  return call((videxo) => videxo.jobs.create(body, { idempotencyKey }));
}

Every function goes through one call helper around the SDK's client. An error answer is problem details: the SDK throws a VidexoError, and call passes the problem details on as a VidexoProblem with the code, the detail and the field errors:

examples/express/src/videxo.ts · SDK
let client: Videxo | undefined;

/** The SDK's client, made on the first call: it retries a 429, a 5xx it may and a network error. */
function videxo(): Videxo {
  const apiKey = process.env.VIDEXO_API_KEY;
  if (!apiKey) throw new Error('Set VIDEXO_API_KEY in the server environment.');
  client ??= new Videxo({ apiKey, ...(process.env.VIDEXO_API_URL ? { baseUrl: process.env.VIDEXO_API_URL } : {}) });
  return client;
}

/** The call's answer; a `VidexoError` becomes a `VidexoProblem` with the API's problem details as they came. */
async function call<T>(send: (videxo: Videxo) => Promise<T>): Promise<T> {
  try {
    return await send(videxo());
  } catch (error) {
    if (!(error instanceof VidexoError)) throw error;
    const body = error.body as Problem | undefined;
    if (typeof body === 'object' && body !== null && typeof body.code === 'string') throw new VidexoProblem(body);
    // No problem details: the network, a timeout or a proxy answered, not the API.
    throw new VidexoProblem({
      type: 'about:blank',
      title: error.title,
      status: error.status ?? 502,
      detail: error.detail,
      code: error.code,
    });
  }
}

The route takes the upload's input_url from the page and derives the idempotency key from the upload, so a retried request returns the same job:

examples/express/src/app.ts · plain HTTP
const UPLOAD = /^videxo:\/\/uploads\/(up_[a-f0-9]{24})$/;

app.post('/api/jobs', async (req, res) => {
  const { kind, input_url } = req.body ?? {};
  // Accept only uploads, so nobody can make this server fetch URLs of their choice.
  const upload = typeof input_url === 'string' ? UPLOAD.exec(input_url)?.[1] : undefined;
  if (!upload) throw badRequest('input_url must be the input_url of an upload.');
  // The same key for the same upload and kind: a retried request answers the same job, never a second one.
  const job = kind === 'jobs' ? await createJob(input_url, `jobs-${upload}`) : await transcode(input_url, `transcode-${upload}`);
  res.status(202).json(job);
});

The error handler passes Videxo's problem details to the page as they came, so the page can show what was wrong:

examples/express/src/app.ts · plain HTTP
// Express 5 sends a rejected handler here. Videxo's problem details go to the page as they came, so
// it can show `detail` and the field errors; a body the parsers refuse keeps their 4xx; anything else is a plain 500.
const onError: ErrorRequestHandler = (error, _req, res, next) => {
  if (res.headersSent) return next(error);
  if (error instanceof VidexoProblem) {
    res.status(error.problem.status).type('application/problem+json').send(JSON.stringify(error.problem));
    return;
  }
  // express.raw() and express.json() refuse a body past their limit with 413, a broken one with 400.
  if (typeof error?.status === 'number' && error.status >= 400 && error.status < 500) {
    res.status(error.status).end();
    return;
  }
  console.error(error);
  res
    .status(500)
    .type('application/problem+json')
    .send(JSON.stringify({ title: 'Something went wrong.', status: 500 }));
};
app.use(onError);

Receive the webhook#

When the job ends, Videxo sends a signed POST (Webhooks). The check needs the raw body exactly as it arrived, so this route reads it with express.raw() and is registered before express.json(). The body is the whole job, which can pass express.raw()'s default limit of 100 KB, so the route allows 2 MB; a longer one is rejected with 413:

examples/express/src/videxo.ts · SDK
/**
 * The event when the request is a webhook Videxo signed, in the last five minutes; it throws
 * otherwise. `body` is the raw body exactly as received: parsed and serialized again, it no
 * longer matches the signature.
 */
export function verifyWebhook(body: string, headers: Record<string, string | string[] | undefined>): Promise<WebhookEvent> {
  const secret = process.env.VIDEXO_WEBHOOK_SECRET;
  if (!secret) throw new Error('Set VIDEXO_WEBHOOK_SECRET in the server environment.');
  return verifySignature(body, headers, secret);
}
examples/express/src/app.ts · plain HTTP
// The webhook route reads the raw body: it is registered before express.json(), which would parse it.
// The body is the whole job and can pass express's default limit of 100 KB, so it allows 2 MB.
app.post('/webhooks/videxo', express.raw({ type: '*/*', limit: '2mb' }), async (req, res) => {
  let event: WebhookEvent;
  try {
    event = await verifyWebhook(Buffer.isBuffer(req.body) ? req.body.toString('utf8') : '', req.headers);
  } catch {
    res.status(400).end();
    return;
  }
  const id = req.header('webhook-id')!;
  if (!handled.has(id)) {
    handled.add(id);
    onJobEnded(event);
  }
  // Answer 2xx within seconds; do slow work in a queue of your own.
  res.status(204).end();
});

verifyWebhook rejects a wrong signature and a timestamp more than five minutes away. A delivery can arrive more than once with the same webhook-id; keep the ids you have handled in a unique column and skip one you have seen.

Upload a file#

The server creates the upload with the API key and gives the browser only its one-time URL:

examples/express/src/videxo.ts · SDK
/** A one-time upload URL for a file the browser sends straight to Videxo; the API key stays here. */
export function createUpload(file: { filename: string; size_bytes: number; content_type: string }): Promise<Upload> {
  return call((videxo) => videxo.uploads.create(file));
}
examples/express/src/app.ts · plain HTTP
app.post('/api/uploads', async (req, res) => {
  const { filename, size_bytes, content_type } = req.body ?? {};
  const upload = await createUpload({ filename, size_bytes, content_type });
  // Only the one-time URL goes to the browser; it holds no API key.
  res.status(201).json({ input_url: upload.input_url, upload_url: upload.upload_url });
});

The page then sends the file with PUT to upload_url (no API key; the URL takes one PUT) and asks the server for the job with the upload's input_url, videxo://uploads/<id>. See Files for the size limit and how long an upload lasts.

Download the outputs#

Signed links expire within an hour, so the download route asks for a fresh one each time: outputs.url() reads it from the redirect of GET /v1/jobs/{id}/outputs/{alias}, and the route sends the browser on.

examples/express/src/videxo.ts · SDK
/** A fresh signed link to one output, from the redirect of `GET /v1/jobs/{id}/outputs/{alias}`. */
export function outputUrl(jobId: string, alias: string): Promise<string> {
  return call((videxo) => videxo.outputs.url(jobId, alias));
}
examples/express/src/app.ts · plain HTTP
app.get('/api/jobs/:id/outputs/:alias', async (req, res) => {
  // A fresh link each time: signed links expire within an hour.
  res.redirect(302, await outputUrl(req.params.id, req.params.alias));
});

A job's outputs[].download_url is also a signed link, valid until download_url_expires_at.

Run it#

bash
cp .env.example .env    # fill in VIDEXO_API_KEY and VIDEXO_WEBHOOK_SECRET
npm install
npm run dev             # http://localhost:3000

Open the page, pick a file and choose the job. The page follows the job by reading it every 2 seconds, then lists the outputs as download links.

Webhooks need a public https URL. In development run a tunnel (for example cloudflared tunnel --url http://localhost:3000) and set VIDEXO_WEBHOOK_URL to its address plus /webhooks/videxo; each job then carries it as its webhook_url. Or set the address once as your account's endpoint on the Webhooks page.

Next steps#