Bỏ qua, tới nội dung
Tài liệu APIv1

O3O DocBuilder và chuyển đổi

Việc bất đồng bộ và tệp kết quả

Chạy chuyển đổi, dựng tài liệu và điền mẫu ở chế độ bất đồng bộ: vòng đời job, GET /v1/jobs/{id}, GET /v1/files/{id}, thăm dò trạng thái, thời gian giữ kết quả và callback có chữ ký HMAC.

Trong trang này

Ba endpoint POST /v1/convert, POST /v1/buildPOST /v1/template/render nhận tham số async. Với async = true, DocBuilder trả ngay 202 kèm đối tượng job và header Location; bạn hỏi trạng thái rồi tải từng tệp kết quả. Trích xuất luôn đồng bộ, không tạo job.

Khi nào nên dùng#

  • Việc có thể dài hơn sync_timeout_seconds (60 giây).
  • Kịch bản có nhiều phần tử trong save: chế độ đồng bộ chỉ nhận một tệp ra.
  • Không muốn giữ kết nối HTTP mở, hoặc muốn nhận callback khi xong (bản doanh nghiệp).

Vòng đời của job#

statusÝ nghĩa
queuedĐang chờ worker rảnh.
runningĐang chạy trên một worker.
doneXong; outputs có các tệp kết quả.
failedLỗi; error có cùng dạng với lỗi của API.
hết hạnJob và tệp bị xoá hẳn; hỏi lại nhận 410 gone.

Job chạy quá job_timeout_seconds của gói thì tiến trình LibreOffice của worker bị dừng và dựng lại, job chuyển sang failed với mã timeout.

Đối tượng Job#

Trường của Job

  • idstringbắt buộc
    job_ + 24 ký tự hex.
  • kindconvert | build | templatebắt buộc
    Loại việc.
  • statusqueued | running | done | failedbắt buộc
    Trạng thái.
  • created_atdate-timebắt buộc
    Thời điểm tạo.
  • started_at, finished_atdate-time | nulltuỳ chọn
    Thời điểm bắt đầu và kết thúc.
  • duration_msinteger | nulltuỳ chọn
    Thời gian chạy, mili giây.
  • expires_atdate-time | nulltuỳ chọn
    Thời điểm job và các tệp của nó bị xoá.
  • outputsarraybắt buộc
    Các tệp kết quả, xem bảng dưới.
  • errorobject | nullbắt buộc
    Chỉ có khi failed; cùng dạng với error của API.
  • callbackobject | nulltuỳ chọn
    {state: pending | delivered | failed, attempts} khi có callback.

Mỗi phần tử của outputs

  • file_idstringbắt buộc
    file_ + 24 ký tự hex.
  • urlstringbắt buộc
    O3O_PUBLIC_URL + /v1/files/{file_id}. Tải về vẫn cần header Authorization.
  • filenamestringbắt buộc
    Tên tệp.
  • formatstringbắt buộc
    Định dạng.
  • content_typestringbắt buộc
    Kiểu MIME.
  • sizeintegerbắt buộc
    Kích thước, byte.
  • sha256stringbắt buộc
    SHA-256 của tệp.
  • pagesinteger | nulltuỳ chọn
    Số trang nếu biết.
  • expires_atdate-timebắt buộc
    Thời điểm tệp bị xoá.

Hỏi trạng thái job#

GET/v1/jobs/{id}

Trạng thái của một job.

Xác thực: BearerCộng đồngDoanh nghiệp

Tham số

  • Authorizationheaderbắt buộc
    Bearer <khoá API hoặc JWT>.
  • idpath, stringbắt buộc
    Mã job, mẫu ^job_[0-9a-f]{24}$.
Hỏi một job
curl -sS http://localhost:8080/v1/jobs/job_5f0c2a9e41b7d3c8a6e1f024 \
  -H "Authorization: Bearer O3O_DEMO_KEY"
const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };

const res = await fetch(`${BASE_URL}/v1/jobs/job_5f0c2a9e41b7d3c8a6e1f024`, { headers: HEADERS });
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
console.log(await res.json());
import requests

BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}

r = requests.get(f"{BASE_URL}/v1/jobs/job_5f0c2a9e41b7d3c8a6e1f024", headers=HEADERS, timeout=30)
if not r.ok:
    raise RuntimeError(f"{r.status_code}: {r.text}")
