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.