Lesson 2 of 10

Upload Your Video

Move a local file into the API with the presigned upload flow: request a URL, PUT the bytes, confirm. Or skip it and pass a public URL.

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

Get free API key

Getting a video into the API

FFmpeg Micro processes files that live in cloud storage, not files on your laptop. To transcode a local video, you first move it into the API's input bucket with the presigned upload flow. It's three steps: ask for a presigned URL, PUT the raw bytes to it, then confirm. The confirm step hands back a gs:// URL you'll pass to the transcode endpoint in the next lesson.

Shortcut: already have a public URL?

If your video is already reachable at a public http(s) URL, you can skip this entire lesson. Pass the URL straight to the transcode endpoint as inputs:[{ url: 'https://example.com/video.mp4' }]. The upload flow below is only needed for local or private files that the API can't fetch on its own.

Step 1: request a presigned upload URL

Tell the API the file's name, its content type, and its size in bytes. The fileSize must be a JSON number. Sending it as a string returns 400. The response gives you an uploadUrl to PUT to and a server-side filename (timestamp-prefixed) that you must keep for the confirm step.

const res = await fetch(`${API_URL}/v1/upload/presigned-url`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    filename: 'sample.mp4',
    contentType: 'video/mp4', // must be an allowed type (see below)
    fileSize: 122925,         // a NUMBER of bytes, not a string
  }),
})

// { success: true, result: { uploadUrl, filename } }
// result.filename is the SERVER filename. Use it in step 3.

contentType must be one of: video/mp4, video/webm, video/avi, video/quicktime, video/mkv, audio/mpeg, audio/wav, audio/flac, audio/aac.

Step 2: PUT the raw bytes

Send the file's raw bytes to the uploadUrl with an HTTP PUT. This request goes directly to Google Cloud Storage, so it carries no API key. Only a Content-Type header that must match the contentType from step 1. The presigned URL expires 10 minutes after it's issued, so upload promptly.

await fetch(uploadUrl, {
  method: 'PUT',
  headers: { 'Content-Type': 'video/mp4' }, // same as step 1's contentType
  body: fileBytes,                          // the raw file bytes
})

Step 3: confirm the upload

Confirm the upload so the API registers the file and probes its metadata. Send the server-returned filename from step 1 (not your original filename) along with the fileSize. The response includes result.fileUrl, the gs:// URL that is your transcode input, plus probed metadata like duration_seconds, handy for cost checks later.

const res = await fetch(`${API_URL}/v1/upload/confirm`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    filename: '1782928050941-sample.mp4', // result.filename from step 1
    fileSize: 122925,
  }),
})

// {
//   success: true,
//   result: {
//     fileUrl: 'gs://ffmpeg-micro-...-input/1782928050941-sample.mp4',
//     downloadUrl: 'https://storage.googleapis.com/...',
//     filename: '...',
//     fileSize: 122925,
//     metadata: { duration_seconds: 5, format: 'mov,mp4,m4a,3gp,3g2,mj2', size_bytes: 122925 },
//   },
// }

A reusable uploadFile helper

Here are all three steps wrapped in one function. It reads the file from disk with Node's built-in node:fs and node:path, so there's nothing to install on Node 18+. It returns the confirm result, whose fileUrl you'll feed to the transcode endpoint.

// client.mjs
import { readFileSync, statSync } from 'node:fs'
import { basename } from 'node:path'

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

if (!API_KEY) throw new Error('Set FFMPEG_MICRO_API_KEY')

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

// Upload flow: presigned URL -> PUT -> confirm -> returns gs:// url
export async function uploadFile(localPath, contentType = 'video/mp4') {
  const fileSize = statSync(localPath).size

  // 1. Ask for a presigned upload URL
  const presignRes = await fetch(`${API_URL}/v1/upload/presigned-url`, {
    method: 'POST',
    headers: authHeaders,
    body: JSON.stringify({ filename: basename(localPath), contentType, fileSize }),
  })
  if (!presignRes.ok) throw new Error(`presigned-url ${presignRes.status}: ${await presignRes.text()}`)
  const { result: { uploadUrl, filename } } = await presignRes.json()

  // 2. PUT the raw bytes to the presigned URL (no API key on this request)
  const putRes = await fetch(uploadUrl, {
    method: 'PUT',
    headers: { 'Content-Type': contentType },
    body: readFileSync(localPath),
  })
  if (!putRes.ok) throw new Error(`upload PUT ${putRes.status}: ${await putRes.text()}`)

  // 3. Confirm the upload -> get the gs:// url to use as a transcode input
  const confirmRes = await fetch(`${API_URL}/v1/upload/confirm`, {
    method: 'POST',
    headers: authHeaders,
    body: JSON.stringify({ filename, fileSize }),
  })
  if (!confirmRes.ok) throw new Error(`confirm ${confirmRes.status}: ${await confirmRes.text()}`)
  const { result } = await confirmRes.json()
  return result
}

Import it and upload a local file:

// upload.mjs
import { uploadFile } from './client.mjs'

const result = await uploadFile('sample.mp4', 'video/mp4')
console.log('Uploaded:', result.fileUrl)             // gs://... your transcode input
console.log('Duration:', result.metadata.duration_seconds, 'seconds')

Run it:

node --env-file=.env upload.mjs
# Uploaded: gs://ffmpeg-micro-...-input/1782928050941-sample.mp4
# Duration: 5 seconds

What could go wrong: A 400 on step 1 almost always means the body is off. Send fileSize as a JSON number, not a string, and make sure contentType is one of the allowed values above. Anything else is rejected. On step 3, use the server-returned result.filename from step 1 (the timestamp-prefixed one), not your original filename, or the confirm won't find the uploaded object. And if you sit between steps too long, the presigned URL expires after 10 minutes. Request a fresh one and start over.

Key takeaways

  • The upload flow is three steps: presigned-url, then PUT, then confirm.
  • fileSize is a number of bytes, never a string.
  • Confirm with the server-returned result.filename, not your local filename.
  • The confirm result.fileUrl (a gs:// URL) is your transcode input.
  • A public http(s) URL skips the whole flow. Pass it directly as a transcode input.

Next up: create a transcode job with that input and poll until the result is ready.

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' }