Skip to content

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, model fotohub-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 ​

MethodPathChargedDescription
POST/v1/video/generationsyesAI Motion clip
POST/v1/video/avataryesTalking-head avatar clip (consent required)
POST/v1/video/dubyes, per languageDubbing, one job per target language
GET/v1/video/generations/{job_id}noStatus 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 ​

LimitValueAnswer
Starts per API key (all three POST routes together)12 per minute429 rate_limited, Retry-After: 60
Reads per API key240 per minute429 rate_limited, Retry-After: 60
Starts per account, per feature4 per minute429 rate_limited, Retry-After: 60
Runs per minute, runs in progress, engine capacity and daily processing, per account and featureset per feature429 ceiling_exceeded, with Retry-After (see Errors)
json
{"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: credits and usd (the price of this run), eta_s and queue_depth. For dubbing, quote is the total over all languages, with per_language when you asked for more than one.
  • The job carries charged_credits once 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:

json
{
  "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
FieldTypeDefaultDescription
modelstringrequired"fotohub-motion-audio", the only model on this route
request_idUUIDrequiredSee Idempotency
promptstringrequiredWhat happens in the clip. Plain text, at most 2000 characters
imagestringnoneFirst 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
durationinteger5Seconds. Only 5 is available
resolutionstring"720p"Only "720p" is available
seedintegernone0 to 2^32 - 1
quote_creditsnumbernoneSee 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).

bash
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"
  }'
python
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"])
typescript
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:

jsonc
{
  "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
FieldTypeDefaultDescription
request_idUUIDrequiredSee Idempotency
consentobjectrequiredThe consent declaration, see below
portraitstringrequiredphotos/<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
audiostringnonemode: "audio": your speech in your storage, WAV or MP3, at most 25 MB and at most 5 seconds (at least 0.5 s)
scriptstringnonemode: "script": what the avatar says, at most 80 characters (about 5 seconds of speech)
language"pl" | "en" | "de""pl"Language of the script
voice_refstringnonemode: "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_consentbooleanfalseRequired (true) with voice_ref: the voice is yours, or of a person who agreed
promptstringnoneOptional style or scene hint, at most 500 characters
gesture"natural" | "calm" | "expressive""natural"
seedintegernone0 to 2^32 - 1
quote_creditsnumbernoneSee 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.

json
"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, pl or de. sha256 is optional; when you send it, it must match the text of that locale.
  • Without consent, or with an outdated version, the answer is 403 consent_required and its consent field carries the current declaration: version, subjects, residency, retention_years and texts ({"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 is 413 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 is 422 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.

bash
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"}
  }'
python
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"])
typescript
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
FieldTypeDefaultDescription
request_idUUIDrequiredSee Idempotency
sourcestringrequiredYour 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
languagesstring[]required1 to 5 target languages, one job (and one charge) each
source_languagestringautoThe spoken language; omit or "auto" to detect it
glossarystring[][]Names and terms to keep exactly as written: at most 30 terms of at most 60 characters
voice_refstringnoneYour own voice recording to clone for every speaker (WAV, MP3, M4A, OGG or FLAC, at most 10 MB). Needs voice_consent
voice_consentobjectnone{"version": "dub-voice-2026-10-05.1", "locale": "en" | "pl" | "de", "sha256": "<optional>"}
reviewfalsefalseAlways false on the API: the job runs through without pausing for a translation review
quote_creditsnumbernoneThe 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).

bash
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"]
  }'
python
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"])
typescript
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:

jsonc
{
  "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).

jsonc
{
  "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[].name is 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 example generation_failed) and error.message, and refunded: true once 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, the 403 consent refusals, the 413 and 422 input and content-review refusals, 422 invalid_portrait, and 503 temporarily_unavailable / moderation_unavailable raised by the feature itself.
  • Without charged: 401 api_key_required, 403 feature_disabled, the read-only-key 403, 409 price_changed, 422 and 429 ceiling_exceeded, 429 busy, and the 503 temporarily_unavailable that carries a reason (from the price check). Their message still 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.

StatuserrorWhen
401api_key_requiredA dashboard session instead of an API key
402insufficient_fundsThe wallet cannot cover the run (see 402 Payment Required)
403feature_disabledThe feature is not available to your account (reason, and min_tier when a plan is needed)
403consent_required, voice_consent_requiredAvatar consent missing or outdated; a voice sample without consent. The current declaration is in consent
403(string)Read-only API key
409price_changedquote_credits is stale (see above)
409idempotency_key_reusedThe request_id was already used for a different kind of job (job_id names it)
413input_too_large, image_too_large, portrait_too_largeA file over its limit (limit_bytes)
422invalid_portraitAvatar: the portrait cannot be read, is not PNG, JPEG or WebP, or a side is under 64 px or over 4096 px
422invalid_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_capThe request cannot run as sent; field names the input
422content_policy, content_blocked, minor_not_allowed, public_figure_not_allowedThe text or image was refused by the content review
422ceiling_exceededThe run is above the per-run limit (limit, credits)
429rate_limitedSee Rate limits
429busyThe feature is busy right now (retry_after_s, queue_depth, Retry-After)
429ceiling_exceededA 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
503temporarily_unavailable, moderation_unavailable, capability_unavailableTry 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.

  • 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