print(r.json())
<?php
$ch = curl_init("http://localhost:8080/v1/jobs/job_5f0c2a9e41b7d3c8a6e1f024");
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => ["Authorization: Bearer O3O_DEMO_KEY"],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false || $status >= 400) {
    throw new RuntimeException("HTTP $status: " . ($body === false ? curl_error($ch) : $body));
}
print_r(json_decode($body, true));
using System.Net.Http.Headers;

using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "O3O_DEMO_KEY");

using var res = await http.GetAsync("http://localhost:8080/v1/jobs/job_5f0c2a9e41b7d3c8a6e1f024");
if (!res.IsSuccessStatusCode)
    throw new HttpRequestException($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
Console.WriteLine(await res.Content.ReadAsStringAsync());
200Job chuyển đổi đã xong (bản cộng đồng, giữ 15 phút). Giá trị thời gian chỉ minh hoạ cấu trúc, không phải số đo.
{
  "id": "job_5f0c2a9e41b7d3c8a6e1f024",
  "kind": "convert",
  "status": "done",
  "created_at": "2026-09-21T10:00:00Z",
  "started_at": "2026-09-21T10:00:01Z",
  "finished_at": "2026-09-21T10:00:03Z",
  "duration_ms": 2000,
  "expires_at": "2026-09-21T10:15:03Z",
  "outputs": [
    {
      "file_id": "file_9b3e7d21c4a8f0e65d1b2c37",
      "url": "http://localhost:8080/v1/files/file_9b3e7d21c4a8f0e65d1b2c37",
      "filename": "bao-cao.pdf",
      "format": "pdf",
      "content_type": "application/pdf",
      "size": 48213,
      "sha256": "6991bec8dce8cbd4366a0fe015cce0ccbda86fdebb5fff3f0d7302e48869a932",
      "pages": 3,
      "expires_at": "2026-09-21T10:15:03Z"
    }
  ],
  "error": null,
  "callback": null
}
200Job thất bại vì quá thời gian chạy.
{
  "id": "job_5f0c2a9e41b7d3c8a6e1f024",
  "kind": "build",
  "status": "failed",
  "created_at": "2026-09-21T10:00:00Z",
  "started_at": "2026-09-21T10:00:01Z",
  "finished_at": "2026-09-21T10:00:03Z",
  "duration_ms": 2000,
  "expires_at": "2026-09-21T10:15:03Z",
  "outputs": [],
  "error": {
    "code": "timeout",
    "message": "Job vượt quá thời gian chạy tối đa của gói.",
    "detail": {},
    "request_id": "req_7d1e4b9a2c05f836"
  },
  "callback": null
}

Tải tệp kết quả#

GET/v1/files/{id}

Tải một tệp kết quả.

Xác thực: BearerCộng đồngDoanh nghiệp

Tham số

  • Authorizationheaderbắt buộc
    Bearer <khoá API hoặc JWT>.
  • idpath, stringbắt buộc
    Mã tệp, mẫu ^file_[0-9a-f]{24}$.
Tải một tệp
curl -sS http://localhost:8080/v1/files/file_9b3e7d21c4a8f0e65d1b2c37 \
  -H "Authorization: Bearer O3O_DEMO_KEY" \
  -o bao-cao.pdf -w "HTTP %{http_code}\n"
import { writeFile } from "node:fs/promises";

const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };

const res = await fetch(`${BASE_URL}/v1/files/file_9b3e7d21c4a8f0e65d1b2c37`, { headers: HEADERS });
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile("bao-cao.pdf", Buffer.from(await res.arrayBuffer()));
import requests

BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}

r = requests.get(f"{BASE_URL}/v1/files/file_9b3e7d21c4a8f0e65d1b2c37", headers=HEADERS, timeout=30)
if not r.ok:
    raise RuntimeError(f"{r.status_code}: {r.text}")
with open("bao-cao.pdf", "wb") as fh:
    fh.write(r.content)
<?php
$ch = curl_init("http://localhost:8080/v1/files/file_9b3e7d21c4a8f0e65d1b2c37");
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => ["Authorization: Bearer O3O_DEMO_KEY"],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false || $status >= 400) {
    throw new RuntimeException("HTTP $status: " . ($body === false ? curl_error($ch) : $body));
}
file_put_contents("bao-cao.pdf", $body);
using System.Net.Http.Headers;

using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "O3O_DEMO_KEY");

