Human Transcription and Captions API
Submit audio or video to a professional human transcriptionist or captioner. The work is charged to your Pay-As-You-Go Balance and also appears in your FreeTranscription.AI dashboard.
Authentication and Idempotency
Use the API key generated from the API page in your dashboard. Every submission also requires a unique Idempotency-Key. Repeating the same key returns the existing job and does not charge or order the work again.
X-API-Key: YOUR_API_KEY
Idempotency-Key: your-unique-order-reference
Submit Human Work
Multipart File Upload
curl -X POST https://api.freetranscription.ai/customerapi/human.php \
-H "X-API-Key: YOUR_API_KEY" \
-H "Idempotency-Key: customer-order-10482" \
-F "file=@interview.mp3" \
-F "service_type=transcription" \
-F "timestamp=speakerchange" \
-F "verbatim=no" \
-F "extra_comment=Use the supplied spelling for product names." \
-F "callback_url=https://example.com/human-result"
Remote Media URL
curl -X POST https://api.freetranscription.ai/customerapi/human.php \
-H "X-API-Key: YOUR_API_KEY" \
-H "Idempotency-Key: captions-2026-08-15-01" \
-H "Content-Type: application/json" \
-d '{
"audio_url": "https://example.com/media/video.mp4",
"service_type": "captions",
"extra_comment": "US English captions",
"callback_url": "https://example.com/human-result"
}'
Request Options
| Field | Required | Description |
|---|---|---|
file | One media source | Multipart audio or video file. Do not send with audio_url. |
audio_url | One media source | Public HTTP or HTTPS media URL. Do not send with file. |
service_type | Yes | transcription or captions. |
timestamp | No | For transcription: none, 2min, or speakerchange. Ignored for captions. |
verbatim | No | For transcription: yes or no. Ignored for captions. |
extra_comment | No | Instructions for the human team, up to 5,000 characters. |
project_name | No | Dashboard project name. Defaults to the uploaded filename. |
callback_url | No | Public HTTP or HTTPS endpoint for the completed result notification. |
Response and Status
A successful submission returns HTTP 202 Accepted with the Human API job details.
{
"product": "human_transcription",
"job_id": "hapi_20260815_120000_a1b2c3d4e5f6a7b8",
"status": "queued",
"service_type": "transcription",
"timestamp": "speakerchange",
"verbatim": "no"
}
curl -H "X-API-Key: YOUR_API_KEY" \
"https://api.freetranscription.ai/customerapi/human.php?job_id=hapi_..."
A completed transcription response includes the TXT content directly in result. A completed captions response includes the SRT text directly in result.
Completed Transcription Result
{
"job_id": "hapi_...",
"status": "completed",
"result": "Completed human transcript text."
}
Optional Customer Callback
After the human result is stored successfully, the API sends a JSON POST to callback_url. Return any HTTP 2xx response.
Delivery is limited to one immediate attempt plus three cron retries: after approximately 5 minutes, 15 minutes, and 60 minutes. After four failed attempts, delivery is marked failed, automatic retries stop, and FreeTranscription.AI staff are emailed.
Callback Headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-Customer-Job-Id | The Human API job ID returned at submission. |
X-Human-API-Job-Id | The same Human API job ID, provided as a product-specific header. |
Completed Callback
{
"job_id": "hapi_...",
"status": "completed",
"result": "1\n00:00:00,000 --> 00:00:04,500\nCompleted human captions.\n"
}
The result remains available through status lookup and the customer dashboard even if callback delivery fails.
Sample Callback Scripts
Create a callback endpoint on your server, then send its URL as callback_url. Each example receives JSON, stores the complete payload in callback-payloads, and returns a JSON acknowledgement.
<?php
header('Content-Type: application/json');
$raw = file_get_contents('php://input');
$payload = json_decode($raw, true);
$jobId = is_array($payload) ? (string)($payload['job_id'] ?? '') : '';
if ($jobId === '' || json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
echo json_encode(['received' => false, 'error' => 'Invalid callback JSON']);
exit;
}
$jobId = preg_replace('/[^A-Za-z0-9_-]/', '_', $jobId);
$directory = __DIR__ . '/callback-payloads';
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) {
http_response_code(500);
echo json_encode(['received' => false, 'error' => 'Storage directory unavailable']);
exit;
}
$saved = file_put_contents(
$directory . '/' . $jobId . '.json',
json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
LOCK_EX
);
if ($saved === false) {
http_response_code(500);
echo json_encode(['received' => false, 'error' => 'Could not store callback']);
exit;
}
echo json_encode(['received' => true, 'job_id' => $jobId]);
import express from "express";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
const app = express();
app.use(express.json({ limit: "10mb" }));
app.post("/callback", async (req, res) => {
const jobId = String(req.body?.job_id ?? "")
.replace(/[^A-Za-z0-9_-]/g, "_");
if (!jobId) {
return res.status(400).json({ received: false, error: "Invalid callback JSON" });
}
try {
const directory = path.join(process.cwd(), "callback-payloads");
await mkdir(directory, { recursive: true });
await writeFile(
path.join(directory, `${jobId}.json`),
JSON.stringify(req.body, null, 2),
"utf8"
);
return res.json({ received: true, job_id: jobId });
} catch (error) {
return res.status(500).json({ received: false, error: "Could not store callback" });
}
});
app.listen(3000);
import json
import re
from pathlib import Path
from flask import Flask, jsonify, request
app = Flask(__name__)
@app.post("/callback")
def callback():
payload = request.get_json(silent=True)
job_id = str(payload.get("job_id", "")) if isinstance(payload, dict) else ""
job_id = re.sub(r"[^A-Za-z0-9_-]", "_", job_id)
if not job_id:
return jsonify(received=False, error="Invalid callback JSON"), 400
directory = Path("callback-payloads")
directory.mkdir(parents=True, exist_ok=True)
(directory / f"{job_id}.json").write_text(
json.dumps(payload, indent=2, ensure_ascii=False),
encoding="utf-8",
)
return jsonify(received=True, job_id=job_id)
app.run(port=3000)
package main
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"regexp"
)
var unsafeFilename = regexp.MustCompile(`[^A-Za-z0-9_-]`)
func callback(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 10<<20)
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{"received": false, "error": "Invalid callback JSON"})
return
}
jobID, _ := payload["job_id"].(string)
jobID = unsafeFilename.ReplaceAllString(jobID, "_")
if jobID == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{"received": false, "error": "Missing job_id"})
return
}
if err := os.MkdirAll("callback-payloads", 0750); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
filename := filepath.Join("callback-payloads", jobID+".json")
if err := os.WriteFile(filename, data, 0600); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]any{"received": true, "job_id": jobID})
}
func main() {
http.HandleFunc("/callback", callback)
http.ListenAndServe(":3000", nil)
}
using System.Text.Json;
using System.Text.RegularExpressions;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/callback", async (HttpRequest request) =>
{
try
{
using var document = await JsonDocument.ParseAsync(request.Body);
if (!document.RootElement.TryGetProperty("job_id", out var jobIdElement))
{
return Results.BadRequest(new { received = false, error = "Missing job_id" });
}
var jobId = Regex.Replace(jobIdElement.GetString() ?? "", "[^A-Za-z0-9_-]", "_");
if (jobId.Length == 0)
{
return Results.BadRequest(new { received = false, error = "Missing job_id" });
}
var directory = Path.Combine(AppContext.BaseDirectory, "callback-payloads");
Directory.CreateDirectory(directory);
var json = JsonSerializer.Serialize(
document.RootElement,
new JsonSerializerOptions { WriteIndented = true }
);
await File.WriteAllTextAsync(Path.Combine(directory, $"{jobId}.json"), json);
return Results.Json(new { received = true, job_id = jobId });
}
catch (JsonException)
{
return Results.BadRequest(new { received = false, error = "Invalid callback JSON" });
}
});
app.Run();
package com.example.callback;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CallbackController {
private static final Path DIRECTORY = Path.of("callback-payloads");
private static final ObjectMapper JSON = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT);
@PostMapping("/callback")
public ResponseEntity<Map<String, Object>> receive(@RequestBody JsonNode payload)
throws IOException {
if (!payload.hasNonNull("job_id")) {
return ResponseEntity.badRequest()
.body(Map.of("received", false, "error", "Missing job_id"));
}
String jobId = payload.get("job_id").asText()
.replaceAll("[^A-Za-z0-9_-]", "_");
if (jobId.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("received", false, "error", "Missing job_id"));
}
Files.createDirectories(DIRECTORY);
JSON.writeValue(DIRECTORY.resolve(jobId + ".json").toFile(), payload);
return ResponseEntity.ok(Map.of("received", true, "job_id", jobId));
}
}
require "sinatra"
require "json"
require "fileutils"
post "/callback" do
content_type :json
begin
payload = JSON.parse(request.body.read)
rescue JSON::ParserError
halt 400, { received: false, error: "Invalid callback JSON" }.to_json
end
job_id = payload.fetch("job_id", "").to_s.gsub(/[^A-Za-z0-9_-]/, "_")
if job_id.empty?
halt 400, { received: false, error: "Missing job_id" }.to_json
end
directory = File.join(__dir__, "callback-payloads")
FileUtils.mkdir_p(directory)
filename = File.join(directory, "#{job_id}.json")
File.open(filename, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
file.write(JSON.pretty_generate(payload))
end
{ received: true, job_id: job_id }.to_json
end
Dashboard and Retention
Human API orders appear with normal human website orders in the customer dashboard. Transcriptions can be opened in the editor; captions can be downloaded from the project.
Errors
| Status | Meaning |
|---|---|
400 | Invalid media, missing idempotency key, invalid options, or an unsafe URL. |
401 | Missing, invalid, or revoked API key. |
402 | Insufficient Pay-As-You-Go Balance. No Human API order is created. |
403 | The customer account is not active. |
404 | The Human API job does not exist for this customer. |
502 | The order was charged but could not be activated. Staff are notified; the balance is not automatically refunded. |
Reference
| Endpoint | Purpose |
|---|---|
POST /customerapi/human.php |
Submit one multipart file upload or JSON remote media URL for human transcription or captions. |
GET /customerapi/human.php?job_id={job_id} |
Fetch job status and the inline TXT or SRT result after completion. |