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:
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 ?? '',
},
});Copy failed. The text is selected; copy it with your keyboard.
Install#
npm install @videxo/sdkCopy failed. The text is selected; copy it with your keyboard.
@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:
/** 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 }));
}Copy failed. The text is selected; copy it with your keyboard.
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:
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,
});
}
}Copy failed. The text is selected; copy it with your keyboard.
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:
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;Copy failed. The text is selected; copy it with your keyboard.
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:
/**
* 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);
}Copy failed. The text is selected; copy it with your keyboard.
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 });
};Copy failed. The text is selected; copy it with your keyboard.
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:
/** 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));
}Copy failed. The text is selected; copy it with your keyboard.
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);
}
};Copy failed. The text is selected; copy it with your keyboard.
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:
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;
};
};Copy failed. The text is selected; copy it with your keyboard.
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:
/** 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));
}Copy failed. The text is selected; copy it with your keyboard.
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 });
};Copy failed. The text is selected; copy it with your keyboard.
A job's outputs[].download_url is also a signed link, valid until download_url_expires_at.
Run it#
cp .env.example .env # fill in VIDEXO_API_KEY and VIDEXO_WEBHOOK_SECRET
npm install
npm run dev # http://localhost:5173Copy failed. The text is selected; copy it with your keyboard.
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#
- Operations and Presets: what else a JSON job can make.
- Webhooks: events, retries and the deliveries log.
- TypeScript SDK: every call of
@videxo/sdk, its errors and its retries.