Inkaai Avatar API — v1

Generate a talking-head video from a reference photo + audio. Supports single-speaker and 2-speaker (podcast) avatars.

Base URL: https://inkaai.com


Authentication

All /v1/avatar* requests require an API key in the Authorization header:

Authorization: Bearer inkaa_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Generate keys from your dashboard at https://inkaai.com/studio → API Keys. Each key is tied to your account and inherits your shared credit balance.

If a key is invalid or revoked: HTTP 401 Unauthorized.


Pricing

ResolutionCredits / minPrice / min
480p10.00$2.00
720p12.00$2.40

Duration is taken from the audio you supply, so the cost is computable before you submit. No subscription, no minimum commitment, no per-avatar setup fee.

Check your balance and spend with GET /v1/usage — it is authenticated by your API key, reports per-key usage, and cannot be pointed at another account.


Endpoints

POST /v1/avatar

Submit a job. Returns immediately with a job_id you can poll.

Headers

Authorization: Bearer inkaa_sk_xxx
Content-Type: application/json

Body (single-speaker)

{
  "image_url": "https://your.cdn/portrait.jpg",
  "audio_url": "https://your.cdn/speech.wav",
  "prompt": "A calm friendly host in a podcast studio.",
  "resolution": "480p",
  "project_name": "Episode 12"
}

Body (2-speaker podcast)

{
  "image_url": "https://your.cdn/two_people.jpg",
  "person1_audio_url": "https://your.cdn/host.wav",
  "person2_audio_url": "https://your.cdn/guest.wav",
  "prompt": "Two people in a podcast studio, warm lighting.",
  "resolution": "480p"
}

Field reference

The photo — send exactly one of image_url or library_id.

FieldTypeDefaultNotes
image_urlstring (https URL)jpg/png. Max 20MB. For 2-speaker: both faces in shot, person1=left, person2=right.
library_idstringId from POST /v1/library. Use this when the photo cannot be exposed on a public URL.

The speech — send text (we synthesize it), or audio_url (you supply it), or the two person*_audio_url fields for a 2-speaker shot.

FieldTypeDefaultNotes
textstringWhat the avatar should say; we synthesize it. Max 5000 chars. Mutually exclusive with audio_url.
audio_urlstringReady-made speech you supply. Mutually exclusive with text. Max 100MB.
person1_audio_urlstringLeft speaker audio. Use silence where that person isn't talking.
person2_audio_urlstringRight speaker audio.
voice_sample_urlstring5–30 s https audio of a voice to clone for text. Omit for a default voice. Only valid with text.
languagestring (2-letter)"en"ISO code for texten, hi, ta, te, es, fr, …
voice_gendermale | female | neutral"neutral"Default-voice gender. Ignored when voice_sample_url is given.
emotionstring"neutral"Delivery hint for text: neutral, happy, sad, angry.

Everything else

FieldTypeDefaultNotes
promptstring""Optional scene description. Leave blank for default. See Prompt and seed below.
seedinteger (0 – 2147483647)Fixes the random seed so the same request reproduces the same take. Omit for a new variation each time.
resolution"480p" | "720p""480p"Output quality. 720p is 2.25× the pixels of 480p and about 2.6× the wall time (measured). Independent of orientation.
aspect_ratio"16:9" | "9:16"matches your photoOptional. Omit it and we use your photo's own orientation — recommended.
callback_urlstring (https URL)We POST the finished job here so you don't have to poll. See Webhooks below.
project_namestringOptional label for your tracking.

Response 200

{
  "job_id": "a1b2c3d4e5f6",
  "status": "queued",
  "audio_duration_s": 32.45,
  "credits_deducted": 6.0844,
  "remaining_balance": 94.59,
  "mode": "single",
  "status_url": "/v1/avatar/a1b2c3d4e5f6",
  "result_url": "/v1/avatar/a1b2c3d4e5f6/result"
}

Errors

CodeMeaning
400Bad input — invalid URL, both audio modes specified, zero-duration audio, etc.
401Missing or invalid API key
402Insufficient credits
413Audio longer than the limit for the chosen resolution (180 s at 480p, 120 s at 720p), or asset > 100MB (audio) / > 20MB (image)
429More than 2 jobs already in flight for this key. See Retry-After.
500Server error — credits NOT charged

GET /v1/avatar/{job_id}

Poll for job status.

Headers: Authorization: Bearer inkaa_sk_xxx

Response 200

{
  "job_id": "a1b2c3d4e5f6",
  "status": "running",
  "terminal": false,
  "message": "Generating…",
  "pct": 50,
  "audio_duration_s": 32.45,
  "wall_time_s": null,
  "error": null,
  "error_code": null,
  "permanent": null,
  "result_url": null
}

