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
- Rate — by output resolution, per minute of video (which equals the audio you send):
| Resolution | Credits / min | Price / min |
|---|---|---|
480p | 10.00 | $2.00 |
720p | 12.00 | $2.40 |
- 1 credit = $0.20
- Charged on submit. Refunded automatically if the job fails or is cancelled.
- Min charge: rounded to 4 decimal places of a credit.
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.
| Field | Type | Default | Notes |
|---|---|---|---|
image_url | string (https URL) | — | jpg/png. Max 20MB. For 2-speaker: both faces in shot, person1=left, person2=right. |
library_id | string | — | Id 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.
| Field | Type | Default | Notes |
|---|---|---|---|
text | string | — | What the avatar should say; we synthesize it. Max 5000 chars. Mutually exclusive with audio_url. |
audio_url | string | — | Ready-made speech you supply. Mutually exclusive with text. Max 100MB. |
person1_audio_url | string | — | Left speaker audio. Use silence where that person isn't talking. |
person2_audio_url | string | — | Right speaker audio. |
voice_sample_url | string | — | 5–30 s https audio of a voice to clone for text. Omit for a default voice. Only valid with text. |
language | string (2-letter) | "en" | ISO code for text — en, hi, ta, te, es, fr, … |
voice_gender | male | female | neutral | "neutral" | Default-voice gender. Ignored when voice_sample_url is given. |
emotion | string | "neutral" | Delivery hint for text: neutral, happy, sad, angry. |
Everything else
| Field | Type | Default | Notes |
|---|---|---|---|
prompt | string | "" | Optional scene description. Leave blank for default. See Prompt and seed below. |
seed | integer (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 photo | Optional. Omit it and we use your photo's own orientation — recommended. |
callback_url | string (https URL) | — | We POST the finished job here so you don't have to poll. See Webhooks below. |
project_name | string | — | Optional 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
| Code | Meaning |
|---|---|
| 400 | Bad input — invalid URL, both audio modes specified, zero-duration audio, etc. |
| 401 | Missing or invalid API key |
| 402 | Insufficient credits |
| 413 | Audio longer than the limit for the chosen resolution (180 s at 480p, 120 s at 720p), or asset > 100MB (audio) / > 20MB (image) |
| 429 | More than 2 jobs already in flight for this key. See Retry-After. |
| 500 | Server 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.
status | terminal | meaning |
|---|---|---|
queued | no | accepted, waiting for a generation slot |
running | no | generating; pct advances 0→100 |
done | yes | result_url is available |
failed | yes | see error_code / error; credits already refunded |
cancelled | yes | you 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:
error_code— machine-readable. One ofimage_too_large,audio_too_large,asset_too_large,unreadable_audio,unreadable_asset,
asset_unreachable, insufficient_credits, backend_timeout, backend_unavailable, backend_capacity, generation_failed.
permanent—truemeans retrying the identical request cannot succeed (bad
input); false means transient and a retry may work.
Polling tips
- Poll every 15–30 seconds. Polling faster does not make a job finish sooner.
pctis always an integer 0–100, never null.- See the Latency table below before setting your timeouts — jobs run well above
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:
| Part | Type | Notes |
|---|---|---|
image | file | jpg/png, max 20MB |
name | string | Your 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)
| Code | Meaning |
|---|---|
| 400 | Missing or empty image / name, or a name over 80 characters |
| 401 | Missing or invalid API key |
| 404 | No such entry for this account |
| 413 | Image 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).
- The body is identical to
GET /v1/avatar/{job_id}— same fields, same shapes. - Delivered at most once per job. Up to 3 attempts if your endpoint errors or times out
(10 s timeout per attempt).
- Must be a public https URL. We resolve the host and refuse anything that is not a
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
| Header | Value |
|---|---|
User-Agent | Inkaai-Webhook/1 |
X-Inkaa-Event | avatar.job.terminal |
X-Inkaa-Job-Id | the job id |
X-Inkaa-Signature | sha256=<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 muchpromptactually 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. Treatpromptas 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
- Lip sync: output runs at 24 fps. The model handles natural pauses, fillers, and breathing.
- 2-speaker positioning: person1 = left in image, person2 = right. Each track plays continuously; mute the non-speaker's track for cleaner edits.
- Image guidance: front-facing portrait works best. Side angles work but get more drift.
resolutionis quality;aspect_ratiois shape. They are independent, so
both qualities are available in both orientations:
16:9 horizontal | 9:16 vertical | |
|---|---|---|
480p | 832×480 | 480×832 |
720p | 1280×704 | 704×1280 |
720p is 2.25× the pixels of 480p and takes about 2.6× as long (measured).
- Leave
aspect_ratiounset and we match your photo's orientation. That is
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.
- Measured on our current hardware, 5 s of audio, same photo:
16:9 | 9:16 | |
|---|---|---|
480p | 42.4 s | 45.0 s |
720p | 94.1 s | 98.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.
- Output is H.264, 24 fps with AAC 24 kHz mono audio.
- Your audio is re-encoded. Supplied audio is normalized to 44.1 kHz PCM on
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.
- No seed parameter, so generation is not deterministic — regenerating the
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:
| Audio | 480p typical | × realtime |
|---|---|---|
| 15 s | ~2 min 20 s | 9.3× |
| 30 s | ~9 min | 17.8× |
| 60 s | ~17 min 30 s | 17.6× |
| 90 s | ~23 min 30 s | 15.7× |
| 120 s | ~25 min | 12.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:
resolution | max audio | a full-length job holds a GPU for |
|---|---|---|
480p | 180 s | ~25–30 min |
720p | 120 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
| Limit | Value | Enforced |
|---|---|---|
| Concurrent jobs per API key | 2 queued or running | 429 + Retry-After: 60 |
| Max audio duration | 180 s at 480p, 120 s at 720p | 413 |
| Max audio file size | 100 MB | 413 |
| Max image size | 20 MB | 413 |
Max text length | 5 000 chars | 422 |
| Submission rate limit | none 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:
- No completion webhook — polling is the only option.
- No seed / determinism — regenerating produces a different take.
- No negative prompt or constrained mode —
promptis advisory and cannot be
used to guarantee that nothing extra is rendered into the frame.
- No content classifier on supplied text or audio — nothing is rejected on
wording grounds.
- No published uptime target or status page.
Support
- Issues: social@inkaai.com
- Service desk: studio dashboard → Help
API key compromised? Revoke immediately via dashboard. Balance is not affected.
API key compromised? Revoke immediately via dashboard. Balance is not affected.