using var res = await http.GetAsync("http://localhost:8080/v1/files/file_9b3e7d21c4a8f0e65d1b2c37");
if (!res.IsSuccessStatusCode)
    throw new HttpRequestException($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
await File.WriteAllBytesAsync("bao-cao.pdf", await res.Content.ReadAsByteArrayAsync());
200Thân là tệp. ETag là SHA-256 của tệp trong dấu nháy kép, dùng để kiểm toàn vẹn; Content-Disposition có cả dạng filename* cho tên tiếng Việt.
{
  "X-O3O-Request-Id": "req_0123456789abcdef",
  "Content-Disposition": "attachment; filename=\"bao-cao.pdf\"; filename*=UTF-8''bao-cao.pdf",
  "ETag": "\"6991bec8dce8cbd4366a0fe015cce0ccbda86fdebb5fff3f0d7302e48869a932\""
}

Thăm dò đến khi xong rồi tải mọi tệp#

Chờ job và tải kết quả
import os
import time

import requests

BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}


def wait_for_job(job_id: str, timeout: float = 600) -> dict:
    deadline = time.monotonic() + timeout
    delay = 1.0
    while True:
        r = requests.get(f"{BASE_URL}/v1/jobs/{job_id}", headers=HEADERS, timeout=30)
        r.raise_for_status()
        job = r.json()
        if job["status"] == "done":
            return job
        if job["status"] == "failed":
            raise RuntimeError(f"{job['error']['code']}: {job['error']['message']}")
        if time.monotonic() > deadline:
            raise TimeoutError(job_id)
        time.sleep(delay)
        delay = min(delay * 1.5, 5.0)


job = wait_for_job("job_5f0c2a9e41b7d3c8a6e1f024")
for output in job["outputs"]:
    r = requests.get(f"{BASE_URL}/v1/files/{output['file_id']}", headers=HEADERS, timeout=90)
    r.raise_for_status()
    with open(os.path.basename(output["filename"]), "wb") as fh:
        fh.write(r.content)
    print("Đã lưu", output["filename"], output["size"], "bytes")
import { writeFile } from "node:fs/promises";
import { basename } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";

const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };

async function waitForJob(jobId, timeoutMs = 600_000) {
  const deadline = Date.now() + timeoutMs;
  for (let delay = 1000; ; delay = Math.min(delay * 1.5, 5000)) {
    const res = await fetch(`${BASE_URL}/v1/jobs/${jobId}`, { headers: HEADERS });
    if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
    const job = await res.json();
    if (job.status === "done") return job;
    if (job.status === "failed") throw new Error(`${job.error.code}: ${job.error.message}`);
    if (Date.now() > deadline) throw new Error(`Quá thời gian chờ job ${jobId}`);
    await sleep(delay);
  }
}

const job = await waitForJob("job_5f0c2a9e41b7d3c8a6e1f024");
for (const output of job.outputs) {
  const res = await fetch(`${BASE_URL}/v1/files/${output.file_id}`, { headers: HEADERS });
  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
  await writeFile(basename(output.filename), Buffer.from(await res.arrayBuffer()));
  console.log("Đã lưu", output.filename);
}
<?php
const BASE_URL = 'http://localhost:8080';
const API_KEY = 'O3O_DEMO_KEY';

function o3o_get(string $path): string
{
    $ch = curl_init(BASE_URL . $path);
    curl_setopt_array($ch, [
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . API_KEY],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 90,
    ]);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    if ($body === false || $status >= 400) {
        throw new RuntimeException("HTTP $status: " . ($body === false ? curl_error($ch) : $body));
    }
    return $body;
}

function wait_for_job(string $jobId, int $timeout = 600): array
{
    $deadline = time() + $timeout;
    for ($delay = 1.0; ; $delay = min($delay * 1.5, 5.0)) {
        $job = json_decode(o3o_get("/v1/jobs/$jobId"), true);
        if ($job['status'] === 'done') {
            return $job;
        }
        if ($job['status'] === 'failed') {
            throw new RuntimeException($job['error']['code'] . ': ' . $job['error']['message']);
        }
        if (time() > $deadline) {
            throw new RuntimeException("Quá thời gian chờ job $jobId");
        }
        usleep((int) ($delay * 1_000_000));
    }
}

$job = wait_for_job('job_5f0c2a9e41b7d3c8a6e1f024');
foreach ($job['outputs'] as $output) {
    file_put_contents(basename($output['filename']), o3o_get('/v1/files/' . $output['file_id']));
    echo "Đã lưu {$output['filename']}\n";
}
using System.Net.Http.Headers;
using System.Text.Json;