message is a human-readable progress note whose wording is not part of the contract — branch on status / terminal, never on message.

Status lifecycle — this is the complete enumeration. Nothing else is ever returned.

statusterminalmeaning
queuednoaccepted, waiting for a generation slot
runningnogenerating; pct advances 0→100
doneyesresult_url is available
failedyessee error_code / error; credits already refunded
cancelledyesyou called DELETE; credits refunded

The payload also carries a terminal boolean, so a poll loop can stop without hardcoding the set above.

On failure the payload adds:

asset_unreachable, insufficient_credits, backend_timeout, backend_unavailable, backend_capacity, generation_failed.

input); false means transient and a retry may work.

Polling tips

realtime and a 60-second clip commonly takes ~25 minutes.


DELETE /v1/avatar/{job_id}

Cancel a queued or running job and refund the credits taken at submission.

Headers: Authorization: Bearer inkaa_sk_xxx

Response 200

{
  "job_id": "a1b2c3d4e5f6",
  "status": "cancelled",
  "terminal": true,
  "cancelled": true,
  "credits_refunded": 22.5,
  "message": "Job cancelled and credits refunded."
}

Cancelling an already-terminal job is a no-op, not an error: it returns cancelled: false with the job's current status. Best-effort — work already dispatched may still finish on the backend, but the result is not delivered and you are not charged.


GET /v1/avatar/{job_id}/result

Download the finished mp4. Streams video/mp4 bytes.

Headers: Authorization: Bearer inkaa_sk_xxx

Response 200: mp4 bytes (Content-Type: video/mp4)

Response 404: job not done yet, or failed.


POST /v1/library — store a photo

Free and instant. Nothing is generated and nothing is charged; this only stores the image so you can reference it by id instead of hosting it on a public URL. Useful when the photo is personal, clinical, or otherwise not something you can put on the open web.

multipart/form-data with two parts:

PartTypeNotes
imagefilejpg/png, max 20MB
namestringYour label for it, max 80 characters
curl -X POST https://inkaai.com/v1/library \
  -H "Authorization: Bearer inkaa_sk_..." \
  -F "image=@portrait.jpg" -F "name=Dr Rao"
{ "library_id": "6b16b4776642", "status": "ready", "credits_deducted": 0 }

Then pass library_id wherever you would have passed image_url. The photo stays available until you delete it, and can be reused across any number of generations.

GET /v1/library — list your stored photos

{ "entries": [ { "library_id": "6b16b4776642", "name": "Dr Rao",
                 "status": "ready", "created_at": "2026-09-06T14:58:10" } ] }

GET /v1/library/{library_id} — one entry

Returns the same shape as a single list entry. 404 if the id does not exist or belongs to another account — ids are scoped to the API key that created them.

DELETE /v1/library/{library_id} — delete a stored photo

{ "library_id": "6b16b4776642", "status": "deleted" }

Errors (all library endpoints)

CodeMeaning
400Missing or empty image / name, or a name over 80 characters
401Missing or invalid API key
404No such entry for this account
413Image larger than 20MB

GET /v1/usage

Balance, spend and job counts for your account, broken down by API key.

Credits are pooled per account: one login can hold several keys and they all draw on the same balance. This endpoint answers which key is spending it — useful when you run separate keys for staging, production and CI.

Query: days (1–365, default 30)

curl "https://inkaai.com/v1/usage?days=30" -H "Authorization: Bearer inkaa_sk_..."
{
  "balance_credits": 8914.5765,
  "rate_credits_per_min": 10.00,
  "rates": { "480p": {"credits_per_min": 10.0, "usd_per_min": 2.0},
             "720p": {"credits_per_min": 12.0, "usd_per_min": 2.4} },
  "credits_used": 40.86,
  "credits_refunded": 0.0,
  "credits_net": 40.86,
  "approx_usd": 8.17,
  "jobs": 4,
  "by_api_key": [
    { "key_prefix": "inkaa_sk_443af", "credits_used": 1.48, "credits_refunded": 0.0,
      "credits_net": 1.48, "jobs": 2 }
  ],
  "calling_key_prefix": "inkaa_sk_443af"
}

credits_used is gross; failed and cancelled jobs are paid back and appear in credits_refunded, so credits_net is what you actually spent. Usage recorded before per-key attribution existed is grouped under key_prefix: "unknown" rather than dropped, so the per-key figures always add up to the account total.


Webhooks

Rather than polling, set callback_url on the submit and we will POST to it once the job reaches a terminal state (done, failed or cancelled).

(10 s timeout per attempt).

global address, so localhost, private ranges and link-local addresses are rejected at submit time with a 400 — the job is not created.

