AI Video
Three video jobs made by FOTOhub AI, each started with one call and read back with one status route:
- AI Motion (
POST /v1/video/generations, modelfotohub-motion-audio): a 5-second 720p clip with sound, from a text prompt or from a first frame. - Avatar (
POST /v1/video/avatar): a talking-head clip of up to 5 seconds at 480p from a portrait of you (or of a person who agreed) and either your own speech or a short script. - Dubbing (
POST /v1/video/dub): your video, translated and re-voiced into up to 5 languages, one job per language.
Every job is charged once, when it is accepted, and refunded automatically if it fails. Every finished video is labelled as AI-generated (Content Credentials).
Endpoints
| Method | Path | Charged | Description |
|---|---|---|---|
| POST | /v1/video/generations | yes | AI Motion clip |
| POST | /v1/video/avatar | yes | Talking-head avatar clip (consent required) |
| POST | /v1/video/dub | yes, per language | Dubbing, one job per target language |
| GET | /v1/video/generations/{job_id} | no | Status and result of a job started by any of the three routes |
Base URL: https://apis.fotohub.appAuthentication: an API key only: Authorization: Bearer fh_live_your_api_key (see Authentication). A dashboard session is refused with 401 {"detail": {"error": "api_key_required", "message": "Use an API key (fh_live_...)."}}. The three POST routes need a key with write access; a read-only key gets 403.
Rate limits
| Limit | Value | Answer |
|---|---|---|
Starts per API key (all three POST routes together) | 12 per minute | 429 rate_limited, Retry-After: 60 |
| Reads per API key | 240 per minute | 429 rate_limited, Retry-After: 60 |
| Starts per account, per feature | 4 per minute | 429 rate_limited, Retry-After: 60 |
| Runs per minute, runs in progress, engine capacity and daily processing, per account and feature | set per feature | 429 ceiling_exceeded, with Retry-After (see Errors) |
{"detail": {"error": "rate_limited", "charged": false,
"message": "Too many requests for this key. You were not charged."}}Idempotency: request_id
Every POST takes a request_id (a UUID you generate). A retry with the same request_id returns the first job with "created": false and is never charged twice. For dubbing, the same request_id returns the jobs already created and starts only the missing languages.
Pricing
Prices are not hard-coded in this page and there is no separate quote route on these three paths.
- The accepted response carries
quote:creditsandusd(the price of this run),eta_sandqueue_depth. For dubbing,quoteis the total over all languages, withper_languagewhen you asked for more than one. - The job carries
charged_creditsonce it is charged. - The USD wallet rates are listed by
GET /v1/pricing. - Every run also has a per-run ceiling, and each feature has per-minute, concurrency and daily processing limits (see Errors).
Price check: quote_credits
Optional on every POST. Send the quote.credits you showed to the user (for dubbing the total over all languages). When it is more than 0.5 credit away from the current price, the start is refused before any charge:
{
"detail": {
"error": "price_changed",
"quoted_credits": 12.0,
"current_credits": 14.0,
"message": "The price changed since it was shown. Check the new price; you were not charged."
}
}quote_credits must be a JSON number: any other value (a word, an object) is rejected by request validation with 422, before anything is charged. Without quote_credits there is no check. See Price changed (409).
AI Motion
POST /v1/video/generations| Field | Type | Default | Description |
|---|---|---|---|
model | string | required | "fotohub-motion-audio", the only model on this route |
request_id | UUID | required | See Idempotency |
prompt | string | required | What happens in the clip. Plain text, at most 2000 characters |
image | string | none | First frame (image-to-video): photos/<your user id>/..., a PNG, JPEG or WebP of your own, at most 3.75 MB. Omit for text-to-video |
duration | integer | 5 | Seconds. Only 5 is available |
resolution | string | "720p" | Only "720p" is available |
seed | integer | none | 0 to 2^32 - 1 |
quote_credits | number | none | See Price check |
Unknown fields are refused (422). The clip has sound. The prompt is screened before the charge (422 content_policy); a first frame is reviewed too, and images with nudity or sexual content, of minors or of public figures are refused (422 content_blocked, minor_not_allowed, public_figure_not_allowed). A first frame over 3.75 MB cannot be reviewed and answers 413 image_too_large (and over 20 MB 413 input_too_large).
Where it runs: the video is generated on FOTOhub AI servers in the United States; the result is stored in the EU (FOTOhub storage, Poland).
curl https://apis.fotohub.app/v1/video/generations \
-H "Authorization: Bearer $FOTOHUB_API_KEY" -H "Content-Type: application/json" \
-d '{
"model": "fotohub-motion-audio",
"request_id": "6f1d3c52-8a0e-4b49-9c1f-0a2b3c4d5e6f",
"prompt": "Waves rolling onto a pebble beach at sunset, gulls calling"
}'import os, time, uuid, requests
API = "https://apis.fotohub.app"
H = {"Authorization": f"Bearer {os.environ['FOTOHUB_API_KEY']}"}
out = requests.post(f"{API}/v1/video/generations", headers=H, json={
"model": "fotohub-motion-audio",
"request_id": str(uuid.uuid4()),
"prompt": "Waves rolling onto a pebble beach at sunset, gulls calling",
}, timeout=60).json()
job = out["job"]
print("price:", out.get("quote"))
while job["status"] in ("queued", "running"):
time.sleep(10)
job = requests.get(f"{API}/v1/video/generations/{job['id']}", headers=H, timeout=30).json()
if job["status"] == "succeeded":
print([a["url"] for a in job["result"]["assets"]])
else:
print(job["error"], "refunded:", job["refunded"])import { randomUUID } from "node:crypto";
const API = "https://apis.fotohub.app";
const H = { Authorization: `Bearer ${process.env.FOTOHUB_API_KEY}`, "Content-Type": "application/json" };
const out: any = await (await fetch(`${API}/v1/video/generations`, {
method: "POST", headers: H,
body: JSON.stringify({
model: "fotohub-motion-audio",
request_id: randomUUID(),
prompt: "Waves rolling onto a pebble beach at sunset, gulls calling",
}),
})).json();
let job = out.job;
while (job.status === "queued" || job.status === "running") {
await new Promise((r) => setTimeout(r, 10000));
job = await (await fetch(`${API}/v1/video/generations/${job.id}`, { headers: H })).json();
}
console.log(job.status, job.result?.assets?.map((a: any) => a.url));Response 200:
{
"job": {
"id": "0b7e5d1c-...",
"type": "video.generation",
"model": "fotohub-motion-audio",
"status": "queued",
"refunded": false
// + progress, eta_s, queue_position, step_index, steps_total, steps_completed,
// charged_credits, created_at, started_at, finished_at, updated_at, error, result
},
"created": true,
"quote": {"credits": 12.0, "usd": 0.0, "eta_s": 120, "queue_depth": 0},
"limits": {"max_seconds": 5, "resolution": "720p"}
}The quote values above are placeholders; read the real ones from your response.
Avatar
POST /v1/video/avatar| Field | Type | Default | Description |
|---|---|---|---|
request_id | UUID | required | See Idempotency |
consent | object | required | The consent declaration, see below |
portrait | string | required | photos/<your user id>/...: a PNG, JPEG or WebP of your own, at most 3.75 MB, shortest side at least 64 px and longest side at most 4096 px |
mode | "audio" | "script" | "audio" | Animate your own speech, or a text |
audio | string | none | mode: "audio": your speech in your storage, WAV or MP3, at most 25 MB and at most 5 seconds (at least 0.5 s) |
script | string | none | mode: "script": what the avatar says, at most 80 characters (about 5 seconds of speech) |
language | "pl" | "en" | "de" | "pl" | Language of the script |
voice_ref | string | none | mode: "script": your own voice recording to speak the script (WAV, MP3, M4A, OGG or FLAC, at most 10 MB); the built-in voice when omitted |
voice_consent | boolean | false | Required (true) with voice_ref: the voice is yours, or of a person who agreed |
prompt | string | none | Optional style or scene hint, at most 500 characters |
gesture | "natural" | "calm" | "expressive" | "natural" | |
seed | integer | none | 0 to 2^32 - 1 |
quote_credits | number | none | See Price check |
The clip is 480p and at most 5 seconds. Speech longer than 5 seconds is refused before the charge with 422 audio_too_long, which says how many parts it takes (parts, limit_seconds, measured_s); a script over 80 characters is 422 script_too_long.
Consent (required)
"consent": {
"subject": "self",
"version": "avatar-2026-10-05.1",
"locale": "en",
"sha256": "<sha256 of the declaration text you showed>"
}subject:"self"(the portrait shows you) or"consented_person"(an adult who gave you explicit permission).version: the current declaration version,avatar-2026-10-05.1.locale:en,plorde.sha256is optional; when you send it, it must match the text of that locale.- Without
consent, or with an outdated version, the answer is403 consent_requiredand itsconsentfield carries the current declaration:version,subjects,residency,retention_yearsandtexts({"en": {"text", "sha256"}, "pl": ..., "de": ...}). Show the text to the person who decides, then send it back as above. Nothing is charged. - The portrait is then reviewed before the charge. Portraits with nudity or sexual content, of minors or of public figures are refused (
422 content_blocked,minor_not_allowed,public_figure_not_allowed); a portrait over 3.75 MB is413 portrait_too_large; a portrait that cannot be read, is not PNG, JPEG or WebP, has a side under 64 px or a side over 4096 px is422 invalid_portrait. - A consent record (without the photo) is kept for 6 years as proof of consent.
Where it runs: the portrait is reviewed by an AI vision model hosted in the United States, and the video is rendered on FOTOhub GPU servers in the United States (Google Cloud, us-east4). A script is turned into speech on FOTOhub servers in the EU, and the result is stored in the EU (Azure, Poland Central). The declaration text says this; if you resell Avatar, pass it on to your users.
curl https://apis.fotohub.app/v1/video/avatar \
-H "Authorization: Bearer $FOTOHUB_API_KEY" -H "Content-Type: application/json" \
-d '{
"request_id": "9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
"portrait": "photos/<your-user-id>/me.jpg",
"mode": "script",
"script": "Hi, welcome to our spring collection!",
"language": "en",
"consent": {"subject": "self", "version": "avatar-2026-10-05.1", "locale": "en"}
}'import os, uuid, requests
API = "https://apis.fotohub.app"
H = {"Authorization": f"Bearer {os.environ['FOTOHUB_API_KEY']}"}
body = {
"request_id": str(uuid.uuid4()),
"portrait": "photos/<your-user-id>/me.jpg",
"mode": "script",
"script": "Hi, welcome to our spring collection!",
"language": "en",
"consent": {"subject": "self", "version": "avatar-2026-10-05.1", "locale": "en"},
}
r = requests.post(f"{API}/v1/video/avatar", headers=H, json=body, timeout=60)
if r.status_code == 403 and r.json()["detail"].get("error") == "consent_required":
current = r.json()["detail"]["consent"] # show current["texts"]["en"]["text"], then retry
print(current["version"])
else:
print(r.json()["job"]["id"])import { randomUUID } from "node:crypto";
const res = await fetch("https://apis.fotohub.app/v1/video/avatar", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.FOTOHUB_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
request_id: randomUUID(),
portrait: "photos/<your-user-id>/me.jpg",
mode: "script",
script: "Hi, welcome to our spring collection!",
language: "en",
consent: { subject: "self", version: "avatar-2026-10-05.1", locale: "en" },
}),
});
const out: any = await res.json();
console.log(res.status, out.job?.id ?? out.detail);The response has the same shape as AI Motion's, with job.type: "video.avatar" and limits: {"max_seconds": 5, "resolution": "480p"}.
Dubbing
POST /v1/video/dub| Field | Type | Default | Description |
|---|---|---|---|
request_id | UUID | required | See Idempotency |
source | string | required | Your video in your own storage: videos/<your user id>/... (also photos/ or cloud-drive/). MP4, MOV, M4V, WebM or MKV, at most 1 GB, at least 2 seconds, with an audio track |
languages | string[] | required | 1 to 5 target languages, one job (and one charge) each |
source_language | string | auto | The spoken language; omit or "auto" to detect it |
glossary | string[] | [] | Names and terms to keep exactly as written: at most 30 terms of at most 60 characters |
voice_ref | string | none | Your own voice recording to clone for every speaker (WAV, MP3, M4A, OGG or FLAC, at most 10 MB). Needs voice_consent |
voice_consent | object | none | {"version": "dub-voice-2026-10-05.1", "locale": "en" | "pl" | "de", "sha256": "<optional>"} |
review | false | false | Always false on the API: the job runs through without pausing for a translation review |
quote_credits | number | none | The total for all languages. See Price check |
Languages (target and source): ar, da, de, el, en, es, fi, fr, he, hi, it, ja, ko, ms, nl, no, pl, pt, ru, sv, sw, tr, zh. A target equal to source_language is 422 same_language.
Length: the source is at most 300 seconds by default; the accepted response states the limit as limits.max_source_seconds. Longer is 422 input_too_long, shorter than 2 s 422 input_too_short, no sound 422 no_audio_track.
Voice cloning: with voice_ref and no valid voice_consent the answer is 403 voice_consent_required, with the current declaration (version and texts per locale with sha256) in its consent field. Nothing is charged.
Where it runs: transcription, voice and background separation run on FOTOhub servers in the EU; the translation (text only) is made by an AI language model hosted by Google Cloud; the result is stored in the EU (Azure, Poland Central).
curl https://apis.fotohub.app/v1/video/dub \
-H "Authorization: Bearer $FOTOHUB_API_KEY" -H "Content-Type: application/json" \
-d '{
"request_id": "2c4e6a80-1b3d-4f5a-9c7e-1a2b3c4d5e6f",
"source": "videos/<your-user-id>/promo.mp4",
"languages": ["en", "de"],
"glossary": ["FOTOhub"]
}'import os, uuid, requests
API = "https://apis.fotohub.app"
H = {"Authorization": f"Bearer {os.environ['FOTOHUB_API_KEY']}"}
out = requests.post(f"{API}/v1/video/dub", headers=H, json={
"request_id": str(uuid.uuid4()),
"source": "videos/<your-user-id>/promo.mp4",
"languages": ["en", "de"],
"glossary": ["FOTOhub"],
}, timeout=120).json()
print("project:", out["project_id"], "total:", out["quote"])
for item in out["jobs"]:
print(item["language"], item["job"]["id"], item["job"]["status"])import { randomUUID } from "node:crypto";
const out: any = await (await fetch("https://apis.fotohub.app/v1/video/dub", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.FOTOHUB_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
request_id: randomUUID(),
source: "videos/<your-user-id>/promo.mp4",
languages: ["en", "de"],
glossary: ["FOTOhub"],
}),
})).json();
for (const item of out.jobs) console.log(item.language, item.job.id);Response 200:
{
"project_id": "5d2f...",
"created": true,
"jobs": [
{"language": "en", "job": {"id": "...", "type": "video.dub", "status": "queued"}, "created": true},
{"language": "de", "job": {"id": "...", "type": "video.dub", "status": "queued"}, "created": true}
],
"limits": {"max_source_seconds": 300.0},
"quote": {"credits": 0.0, "usd": 0.0, "eta_s": 300, "queue_depth": 0,
"per_language": {"credits": 0.0, "usd": 0.0}}
}Placeholder figures; quote.credits and quote.usd are the total for all languages. If a later language cannot be started (for example the wallet runs out), the error carries project_id, created_jobs (the ids already started and charged) and a note: retry with the same request_id to start only the rest.
Job status
GET /v1/video/generations/{job_id}Free. Reads a job started by any of the three routes (for dubbing, one language's job).
{
"id": "0b7e5d1c-...",
"type": "video.generation", // video.generation | video.avatar | video.dub
"model": "fotohub-motion-audio", // AI Motion only
"status": "succeeded", // queued | running | succeeded | failed | cancelled
"progress": 1.0,
"eta_s": null,
"queue_position": null,
"step_index": 3,
"steps_total": 4,
"steps_completed": 4,
"charged_credits": 12.0,
"created_at": "...", "started_at": "...", "finished_at": "...", "updated_at": "...",
"refunded": false,
"error": null, // {"code", "message"} when failed
"result": {"assets": [{"asset_id": "...", "kind": "video", "content_type": "video/mp4", "bytes": 2483021, "url": "https://..."}]},
"steps": [{"idx": 0, "name": "generate", "status": "succeeded", "review": false, "result": null}],
"eta": null
}steps[].nameis one of the neutral step names:prepare,transcribe,translate,voice,generate,mix,audio,assemble,frame_rate,enhance,preview,finalize,process.- A failed job has
error.code(for examplegeneration_failed) anderror.message, andrefunded: trueonce the charge is back. - An unknown id, or a job of another account:
404 {"detail": {"error": "job_not_found"}}.
Webhooks
A job started through the API sends one webhook when it ends: generation.completed or generation.failed (also for a cancelled job). The payload: type, model, job_id, status, refunded, error, result, charged_credits, finished_at. Delivery is at least once, so the same event can arrive twice: deduplicate by job_id and event type.
Errors
Every refusal happens before the charge: nothing is charged. Bodies are {"detail": {"error": ..., "message": ..., ...}}, but the charged field is not on every one of them:
- With
"charged": false:402 insufficient_funds,409 idempotency_key_reused,429 rate_limited, the403consent refusals, the413and422input and content-review refusals,422 invalid_portrait, and503 temporarily_unavailable/moderation_unavailableraised by the feature itself. - Without
charged:401 api_key_required,403 feature_disabled, the read-only-key403,409 price_changed,422and429 ceiling_exceeded,429 busy, and the503 temporarily_unavailablethat carries areason(from the price check). Theirmessagestill ends with "You were not charged." where there is one.
Treat any of these statuses as "not charged"; do not rely on the field being present.
| Status | error | When |
|---|---|---|
| 401 | api_key_required | A dashboard session instead of an API key |
| 402 | insufficient_funds | The wallet cannot cover the run (see 402 Payment Required) |
| 403 | feature_disabled | The feature is not available to your account (reason, and min_tier when a plan is needed) |
| 403 | consent_required, voice_consent_required | Avatar consent missing or outdated; a voice sample without consent. The current declaration is in consent |
| 403 | (string) | Read-only API key |
| 409 | price_changed | quote_credits is stale (see above) |
| 409 | idempotency_key_reused | The request_id was already used for a different kind of job (job_id names it) |
| 413 | input_too_large, image_too_large, portrait_too_large | A file over its limit (limit_bytes) |
| 422 | invalid_portrait | Avatar: the portrait cannot be read, is not PNG, JPEG or WebP, or a side is under 64 px or over 4096 px |
| 422 | invalid_params, prompt_required, image_required, input_not_allowed, input_not_found, unsupported_input, unsupported_language, same_language, invalid_glossary, script_required, script_too_long, audio_too_short, audio_too_long, input_too_short, input_too_long, no_audio_track, unreadable_media, duration_over_cap | The request cannot run as sent; field names the input |
| 422 | content_policy, content_blocked, minor_not_allowed, public_figure_not_allowed | The text or image was refused by the content review |
| 422 | ceiling_exceeded | The run is above the per-run limit (limit, credits) |
| 429 | rate_limited | See Rate limits |
| 429 | busy | The feature is busy right now (retry_after_s, queue_depth, Retry-After) |
| 429 | ceiling_exceeded | A usage limit of the feature was hit, not only the daily one: too many runs started in the last minute, too many of your runs in progress, the engine at capacity, or the daily processing limit used up. The message says which; retry_after_s and Retry-After say when to retry (within about a minute for the per-minute and concurrency limits, midnight UTC for the daily limit); limit when there is one |
| 503 | temporarily_unavailable, moderation_unavailable, capability_unavailable | Try again after Retry-After |
input_not_allowed means the path is not in your own storage: every input (image, portrait, audio, voice_ref, source) must be a file of yours, addressed by its storage path, not a URL.
Related APIs
- Video Generation — text-to-video and image-to-video with catalogue models
- Video Editing — transcode, upscale, object removal and more
- Errors — error format and
price_changed

