Skip to content

DocsIntegrations

SvelteKit

A SvelteKit app whose form action creates jobs, whose +server.ts verifies webhooks, and whose page uploads straight to Videxo.

What you build#

A SvelteKit app that turns an uploaded file into an MP4, or into an MP4, a poster and a GIF preview in one job. The page sends the file straight to Videxo with a one-time URL, so it never passes through your app. A form action creates the job, a +server.ts verifies the webhook and another sends each download to a fresh signed link; the API key stays in server code. Every call to Videxo sits in one file, src/lib/server/videxo.ts, with the TypeScript SDK.

The whole project: videxo-sveltekit.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.
  • SvelteKit 3 and Node 22.17 or newer.

src/env.ts declares both as private variables; code that runs in the browser cannot import $app/env/private, so SvelteKit stops a build that would leak them:

examples/sveltekit/src/env.ts · plain HTTP
import { defineEnvVars } from '@sveltejs/kit/env';

// The server's settings, read through `$app/env/private`, which browser code cannot import.

const required = (name: string) => (value: string | undefined) => {
  if (!value) throw new Error(`Set ${name} in the server environment.`);
  return value;
};

export const variables = defineEnvVars({
  VIDEXO_API_URL: { schema: (value) => value || 'https://api.videxo.io' },
  VIDEXO_API_KEY: { schema: required('VIDEXO_API_KEY') },
  VIDEXO_WEBHOOK_SECRET: { schema: required('VIDEXO_WEBHOOK_SECRET') },
  VIDEXO_WEBHOOK_URL: {
    description: "A public https URL of this app's /webhooks/videxo (a tunnel in development); optional.",
    schema: (value) => value ?? '',
  },
});

Install#

bash
npm install @videxo/sdk

@videxo/sdk sends the requests, retries a request when a retry is safe and verifies the webhook's signature. The example uses @sveltejs/adapter-node; any adapter works.

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/sveltekit/src/lib/server/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/sveltekit/src/lib/server/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 {
  client ??= new Videxo({ apiKey: VIDEXO_API_KEY, baseUrl: 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 form action takes the upload's input_url and derives the idempotency key from the upload, so a resubmitted form returns the same job. A VidexoProblem becomes fail() with the problem, which the page shows:

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

export const actions = {
  // The browser has already uploaded the file; the form carries the upload's input_url.
  default: async ({ request }) => {
    const form = await request.formData();
    const inputUrl = String(form.get('input_url') ?? '');
    // Accept only uploads, so nobody can make this server fetch URLs of their choice.
    const upload = UPLOAD.exec(inputUrl)?.[1];
    if (!upload) return fail(400, { problem: { title: 'The request is not valid.', detail: 'Upload a file first.' } });
    try {
      // The same key for the same upload and kind: a retried submit answers the same job, never a second one.
      const job =
        form.get('kind') === 'jobs' ? await createJob(inputUrl, `jobs-${upload}`) : await transcode(inputUrl, `transcode-${upload}`);
      return { job };
    } catch (error) {
      if (error instanceof VidexoProblem) return fail(error.problem.status, { problem: error.problem });
      throw error;
    }
  },
} satisfies Actions;

Receive the webhook#

When the job ends, Videxo sends a signed POST (Webhooks). The endpoint reads the raw body exactly as it arrived, refusing one over 2 MB with 413 (src/lib/server/limited-body.ts checks Content-Length and stops reading past the limit), and checks it:

examples/sveltekit/src/lib/server/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: Headers): Promise<WebhookEvent> {
  return verifySignature(body, headers, VIDEXO_WEBHOOK_SECRET);
}
examples/sveltekit/src/routes/webhooks/videxo/+server.ts · plain HTTP
export const POST: RequestHandler = async ({ request }) => {
  // The raw body, exactly as received: parsed and serialized again, it no longer matches the signature.
  const body = await readLimited(request, MAX_WEBHOOK_BYTES);
  if (body === null) return new Response(null, { status: 413 });
  let event: WebhookEvent;
  try {
    event = await verifyWebhook(body, request.headers);
  } catch {
    return new Response(null, { status: 400 });
  }
  const id = request.headers.get('webhook-id')!;
  if (!handled.has(id)) {
    handled.add(id);
    onJobEnded(event);
  }
  // Answer 2xx within seconds; do slow work in a queue of your own.
  return new Response(null, { status: 204 });
};

With @sveltejs/adapter-node, the built server rejects request bodies over 512 KB unless the BODY_SIZE_LIMIT environment variable allows more; set it to 2M, as the example's .env.example does, since a webhook carries the whole job.

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#

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

examples/sveltekit/src/lib/server/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/sveltekit/src/routes/api/uploads/+server.ts · plain HTTP
export const POST: RequestHandler = async ({ request }) => {
  try {
    const { filename, size_bytes, content_type } = await request.json();
    const upload = await createUpload({ filename, size_bytes, content_type });
    // Only the one-time URL goes to the browser; it holds no API key.
    return Response.json({ input_url: upload.input_url, upload_url: upload.upload_url }, { status: 201 });
  } catch (error) {
    return problemResponse(error);
  }
};

The page's use:enhance callback sends the file with PUT to upload_url (no API key; the URL takes one PUT) before the form is submitted, replaces the file with the upload's input_url, then reads the job the action returns until it ends:

examples/sveltekit/src/routes/+page.svelte · plain HTTP
const upload: SubmitFunction = async ({ formData, cancel }) => {
  problem = null;
  job = null;
  const file = formData.get('file') as File;
  const contentType = file.type || 'application/octet-stream';
  try {
    status = 'Creating the upload';
    const created = await readJson<{ input_url: string; upload_url: string }>(
      await fetch('/api/uploads', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ filename: plainName(file.name), size_bytes: file.size, content_type: contentType }),
      }),
    );
    status = 'Uploading';
    // Straight to Videxo with the one-time URL: no API key, and the file never passes through this app.
    await readJson(await fetch(created.upload_url, { method: 'PUT', headers: { 'content-type': contentType }, body: file }));
    // The action gets the upload's input_url, not the file.
    formData.delete('file');
    formData.set('input_url', created.input_url);
  } catch (error) {
    status = '';
    problem = error as Problem;
    cancel();
    return;
  }
  return async ({ result }) => {
    if (result.type === 'failure') {
      status = '';
      problem = (result.data?.problem as Problem) ?? { title: 'The job was not created.' };
      return;
    }
    if (result.type !== 'success') return;
    let current = result.data?.job as Job;
    while (current.state === 'queued' || current.state === 'running') {
      status = `Job ${current.id}: ${current.state}`;
      await new Promise((resolve) => setTimeout(resolve, 2000));
      current = await readJson<Job>(await fetch(`/api/jobs/${current.id}`));
    }
    status = `Job ${current.id}: ${current.state}`;
    job = current;
  };
};

See Files for the size limit and how long an upload lasts.

Download the outputs#

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

examples/sveltekit/src/lib/server/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/sveltekit/src/routes/api/jobs/[id]/outputs/[alias]/+server.ts · plain HTTP
export const GET: RequestHandler = async ({ params }) => {
  let url: string;
  try {
    // A fresh link each time: signed links expire within an hour.
    url = await outputUrl(params.id!, params.alias!);
  } catch (error) {
    return problemResponse(error);
  }
  redirect(302, url, { external: true });
};

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:5173

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:5173) 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#