Examples
Node.js

Node.js

No SDK is required. These examples use the built-in fetch, so they run on Node 18 or later with no dependencies beyond Express for the webhook receiver.

A small client

// videotranscode.js
const BASE = 'https://videotranscode.cloud/api/v1'
 
class VideoTranscodeError extends Error {
  constructor(status, body) {
    super(body?.message || body?.error || `Request failed with ${status}`)
    this.name = 'VideoTranscodeError'
    this.status = status
    this.body = body
  }
}
 
async function request(path, options = {}) {
  const res = await fetch(`${BASE}${path}`, {
    ...options,
    headers: {
      Authorization: `Bearer ${process.env.VT_API_KEY}`,
      'Content-Type': 'application/json',
      ...options.headers
    }
  })
 
  const body = await res.json().catch(() => null)
  if (!res.ok) throw new VideoTranscodeError(res.status, body)
  return body
}
 
export const createJob = (inputUrl, preset, metadata) =>
  request('/jobs', {
    method: 'POST',
    body: JSON.stringify({ inputUrl, preset, ...(metadata && { metadata }) })
  })
 
export const getJob = id => request(`/jobs/${id}`)
 
export const listJobs = (params = {}) =>
  request(`/jobs?${new URLSearchParams(params)}`)
 
export const cancelJob = id => request(`/jobs/${id}`, { method: 'DELETE' })
 
export const getDownloadUrl = id => request(`/jobs/${id}/download-url`)
 
export const listPresets = () => request('/presets')
 
export { VideoTranscodeError }

Create a job and handle the refusals

import { createJob, VideoTranscodeError } from './videotranscode.js'
 
try {
  const job = await createJob(
    'https://cdn.example.com/master.mov',
    'hls_abr_264_standard'
  )
  console.log(`Queued ${job.id}, ${job.user.remainingJobs} jobs left this month`)
} catch (error) {
  if (!(error instanceof VideoTranscodeError)) throw error
 
  switch (error.status) {
    case 403:
      // The body names what you can use instead.
      console.error(`Not on your plan. Available: ${error.body.availablePresets.join(', ')}`)
      break
    case 429:
      console.error(`Quota used: ${error.body.usage.current} of ${error.body.usage.limit}`)
      break
    case 400:
      console.error(error.body.details ?? error.body.message)
      break
    default:
      throw error
  }
}

Wait for the result

Use this for scripts. In a service, prefer a webhook.

import { getJob } from './videotranscode.js'
 
const TERMINAL = new Set(['completed', 'failed', 'cancelled'])
const sleep = ms => new Promise(r => setTimeout(r, ms))
 
export async function waitForJob(jobId, { intervalMs = 12_000, timeoutMs = 45 * 60_000 } = {}) {
  const deadline = Date.now() + timeoutMs
 
  while (Date.now() < deadline) {
    const job = await getJob(jobId)
    if (TERMINAL.has(job.status)) return job
    await sleep(intervalMs)
  }
 
  throw new Error(`Job ${jobId} did not finish within ${timeoutMs} ms`)
}

End to end

import { createJob } from './videotranscode.js'
import { waitForJob } from './wait.js'
import { createWriteStream } from 'node:fs'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
 
const job = await createJob('https://cdn.example.com/master.mov', 'mp4_264_720p')
console.log(`Created ${job.id}`)
 
const finished = await waitForJob(job.id)
 
if (finished.status !== 'completed') {
  throw new Error(`Job ${finished.id} ${finished.status}: ${finished.errorMessage}`)
}
 
const res = await fetch(finished.outputUrl)
await pipeline(Readable.fromWeb(res.body), createWriteStream('output.mp4'))
 
console.log(`Saved output.mp4, link was valid until ${finished.expiration}`)

Webhook receiver

Verify the signature before trusting anything in the body. The signature is computed over the raw bytes we sent, so capture the raw body rather than the parsed object.

import express from 'express'
import crypto from 'node:crypto'
 
const app = express()
 
// Keep the raw body: re-serialising the parsed JSON can reorder keys and
// change whitespace, and the signature would then never match.
app.use('/hooks/video', express.raw({ type: 'application/json' }))
 
function signatureIsValid(rawBody, header, secret) {
  if (!header) return false
 
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')
    .digest('hex')
 
  const a = Buffer.from(expected, 'utf8')
  const b = Buffer.from(header, 'utf8')
 
  // Compare in constant time so the check cannot be probed byte by byte.
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}
 
app.post('/hooks/video', (req, res) => {
  const raw = req.body.toString('utf8')
 
  if (!signatureIsValid(raw, req.get('X-VideoTranscoder-Signature'), process.env.VT_WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' })
  }
 
  // Acknowledge before working. Delivery times out after 30 seconds.
  res.status(200).json({ received: true })
 
  const payload = JSON.parse(raw)
 
  if (payload.status === 'completed') {
    void archive(payload.jobId, payload.outputUrl)
  } else if (payload.status === 'failed') {
    void recordFailure(payload.jobId, payload.error)
  }
})
 
app.listen(3000)

Deliveries can repeat, so make archive and recordFailure idempotent on jobId. The X-VideoTranscoder-Webhook-Id header is stable across retries of the same delivery and is a convenient key for that.