# VOC API Documentation v1.5.0

**V**ideo **O**nline **C**ompressor — High-performance FFmpeg processing unit for automated video compression and thumbnail generation. Send a video, receive a compressed MP4.

`POST multipart/form-data` · H.264 · AAC · WebP thumbs · Max 500 MB

---

## Table of contents

1. [Overview](#1-overview)
2. [Authentication](#2-authentication)
3. [Request format](#3-request-format)
4. [Response](#4-response)
5. [Response headers](#5-response-headers)
6. [Error codes](#6-error-codes)
7. [Technical specifications](#7-technical-specifications)
8. [Examples](#8-examples)
   - [cURL](#curl)
   - [JavaScript](#javascript)
   - [PHP](#php)
   - [Deno / Edge functions](#deno--edge-functions)
9. [Changelog](#9-changelog)

---

## 1. Overview

VOC exposes a single HTTP endpoint. You POST a video file, the server runs FFmpeg compression and thumbnail extraction, and returns the compressed video as a binary stream with metadata in the response headers.

| Property       | Value                      |
|----------------|----------------------------|
| Endpoint       | `https://voc.tx.lk/`       |
| Method         | `POST`                     |
| Content type   | `multipart/form-data`      |
| Authentication | `X-API-Key` header         |
| Max file size  | 500 MB                     |
| Output format  | H.264 MP4                  |

---

## 2. Authentication

All requests require a valid API key passed in the `X-API-Key` header. Keys are validated server-side using a constant-time comparison to prevent timing attacks.

> **Warning:** API keys must only be set server-side. Including them in frontend JavaScript exposes the key to anyone who views your page source or network traffic.

| Header      | Required | Description                                         |
|-------------|----------|-----------------------------------------------------|
| `X-API-Key` | Required | Your unique API key. Validated against the server's key store. |

---

## 3. Request format

Send the video as a `multipart/form-data` body using the field name `video`. The server validates file type via magic bytes — the file extension and declared MIME type are not trusted.

| Field   | Required | Accepted MIME types |
|---------|----------|---------------------|
| `video` | Required | `video/mp4` · `video/quicktime` · `video/x-msvideo` · `video/webm` · `video/x-matroska` · `video/3gpp` · `video/x-flv` · `video/ogg` |

> **Note:** File type is detected from the binary content (magic bytes) using `finfo`. Files with a mismatched extension are still accepted as long as the binary content is a recognised video format. Files with unrecognised content will receive a `415` response regardless of extension.

---

## 4. Response

On success, the API streams the compressed video as `video/mp4` with `Content-Disposition: attachment`. The binary body is the complete MP4 file.

| Status | Meaning              | Body                                                              |
|--------|----------------------|-------------------------------------------------------------------|
| `200`  | Success              | Compressed MP4 binary stream                                      |
| `400`  | Bad request          | JSON `{"error": "..."}`                                           |
| `401`  | Unauthorized         | JSON `{"error": "Unauthorized: invalid or missing API key"}`      |
| `413`  | File too large       | JSON `{"error": "File too large", "max_bytes": 524288000}`        |
| `415`  | Unsupported media    | JSON `{"error": "Unsupported media type", "detected": "..."}`     |
| `500`  | Server error         | JSON `{"error": "Compression failed"}`                            |

---

## 5. Response headers

In addition to standard HTTP headers, a successful response includes the following custom headers:

| Header                    | Type    | Description |
|---------------------------|---------|-------------|
| `X-Original-Size`         | integer | Size of the uploaded file in bytes before compression. |
| `X-Compressed-Size`       | integer | Size of the output file in bytes after compression. |
| `X-Video-Thumbnail-WebP`  | string  | Base64-encoded WebP image captured at 00:00:01. Only present if the thumbnail is smaller than 10 KB. Resolution: 320 px wide, aspect-ratio preserved. |
| `Content-Disposition`     | string  | `attachment; filename="compressed_[original name]"` |
| `Content-Length`          | integer | Byte length of the compressed video body. |

> **Note:** `X-Video-Thumbnail-WebP` is conditional. If the generated thumbnail exceeds 10 KB after base64 encoding, the header is omitted. Treat it as optional and handle its absence gracefully.

---

## 6. Error codes

All error responses use `Content-Type: application/json`. The body always contains at minimum an `error` string field.

### Upload errors

PHP upload error codes are returned as `code` in the JSON body when the upload itself fails before reaching the compression stage.

| Code | Meaning |
|------|---------|
| `1`  | File exceeds server's `upload_max_filesize` ini directive |
| `2`  | File exceeds form's `MAX_FILE_SIZE` directive |
| `3`  | File only partially uploaded |
| `4`  | No file was uploaded |
| `6`  | Missing temporary folder |
| `7`  | Failed to write file to disk |

---

## 7. Technical specifications

| Parameter          | Value              | Notes |
|--------------------|--------------------|-------|
| Video codec        | `libx264`          | H.264 baseline, broadly compatible |
| CRF                | `28`               | Quality/size trade-off. Lower = larger file, higher quality |
| Preset             | `fast`             | Encoding speed vs compression efficiency |
| Audio codec        | `aac` @ 128 kbps   | Stereo, web-compatible |
| Container flags    | `+faststart`       | Moves moov atom to file start for progressive playback |
| Thumbnail format   | WebP               | Captured at 1 s, 320 px wide, quality 10 (ffmpeg scale) |
| File detection     | `finfo` magic bytes | Extension and declared MIME type are not trusted |
| Max file size      | 500 MB             | Checked before and after upload move |
| Temp file cleanup  | Guaranteed on shutdown | Via `register_shutdown_function` |

---

## 8. Examples

### cURL

**Basic compression**

```bash
curl -X POST https://voc.tx.lk/ \
  -H "X-API-Key: YOUR_KEY" \
  -F "video=@input.mp4" \
  --output "compressed.mp4"
```

**Include response headers (for thumbnail + stats)**

```bash
curl -X POST https://voc.tx.lk/ \
  -H "X-API-Key: YOUR_KEY" \
  -F "video=@input.mp4" \
  -D headers.txt \
  --output "compressed.mp4"

# headers.txt will contain X-Original-Size, X-Compressed-Size, X-Video-Thumbnail-WebP
```

---

### JavaScript

Always set your API key server-side. The example below is for server-rendered contexts (Node.js, edge functions) — never hardcode the key in browser JavaScript.

```javascript
const compressVideo = async (file) => {
  const formData = new FormData();
  formData.append('video', file);

  const response = await fetch('https://voc.tx.lk/', {
    method: 'POST',
    headers: { 'X-API-Key': process.env.VOC_API_KEY },
    body: formData,
  });

  if (!response.ok) {
    const err = await response.json();
    throw new Error(err.error);
  }

  // Compression stats
  const originalSize   = parseInt(response.headers.get('X-Original-Size'));
  const compressedSize = parseInt(response.headers.get('X-Compressed-Size'));
  const ratio = ((1 - compressedSize / originalSize) * 100).toFixed(1);

  // Thumbnail (may be absent for large thumbnails)
  const thumbB64 = response.headers.get('X-Video-Thumbnail-WebP');
  const thumbUrl = thumbB64 ? `data:image/webp;base64,${thumbB64}` : null;

  // Compressed video
  const videoBlob = await response.blob();
  const videoUrl  = URL.createObjectURL(videoBlob);

  return { videoUrl, thumbUrl, originalSize, compressedSize, ratio };
};
```

---

### PHP

This is the recommended integration pattern — the API key stays on the server and is never sent to the browser.

```php
$ch = curl_init('https://voc.tx.lk/');

curl_setopt_array($ch, [
  CURLOPT_POST           => true,
  CURLOPT_POSTFIELDS     => ['video' => new CURLFile($tmp_path, $mime, $filename)],
  CURLOPT_HTTPHEADER     => ['X-API-Key: ' . $_ENV['VOC_API_KEY']],
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HEADER         => true,
  CURLOPT_TIMEOUT        => 0,
]);

$raw      = curl_exec($ch);
$status   = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$hdr_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);

$headers = substr($raw, 0, $hdr_size);
$body    = substr($raw, $hdr_size);

// Compression stats
preg_match('/X-Original-Size: (\d+)/i',   $headers, $m1);
preg_match('/X-Compressed-Size: (\d+)/i', $headers, $m2);

// Thumbnail (optional — check before use)
$thumb_b64 = null;
if (preg_match('/X-Video-Thumbnail-WebP: ([\w+\/=]+)/i', $headers, $tm)) {
  $thumb_b64 = trim($tm[1]);
}

if ($status === 200) {
  file_put_contents('compressed.mp4', $body);
} else {
  $error = json_decode($body, true);
}
```

---

### Deno / Edge functions

When calling VOC from a server-to-server context, validate the source URL before fetching to prevent SSRF. Check `Content-Length` before downloading the full file to avoid buffering large payloads unnecessarily.

```typescript
// Validate URL before fetching (prevent SSRF)
const parsed = new URL(videoUrl);
const allowedHosts = ['your-bucket.supabase.co'];
if (parsed.protocol !== 'https:' ||
    !allowedHosts.some(h => parsed.hostname.endsWith(h))) {
  throw new Error('Invalid video URL');
}

// Check Content-Length before downloading
const head = await fetch(videoUrl, { method: 'HEAD' });
const contentLength = parseInt(head.headers.get('content-length') ?? '0');
if (contentLength > 500 * 1024 * 1024) {
  throw new Error('Video too large');
}

// Fetch and forward to VOC with abort timeout
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 300_000); // 5 min

const videoBlob = await (await fetch(videoUrl)).blob();
const formData  = new FormData();
formData.append('video', videoBlob, 'video.mp4');

const vocRes = await fetch('https://voc.tx.lk/', {
  method: 'POST',
  headers: { 'X-API-Key': Deno.env.get('VOC_API_KEY')! },
  body: formData,
  signal: controller.signal,
});
clearTimeout(timeout);

// Read stats and thumbnail from response headers
const originalSize   = vocRes.headers.get('X-Original-Size');
const compressedSize = vocRes.headers.get('X-Compressed-Size');
const thumbB64       = vocRes.headers.get('X-Video-Thumbnail-WebP'); // may be null
```

> **Warning:** Edge functions have limited RAM. Avoid holding both the original blob and the compressed blob in memory simultaneously. If the Supabase Storage SDK supports streaming uploads, pipe `vocRes.body` directly instead of calling `await vocRes.blob()`.

---

## 9. Changelog

### v1.5.0
- File type validated via magic bytes (`finfo`) — extension no longer trusted
- 500 MB size limit enforced before and after upload move (HTTP 413)
- API key comparison uses `hash_equals()` (constant-time, timing-attack safe)
- Temp files guaranteed to be cleaned up via `register_shutdown_function`
- Added `X-Original-Size` and `X-Compressed-Size` response headers
- Thumbnail header now conditional (omitted if WebP exceeds 10 KB); resolution increased to 320 px wide
- CORS origin restricted to configured value (was `*`)
- Content-Security-Policy header added to HTML landing page
- Request logging to `voc_access.log` with IP, timing, and compression ratio
- HTTP 415 returned for unsupported MIME types (previously silent or 500)

### v1.4.0
- Tailwind Edition — redesigned landing page
- Thumbnail returned as base64 in `X-Video-Thumbnail-WebP` header
- WebP format for thumbnails (previously JPEG)

### v1.1.0
- Initial public API documentation
- FFmpeg H.264 + AAC compression pipeline
- X-API-Key authentication

---

*VOC API v1.5.0 · Powered by FFmpeg · [textalk.com](https://textalk.com)*