Lesson 3 of 10

Create a Job and Poll for Results

The core lifecycle: submit a transcode job, poll until it completes, and fetch a signed download URL for the output.

Follow along with a live API key. 100 free minutes, no credit card.

Get free API key

Processing is asynchronous

Transcoding takes time, so FFmpeg Micro doesn't make you wait on a single request. You submit a job and get back an id immediately, then you poll that job until it finishes. Once it's completed, you ask for a signed download URL and pull the file. Three steps: create, poll, download.

Step 1: Create a job

POST /v1/transcodes with the inputs you want to process and the output format. The inputs array holds 1 to 10 items, each a { url }. That url is either a gs:// URL from the upload flow in Lesson 2 or a public http(s) URL. outputFormat is required; preset is optional.

const res = await fetch('https://api.ffmpeg-micro.com/v1/transcodes', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.FFMPEG_MICRO_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    inputs: [{ url: 'gs://your-bucket/input.mp4' }],
    outputFormat: 'mp4',
    preset: { quality: 'medium', resolution: '720p' },
  }),
})

const job = await res.json()
// { id: '9193c4b4-a12d-4e35-8df0-46847a552b7b', status: 'pending', ... }
console.log(job.id)

preset.quality is one of high, medium, or low, and preset.resolution is one of 480p, 720p, 1080p, or 4k. Save the returned id. Everything else keys off it.

Step 2: Poll for the result

GET /v1/transcodes/<id> returns the current state of the job. The response fields are snake_case. A freshly created job reports pending first, then moves through queued and processing to either completed or failed. Keep requesting on an interval until it settles.

const res = await fetch(`https://api.ffmpeg-micro.com/v1/transcodes/${job.id}`, {
  headers: { Authorization: `Bearer ${process.env.FFMPEG_MICRO_API_KEY}` },
})

const status = await res.json()
// {
//   id: '...',
//   status: 'completed',
//   output_url: 'gs://ffmpeg-micro-user-...-output/<id>.mp4',
//   output_format: 'mp4',
//   duration_seconds: 5.02,
//   processing_time_seconds: 1,
//   error_message: null,
//   created_at: '...',
//   completed_at: '...',
// }

Note that output_url is a gs:// path. It is not directly downloadable. It's the storage location of the result. To actually fetch the bytes, you use the download endpoint next. If the status comes back failed, the reason is in error_message.

Step 3: Get a download URL

GET /v1/transcodes/<id>/download returns a signed https URL you can fetch directly. That URL expires after 10 minutes, so request it right before you download and don't cache it. If it expires, just call /download again for a fresh one.

const res = await fetch(`https://api.ffmpeg-micro.com/v1/transcodes/${job.id}/download`, {
  headers: { Authorization: `Bearer ${process.env.FFMPEG_MICRO_API_KEY}` },
})

const { url } = await res.json()
// url: 'https://storage.googleapis.com/...?X-Goog-Signature=...'

Three reusable helpers

Wrap each step in a small function so the rest of your code stays readable. waitForJob polls on an interval, returns the job when it's completed, throws on failed, and throws if it never settles within maxAttempts.

// client.mjs
const API_URL = 'https://api.ffmpeg-micro.com'
const API_KEY = process.env.FFMPEG_MICRO_API_KEY

const authHeaders = {
  Authorization: `Bearer ${API_KEY}`,
  'Content-Type': 'application/json',
}

// Create a transcode job
export async function createTranscode(body) {
  const res = await fetch(`${API_URL}/v1/transcodes`, {
    method: 'POST',
    headers: authHeaders,
    body: JSON.stringify(body),
  })
  const json = await res.json()
  if (!res.ok) throw new Error(`transcode ${res.status}: ${JSON.stringify(json)}`)
  return json
}

// Poll until the job is completed or failed
export async function waitForJob(jobId, { intervalMs = 3000, maxAttempts = 100 } = {}) {
  for (let i = 0; i < maxAttempts; i++) {
    const res = await fetch(`${API_URL}/v1/transcodes/${jobId}`, {
      headers: { Authorization: `Bearer ${API_KEY}` },
    })
    const job = await res.json()
    if (job.status === 'completed') return job
    if (job.status === 'failed') throw new Error(`Job ${jobId} failed: ${job.error_message || 'unknown'}`)
    await new Promise((r) => setTimeout(r, intervalMs))
  }
  throw new Error(`Job ${jobId} timed out`)
}

// Get a signed download URL for a completed job
export async function getDownloadUrl(jobId) {
  const res = await fetch(`${API_URL}/v1/transcodes/${jobId}/download`, {
    headers: { Authorization: `Bearer ${API_KEY}` },
  })
  const json = await res.json()
  if (!res.ok) throw new Error(`download ${res.status}: ${JSON.stringify(json)}`)
  return json.url
}

The full flow

Now put the three helpers together: create a job from a gs:// input, wait for it, fetch the download URL, and write the file to disk. This is a complete, runnable script on Node 18+.

// full-flow.mjs
import { writeFileSync } from 'node:fs'
import { createTranscode, waitForJob, getDownloadUrl } from './client.mjs'

const job = await createTranscode({
  inputs: [{ url: 'gs://your-bucket/input.mp4' }],
  outputFormat: 'mp4',
  preset: { quality: 'medium', resolution: '720p' },
})
console.log('1. job created ->', { id: job.id, status: job.status })

const done = await waitForJob(job.id)
console.log('2. completed ->', { status: done.status, output_url: done.output_url })

// Fetch the signed URL right before downloading. It expires in 10 minutes
const url = await getDownloadUrl(job.id)

const out = await fetch(url)
const buf = Buffer.from(await out.arrayBuffer())
writeFileSync('out-720p.mp4', buf)
console.log('3. saved out-720p.mp4', buf.length, 'bytes')

Run it:

node --env-file=.env full-flow.mjs

What could go wrong: A long video can outlast a fixed poll timeout. If waitForJob throws "timed out" while the job is still processing, raise maxAttempts (or intervalMs) rather than assuming the job died. A failed status carries the reason in error_message (a bad input URL, an unsupported option). Read it before retrying. And the download URL expires 10 minutes after you request it, so fetch it right before downloading; don't store it and reuse it later.

Key takeaways

  • The lifecycle is create, then poll, then download.
  • The poll GET returns output_url, but it's a gs:// path, so you download via the /download endpoint, which returns a signed https URL.
  • Statuses move through queued, processing, completed, and failed (a fresh job reports pending first).
  • Download URLs expire after 10 minutes, so request one right before you fetch the file.

Next up: change outputFormat to convert between MP4, WebM, and MOV, plus the codec gotcha that trips up WebM jobs.

Build this into your app. No FFmpeg install

One call kicks off a job. No local FFmpeg, no servers, no worker queue to run.

const res = await fetch('https://api.ffmpeg-micro.com/v1/transcodes', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.FFMPEG_MICRO_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    inputs: [{ url: 'gs://your-bucket/input.mp4' }],
    outputFormat: 'mp4',
    preset: { quality: 'medium', resolution: '1080p' },
  }),
})
const job = await res.json() // { id, status: 'pending' }