using var http = new HttpClient { BaseAddress = new Uri("http://localhost:8080/") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "O3O_DEMO_KEY");

async Task<JsonElement> WaitForJobAsync(string jobId, TimeSpan timeout)
{
    var deadline = DateTime.UtcNow + timeout;
    for (var delay = 1.0; ; delay = Math.Min(delay * 1.5, 5))
    {
        var job = JsonDocument.Parse(await http.GetStringAsync($"v1/jobs/{jobId}")).RootElement;
        var status = job.GetProperty("status").GetString();
        if (status == "done") return job;
        if (status == "failed") throw new Exception(job.GetProperty("error").GetProperty("code").GetString());
        if (DateTime.UtcNow > deadline) throw new TimeoutException(jobId);
        await Task.Delay(TimeSpan.FromSeconds(delay));
    }
}

var done = await WaitForJobAsync("job_5f0c2a9e41b7d3c8a6e1f024", TimeSpan.FromMinutes(10));
foreach (var output in done.GetProperty("outputs").EnumerateArray())
{
    var fileId = output.GetProperty("file_id").GetString();
    var name = Path.GetFileName(output.GetProperty("filename").GetString()!);
    await File.WriteAllBytesAsync(name, await http.GetByteArrayAsync($"v1/files/{fileId}"));
    Console.WriteLine($"Đã lưu {name}");
}
JOB_ID="job_5f0c2a9e41b7d3c8a6e1f024"
AUTH="Authorization: Bearer O3O_DEMO_KEY"

while :; do
  JOB=$(curl -sS "http://localhost:8080/v1/jobs/$JOB_ID" -H "$AUTH")
  STATUS=$(printf '%s' "$JOB" | jq -r .status)
  [ "$STATUS" = done ] && break
  [ "$STATUS" = failed ] && { printf '%s\n' "$JOB" | jq .error; exit 1; }
  sleep 2
done

printf '%s' "$JOB" | jq -r '.outputs[] | "\(.file_id) \(.filename)"' | while read -r FILE_ID NAME; do
  curl -sS "http://localhost:8080/v1/files/$FILE_ID" -H "$AUTH" -o "$(basename "$NAME")"
done

Thời gian giữ và giới hạn#

Hạng mụcBản cộng đồngBản doanh nghiệp
Job chạy song song (parallel_jobs)1bằng số worker (O3O_DOCBUILDER_WORKERS)
Job chờ tối đa trong hàng đợi (max_queued_jobs)101.000
Thời gian giữ job và tệp kết quả (result_ttl_minutes)15 phút1.440 phút (24 giờ)
Thời gian chạy tối đa của một job (job_timeout_seconds)120 giây600 giây
  • Bộ dọn chạy mỗi phút, xoá job và tệp quá result_ttl_minutes; sau đó mọi yêu cầu tới chúng trả 410 gone.
  • Hàng đợi đầy (max_queued_jobs) thì yêu cầu mới nhận 503 queue_full kèm Retry-After.
  • Hàng đợi nằm trong bộ nhớ: khởi động lại DocBuilder làm mất job đang chờ và đang chạy; tệp của job đã xong vẫn tải được tới khi hết hạn.

Callback khi job xong#

Bản doanh nghiệp có tính năng callback gửi được callback_url cùng yêu cầu. Khi job xong hoặc lỗi, DocBuilder gửi POST JSON {"event": "job.done" | "job.failed", "job": <Job>} tới URL đó, thử tối đa 3 lần: ngay lập tức, sau 10 giây, sau 60 giây. URL callback chịu quy tắc chống SSRF.