Headers we send

HeaderValue
User-AgentInkaai-Webhook/1
X-Inkaa-Eventavatar.job.terminal
X-Inkaa-Job-Idthe job id
X-Inkaa-Signaturesha256=<hex> — see below

Verifying the signature. The signature is an HMAC-SHA256 over the raw request body, keyed on your API key. Compare it against the header before trusting the payload, and use a constant-time comparison.

import hmac, hashlib

def verify(raw_body: bytes, header: str, api_key: str) -> bool:
    expected = "sha256=" + hmac.new(api_key.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")

Sign over the bytes exactly as received — re-serializing the JSON first will change the payload and the signature will not match.


Prompt and seed

prompt describes the scene, not the person's mouth. Say where they are and what the shot looks like — "a calm clinic room, soft daylight". Leave it blank and you get a sensible default. Avoid describing a fixed facial expression: phrases like "neutral expression" ask for a still face and fight the speech animation.

How much prompt actually moves the output: not much, today. Generation is strongly anchored to your photo. In our own A/B — same photo, same text, same seed, with and without a scene prompt — the outputs were not visibly different, and an explicit request to add a person to the background produced no extra person. Treat prompt as a weak, best-effort hint rather than a control: it will not relocate the subject or restage the shot. The upside is the guarantee that follows from it — we do not invent people or objects that are not in your photo, even when a prompt asks for them. If you need a different setting, change the photo. Strengthening prompt adherence without weakening that guarantee is open work.

seed fixes the random draw, so the same request re-run gives the same take. Use it when you want a regeneration to look like the previous one rather than a fresh variation.

Same seed gives you the same take on the same backend. Two separate submissions are visually equivalent but are not guaranteed to be byte-identical MP4s, and the backend cannot be pinned through the API — so do not build a check that compares md5 across runs.

End-to-end example (single-speaker, curl)

# 1. Submit
JOB=$(curl -sX POST https://inkaai.com/v1/avatar \
  -H "Authorization: Bearer inkaa_sk_$KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "image_url": "https://cdn.example.com/portrait.jpg",
    "audio_url": "https://cdn.example.com/intro.wav",
    "resolution": "480p"
  }' | jq -r .job_id)

# 2. Poll
while true; do
  S=$(curl -s "https://inkaai.com/v1/avatar/$JOB" \
    -H "Authorization: Bearer inkaa_sk_$KEY")
  STATUS=$(echo "$S" | jq -r .status)
  echo "[$STATUS] $(echo $S | jq -r .message)"
  [[ "$(echo "$S" | jq -r .terminal)" = "true" ]] && break
  sleep 30
done

# 3. Fetch result
curl -L "https://inkaai.com/v1/avatar/$JOB/result" \
  -H "Authorization: Bearer inkaa_sk_$KEY" \
  -o avatar.mp4

Quality + technical notes

both qualities are available in both orientations:

16:9 horizontal9:16 vertical
480p832×480480×832
720p1280×704704×1280

720p is 2.25× the pixels of 480p and takes about 2.6× as long (measured).

almost always what you want. The photo is centre-cropped to fill the frame, so forcing the wrong orientation is costly: a vertical photo rendered 16:9 keeps only ~41% of its height and the face becomes a horizontal band. Set it explicitly only when you deliberately want a shape your photo is not.

16:99:16
480p42.4 s45.0 s
720p94.1 s98.8 s

Cost scales with pixel count, so a 720p job takes roughly twice as long as the same clip at 480p. See the Latency section before setting timeouts.

ingest and emitted as 24 kHz mono AAC. If your speech is quality-critical (e.g. a cloned voice), keep your master and re-mux it over our video.

same request produces a visibly different take.


Latency

Generation runs well above realtime. These are measured end to end on current hardware, not projections — replacing an earlier table that ran optimistic:

Audio480p typical× realtime
15 s~2 min 20 s9.3×
30 s~9 min17.8×
60 s~17 min 30 s17.6×
90 s~23 min 30 s15.7×
120 s~25 min12.4×

720p runs about 2.6× the 480p wall time at the same length.

There is a step at ~15 s. Clips up to about 15 s are produced in a single pass and run near 9–10× realtime. Longer clips are generated in segments and stitched, which costs roughly twice the per-second time — so a 30 s clip is closer to four times the wall time of a 15 s one, not twice. Size timeouts from the table, not by scaling the 15 s figure.

Run-to-run spread is roughly ±20%, and these were measured on shared hardware, so treat them as typical rather than guaranteed. Queue wait is separate and depends on how many of your 2 concurrent slots are busy.

Open work item. Bringing the segmented path closer to the single-pass rate is active work. We would rather publish what we measure today than a target we have not hit; expect these numbers to improve, and we will say so when they do.

Maximum audio per job depends on resolution, and the constraint is GPU occupancy rather than a timeout — jobs run in series, so a long clip holds a GPU:

resolutionmax audioa full-length job holds a GPU for
480p180 s~25–30 min
720p120 s~60 min

Exceeding it returns 413, and the message names the resolution so the rejection is never ambiguous. A job is abandoned and marked failed if generation exceeds 90 minutes, which no permitted length approaches.


SDK examples

Python (requests)

import requests, time

API_KEY = "inkaa_sk_..."
BASE = "https://inkaai.com"

def submit(image_url, audio_url, **opts):
    r = requests.post(
        f"{BASE}/v1/avatar",
        headers={"Authorization": f"Bearer {API_KEY}"},
        json={"image_url": image_url, "audio_url": audio_url, **opts},
    )
    r.raise_for_status()
    return r.json()

def wait_and_download(job_id, out_path):
    while True:
        s = requests.get(
            f"{BASE}/v1/avatar/{job_id}",
            headers={"Authorization": f"Bearer {API_KEY}"},
        ).json()
        print(s["status"], s.get("message", ""))
        if s["status"] == "done":
            break
        if s["status"] == "failed":
            raise RuntimeError(f'{s.get("error_code")}: {s.get("error")} (permanent={s.get("permanent")})')
        if s["status"] == "cancelled":
            raise RuntimeError("job was cancelled")
        time.sleep(30)
    r = requests.get(
        f"{BASE}/v1/avatar/{job_id}/result",
        headers={"Authorization": f"Bearer {API_KEY}"},
        stream=True,
    )
    with open(out_path, "wb") as f:
        for chunk in r.iter_content(1 << 16):
            f.write(chunk)

j = submit(
    "https://cdn.example.com/portrait.jpg",
    "https://cdn.example.com/intro.wav",
    resolution="480p",
)
wait_and_download(j["job_id"], "avatar.mp4")

Node.js (fetch)

const KEY = "inkaa_sk_...";
const BASE = "https://inkaai.com";
const auth = { Authorization: `Bearer ${KEY}` };

const submit = async (body) => {
  const r = await fetch(`${BASE}/v1/avatar`, {
    method: "POST",
    headers: { ...auth, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!r.ok) throw new Error(await r.text());
  return r.json();
};

const poll = async (jobId) => {
  while (true) {
    const r = await fetch(`${BASE}/v1/avatar/${jobId}`, { headers: auth });
    const s = await r.json();
    console.log(s.status, s.message);
    if (s.status === "done") return s;
    if (s.status === "failed") throw new Error(`${s.error_code}: ${s.error}`);
    if (s.status === "cancelled") throw new Error("job was cancelled");
    await new Promise((res) => setTimeout(res, 30_000));
  }
};

const download = async (jobId, dest) => {
  const fs = await import("node:fs");
  const r = await fetch(`${BASE}/v1/avatar/${jobId}/result`, { headers: auth });
  fs.writeFileSync(dest, Buffer.from(await r.arrayBuffer()));
};

const job = await submit({
  image_url: "https://cdn.example.com/portrait.jpg",
  audio_url: "https://cdn.example.com/intro.wav",
  resolution: "480p",
});
await poll(job.job_id);
await download(job.job_id, "avatar.mp4");

Limits + quotas

LimitValueEnforced
Concurrent jobs per API key2 queued or running429 + Retry-After: 60
Max audio duration180 s at 480p, 120 s at 720p413
Max audio file size100 MB413
Max image size20 MB413
Max text length5 000 chars422
Submission rate limitnone beyond the concurrency cap

At 2 concurrent, a batch of twenty 90-second clips completes in roughly 6 hours. Raising the cap does not make any individual job faster.

Idempotency. Send an Idempotency-Key header on POST /v1/avatar. Repeating a request with the same key within 24 hours replays the original response instead of creating a second job — so a lost response never means paying twice.

Job retention. Results currently remain retrievable indefinitely; there is no automatic deletion. We do not yet offer a contractual retention window, so download promptly and keep your own copy if you need one.

Fetching your URLs. We fetch image_url / audio_url synchronously during your submit call, so they only need to be reachable for the duration of that request. Requests originate from our application servers, whose egress addresses are not static — we cannot currently supply a stable IP range to allowlist.


Not currently supported

Stated plainly so you can design around them:

used to guarantee that nothing extra is rendered into the frame.

wording grounds.


Support

API key compromised? Revoke immediately via dashboard. Balance is not affected.

API key compromised? Revoke immediately via dashboard. Balance is not affected.