HeaderNội dung
X-O3O-EventTên sự kiện: job.done hoặc job.failed.
X-O3O-DeliveryMã của lần gửi, giữ nguyên qua các lần thử lại; dùng để bỏ bản trùng.
X-O3O-TimestampGiây Unix lúc ký.
X-O3O-Signaturesha256= + hex của HMAC-SHA256(O3O_DOCBUILDER_CALLBACK_SECRET, X-O3O-Timestamp + "." + thân nguyên văn). Vắng mặt khi máy chủ chưa đặt khoá.
JSONThân callback
{
  "event": "job.done",
  "job": {
    "id": "job_5f0c2a9e41b7d3c8a6e1f024",
    "kind": "build",
    "status": "done",
    "created_at": "2026-09-21T10:00:00Z",
    "started_at": "2026-09-21T10:00:01Z",
    "finished_at": "2026-09-21T10:00:03Z",
    "duration_ms": 2000,
    "expires_at": "2026-09-22T10:00:03Z",
    "outputs": [
      {
        "file_id": "file_9b3e7d21c4a8f0e65d1b2c37",
        "url": "http://localhost:8080/v1/files/file_9b3e7d21c4a8f0e65d1b2c37",
        "filename": "hop-dong.docx",
        "format": "docx",
        "content_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        "size": 9412,
        "sha256": "6991bec8dce8cbd4366a0fe015cce0ccbda86fdebb5fff3f0d7302e48869a932",
        "pages": 1,
        "expires_at": "2026-09-22T10:00:03Z"
      },
      {
        "file_id": "file_2c7a91e0b54d3f86a1e9c0d4",
        "url": "http://localhost:8080/v1/files/file_2c7a91e0b54d3f86a1e9c0d4",
        "filename": "hop-dong.pdf",
        "format": "pdf",
        "content_type": "application/pdf",
        "size": 30871,
        "sha256": "4a8ed01db889effd4c25e561af1b01492509efaa6f2406f87e5bc3dd94c17235",
        "pages": 1,
        "expires_at": "2026-09-22T10:00:03Z"
      }
    ],
    "error": null,
    "callback": {
      "state": "delivered",
      "attempts": 1
    }
  }
}
Xác minh chữ ký callback
import hashlib
import hmac
import time


def verify_callback(secret: str, headers: dict, raw_body: bytes, max_skew: int = 300) -> bool:
    """headers: header của yêu cầu, tên viết thường. raw_body: thân NGUYÊN VĂN, chưa phân tích JSON."""
    timestamp = headers.get("x-o3o-timestamp", "")
    signature = headers.get("x-o3o-signature", "")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > max_skew:
        return False
    mac = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256)
    return hmac.compare_digest("sha256=" + mac.hexdigest(), signature)
import { createHmac, timingSafeEqual } from "node:crypto";

// headers: header của yêu cầu, tên viết thường. rawBody: Buffer thân NGUYÊN VĂN.
export function verifyCallback(secret, headers, rawBody, maxSkew = 300) {
  const timestamp = headers["x-o3o-timestamp"] ?? "";
  const signature = headers["x-o3o-signature"] ?? "";
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > maxSkew) return false;
  const expected = "sha256=" + createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}
<?php
// $headers: header của yêu cầu, tên viết thường. $rawBody = file_get_contents('php://input').
function verify_callback(string $secret, array $headers, string $rawBody, int $maxSkew = 300): bool
{
    $timestamp = $headers['x-o3o-timestamp'] ?? '';
    $signature = $headers['x-o3o-signature'] ?? '';
    if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $maxSkew) {
        return false;
    }
    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $signature);
}
using System.Security.Cryptography;
using System.Text;

// rawBody: thân NGUYÊN VĂN của yêu cầu, chưa phân tích JSON.
static bool VerifyCallback(string secret, string? timestamp, string? signature, byte[] rawBody, int maxSkew = 300)
{
    if (!long.TryParse(timestamp, out var ts) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > maxSkew)
        return false;
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var data = Encoding.UTF8.GetBytes(timestamp + ".").Concat(rawBody).ToArray();
    var expected = "sha256=" + Convert.ToHexString(hmac.ComputeHash(data)).ToLowerInvariant();
    return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature ?? ""));
}

Mã lỗi thường gặp#

HTTPKhi nào
401unauthorizedThiếu hoặc sai khoá. detail.reasonmissing, invalid hoặc no_keys_configured.
404not_foundKhông có job hoặc tệp với mã đó.
410goneJob hoặc tệp đã hết thời gian giữ.
404Không có job với mã đó.
{
  "error": {
    "code": "not_found",
    "message": "Không tìm thấy job.",
    "detail": {},
    "request_id": "req_0123456789abcdef"
  }
}
410Job đã hết thời gian giữ.
{
  "error": {
    "code": "gone",
    "message": "Job đã hết thời gian giữ.",
    "detail": {},
    "request_id": "req_0123456789abcdef"
  }
}

Sắp có#

Sắp có

Huỷ job, hàng đợi bền vững qua khởi động lại, tải tệp lên trước rồi dùng lại nhiều lần (POST /v1/files).