# bitHuman — full documentation > Private, on-device, real-time lip-synced AI avatar platform. Push 16-bit PCM audio in, drain 25 FPS lip-synced video frames out — fully on-device (macOS/Linux/iOS, CPU incl. Raspberry Pi, NVIDIA GPU, or Apple Silicon) or via a cloud REST API. Private by design: audio, video, and prompts never leave your hardware; the only network call is a ~1-request-per-minute billing heartbeat, so it self-hosts on-prem, at low per-minute cost. That heartbeat is required — the runtimes are fail-closed without a valid key. Models (second generation, generally available since July 10, 2026): `expression-2` (audio-driven real-time avatar video from a single photo — best for cartoon/animal/creature/robot characters; GPU / Apple / CPU cloud chain, local rendering via the CLI), `essence-2` (the STANDARD photoreal model; served from the GPU / Apple / CPU cloud chain, rendered locally on your own Mac (Apple Silicon) or Linux x86_64 box by the CLI 2.6.1+ — `bithuman pull --model essence-2` then `bithuman render .imx -a speech.wav -o out.mp4`, offline, runtime inside the CLI, the shared audio encoder (~377 MB) fetched once on first render into ~/.bithuman/engines/essence-2/, an incomplete model file refused with exit 69 and no output — from your own CPU servers (Python SDK), on Android (AAR), and opt-in in the viewer's browser (WebGPU/WASM); the former essence-2-light name is retired), plus `essence-1` / `expression-1`. This file concatenates the entire docs site for ingestion. Curated index: https://docs.bithuman.ai/llms.txt · OpenAPI: https://docs.bithuman.ai/api/openapi.yaml --- # EU AI Act — Article 50 transparency URL: https://docs.bithuman.ai/legal/eu-ai-act > **This is our reading of the rules, not legal advice.** Nothing here tells you > that your product is compliant. If you ship into the EU, take your own advice. The EU AI Act's transparency rules — **Article 50** — begin applying on **2 August 2026**. They cover systems that talk to people, and content that is generated or manipulated by AI. Both describe what you build with bitHuman. The single most important thing on this page: **the visible "this is AI" disclosure is the deployer's obligation, not the provider's.** If you build on bitHuman, that means it is yours, not ours. Most of the surprise people have with Article 50 comes from assuming their vendor carries it. ## Who is who The Act splits duties between the party that puts an AI system on the market and the party that uses it. The European Commission's own FAQ addresses the case directly: a company selling an AI avatar API is the **provider**, and the customer using that API is the **deployer**. | | Party | What that means here | |---|---|---| | **Provider** | bitHuman | We place `essence-2` and `expression-2` on the market under our own name. | | **Deployer** | You | You use them under your authority, in your product, in front of your users. | Your own employees acting under your instruction are not separate deployers — your company remains the deployer. Contractors operating the system under your responsibility and control do not create a second deployer either. ## The split, and the two dates | Article | Obligation | Whose | From | |---|---|---|---| | **50(1)** | A system that interacts directly with people must be built so those people are informed they are interacting with an AI. | **Provider** (bitHuman) | **2 Aug 2026**, no grace | | **50(4)** | Visible disclosure that content is artificially generated or manipulated, for deepfake-class output. | **Deployer** (you) | **2 Aug 2026**, no grace | | **50(2)** | Machine-readable marking of generated output, detectable as artificial. | **Provider** (bitHuman) | **2 Dec 2026** — see below | **Why 50(2) has a later date.** Systems placed on the market before 2 August 2026 fall inside a transitional arrangement and have until **2 December 2026** to meet the marking obligation. Both `essence-2` and `expression-2` went GA on 10 July 2026, so that is the clock we are on. Content generated before 2 August 2026 does not need to be labelled retroactively. ## What bitHuman does — and does not — ship today We would rather write this down than let you assume coverage you do not have. - **Machine-readable marking: we do not ship it.** No output from either model carries a watermark, provenance metadata, or a C2PA manifest today. December 2 is the date we are working to. Treat it as a target, not a feature you can plan around. - **In-session AI disclosure (50(1)): we do not show one, and that is a position rather than a gap.** Neither the bitHuman-hosted viewer nor the embed iframe displays a "you are speaking with an AI" notice. We built one, reviewed it on a real session, and removed it: on a product whose premise is a visibly synthetic avatar that the user chose to open, it read as noise. We rely on the Article 50(1) carve-out for cases where the interaction is already obvious — while noting on this same page that the Commission reads that exception narrowly. Do not plan around it changing. If you build on the SDKs, the CLI, or a self-hosted deployment, **we render frames and nothing else** — the surface that could carry a notice is yours by construction. Either way: if you need a disclosure on the page, put it there yourself and do not assume ours. - **Running on your own hardware changes none of this.** On-device and self-hosted serving is the right answer for privacy. It is not an Article 50 answer. If anything it makes marking harder: a pinned old runtime will keep producing unmarked output, so plan to update before December. ## Which output is "deepfake-class"? This decides whether your 50(4) obligation bites. Article 3(60) sets three conditions, all of which must hold. The middle one is broader than most people expect: 1. **Resemblance** — it closely resembles the thing it depicts; 2. **Existence** — the subject *exists, can plausibly exist, or could plausibly have existed*; 3. **False authenticity** — it would falsely appear authentic to a person. Our reading, applied to the two models: - **`essence-2` output is deepfake-class — including for people who never existed.** A synthetic photorealistic human "can plausibly exist", which satisfies the second condition on its own. Do not assume an invented face is outside the rule. - **`expression-2` output generally is not.** A cartoon, animal, creature, or object with a face fails the resemblance and false-authenticity conditions. Evidently artistic, creative, satirical or fictional work also reaches a narrower carve-out, where the disclosure need only be made in a way that does not spoil the work. **The trap: the test follows the content, not the model name.** Drive a photorealistic human likeness through `expression-2` and it is assessed exactly as `essence-2` output would be. ## What a deployer actually has to do - Disclose **at first exposure**, in a **clear and distinguishable** manner. - You **cannot** discharge it by pointing at machine-readable marking — ours or anyone's. That is a different obligation, on a different party, aimed at detection tooling rather than at people. - The disclosure has to meet accessibility requirements, so a purely visual cue is not enough on its own. - The "unless it is obvious" exception in 50(1) is interpreted **restrictively**, judged against a reasonably well-informed, circumspect and observant person. A photorealistic avatar that speaks and responds is not obvious. Do not rely on it. ## Code of Practice The Commission's **Code of Practice on Transparency of AI-Generated Content** is a voluntary instrument for demonstrating compliance with Article 50(2), (4) and (5). It is not mandatory — but non-signatories are expected to demonstrate compliance by other means and can face higher information requests. ## Penalties Non-compliance with the transparency obligations can attract fines of up to **€15,000,000 or 3% of worldwide annual turnover**, whichever is higher. ## Primary sources Read these rather than trusting a summary — including this one. - [Transparency obligations under Article 50 — European Commission FAQ](https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act) - [Article 50 — full text](https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-50) - [Code of Practice on Transparency of AI-generated Content](https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content) - [Guidelines on transparency obligations](https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content) *Last reviewed 1 August 2026. We are not lawyers, this is not legal advice, and nothing here establishes that your deployment is compliant.* --- # FFmpeg / LGPL — the Android relink offer URL: https://docs.bithuman.ai/legal/android-ffmpeg-lgpl > **This is an engineering reading of the licence, not legal advice.** It > describes what the shipped artifact does and how the obligation is > discharged. If you redistribute our AAR inside your own product, take your > own advice about your own obligations. The essence-2 Android AAR links **FFmpeg 7.1 statically**. That triggers LGPL-2.1 **§6(a)**, and the materials that discharge it are published on Maven Central beside the AAR — no request to make, nobody to ask. **The offer:** ```text https://repo1.maven.org/maven2/ai/bithuman/essence2-android/0.5.2/essence2-android-0.5.2-relink.zip ``` Same group, same artifact, same version as the AAR — classifier `relink`, extension `zip`. Anyone who can download the library can download the materials. This page names `0.5.2`, Central's `` on 2026-09-11; the offer travels with every version, so every permanent AAR from `0.2.0` on names its own kit at the same shape of URL. --- ## Which artifact this applies to **Only `ai.bithuman:essence2-android`.** The other two Android artifacts do not carry FFmpeg, so no §6(a) obligation attaches to them and no relink kit is published for them. That is correct, not a gap: | Coordinate | FFmpeg linked in? | Relink offer | |---|---|---| | `ai.bithuman:essence2-android:0.5.2` | **yes** — statically, into `lible_jni.so` | **published** (below); `0.2.0` through `0.5.1` each carry their own kit at the same shape of URL | | `ai.bithuman:expression2-android:0.4.1` | no — it carries LiteRT (Apache-2.0) | none needed | | `ai.bithuman:sdk:2.3.6` | not audited on this page | — | Measured, with the two AARs side by side — the second command is the control that makes the first mean something: ```bash curl -fsSL -o essence2.aar https://repo1.maven.org/maven2/ai/bithuman/essence2-android/0.5.2/essence2-android-0.5.2.aar curl -fsSL -o expression2.aar https://repo1.maven.org/maven2/ai/bithuman/expression2-android/0.4.1/expression2-android-0.4.1.aar unzip -q -o essence2.aar jni/arm64-v8a/lible_jni.so -d e2 unzip -q -o expression2.aar jni/arm64-v8a/libexpr2jni.so -d x2 nm -D --defined-only e2/jni/arm64-v8a/lible_jni.so | grep -cE ' T (av_|avcodec_|sws_)' nm -D --defined-only x2/jni/arm64-v8a/libexpr2jni.so | grep -cE ' T (av_|avcodec_|sws_)' ``` ```text 618 0 rc=1 ``` 618 FFmpeg symbols **defined** inside the essence-2 library; zero in the expression-2 one. First run on Linux x86_64 on 2026-09-03 against the artifacts as published, and **re-run 2026-09-11 against Central's current `` on both sides — `essence2-android:0.5.2` and `expression2-android:0.4.1`** — with the same three lines of output. **The `rc=1` is the second `grep -c`, and it is the expected answer.** `grep` exits 1 when it matches nothing, so a count of zero and a non-zero exit are the same fact stated twice. If you wrap this in `set -e` the script stops here on the *correct* result — check the printed number, not the exit status. --- ## Why §6(a) and not §6(b) §6(b) — the "use a shared library already on the user's system" route — is not available here, and that is a fact about the linkage rather than a preference. FFmpeg is not a dependency of the library, it is **inside** it: ```bash readelf -d e2/jni/arm64-v8a/lible_jni.so | grep NEEDED ``` ```text 0x0000000000000001 (NEEDED) Shared library: [libonnxruntime.so] 0x0000000000000001 (NEEDED) Shared library: [libandroid.so] 0x0000000000000001 (NEEDED) Shared library: [liblog.so] 0x0000000000000001 (NEEDED) Shared library: [libm.so] 0x0000000000000001 (NEEDED) Shared library: [libc++_shared.so] 0x0000000000000001 (NEEDED) Shared library: [libdl.so] 0x0000000000000001 (NEEDED) Shared library: [libc.so] rc=0 ``` No `libav*`, no `libsw*`. Nothing to swap at run time, so the recipient's right to relink has to be served with materials — which is what §6(a) asks for. --- ## What is in the kit Fifteen files. Fetch it and check the count yourself (re-run 2026-09-11 on `0.5.2`: HTTP 200, 14,089,306 B, the same fifteen names): ```bash curl -fsSL -o relink.zip https://repo1.maven.org/maven2/ai/bithuman/essence2-android/0.5.2/essence2-android-0.5.2-relink.zip unzip -Z1 relink.zip | grep -v '/$' | wc -l ``` ```text 15 rc=0 ``` ```text essence2-android-0.5.2-relink/ ├── MANIFEST.json machine-readable summary + sha256 of every file ├── NOTICE.txt the same NOTICE that ships inside the AAR ├── README.md ├── LICENSE-LGPL-2.1.txt ├── ffmpeg/ │ ├── ffmpeg-7.1.tar.xz the complete corresponding source, 11,011,364 B │ ├── ffmpeg-7.1.tar.xz.sha256 │ ├── config.h what that build's ./configure produced │ ├── CONFIGURATION.txt the FFMPEG_CONFIGURATION string │ ├── PROVENANCE.txt │ └── build_ffmpeg_android.sh the provisioning script ├── objects/ │ └── lible_jni_relink.a the "work that uses the Library", as object code ├── link/ │ ├── link_command.txt the real link command, lifted from the build │ ├── link_command.raw.txt │ └── relink.sh substitutes your FFmpeg prefix and relinks └── verify/ └── undefined_ffmpeg_symbols.txt the 30-symbol surface your build must resolve ``` **No patches are applied to FFmpeg**, so "including whatever changes were used in the work" is the empty set — and you can check that rather than take it. ### The three checks worth running **1. The source is the real 7.1 release, unmodified.** ```bash mkdir -p rl && unzip -q -o relink.zip -d rl cd rl/essence2-android-0.5.2-relink/ffmpeg && sha256sum -c ffmpeg-7.1.tar.xz.sha256 ``` ```text ffmpeg-7.1.tar.xz: OK rc=0 ``` **2. The object archive defines no FFmpeg and leaves exactly the documented surface undefined.** This is what makes the relink possible: your FFmpeg supplies these, ours does not get baked in. ```bash cd rl/essence2-android-0.5.2-relink nm --undefined-only objects/lible_jni_relink.a | awk '{print $NF}' | sort -u > undef.txt nm --defined-only objects/lible_jni_relink.a | awk '{print $NF}' | sort -u > def.txt miss=0; dup=0 while read -r s; do grep -qx "$s" undef.txt || miss=$((miss+1)); grep -qx "$s" def.txt && dup=$((dup+1)); done \ < verify/undefined_ffmpeg_symbols.txt echo "listed=$(wc -l < verify/undefined_ffmpeg_symbols.txt) missing=$miss defined=$dup" grep -qx "av_zzz_not_a_symbol" undef.txt && echo "CONTROL FAILED" || echo "control fired" ar t objects/lible_jni_relink.a | wc -l ``` ```text listed=30 missing=0 defined=0 control fired 21 rc=0 ``` All 30 symbols in the shipped list really are undefined in the archive, none of them is defined by it, and the archive holds its 21 translation units. The `av_zzz_not_a_symbol` line is the negative control — without it, a `grep` that silently matched everything would print the same reassuring numbers. **3. The offer URL in the AAR is the one that resolves.** The commitment lives in the shipped bytes, not on this page: ```bash unzip -p essence2.aar META-INF/NOTICE.txt | grep -o 'https://repo1[^ ]*relink.zip' curl -o /dev/null -s -w '%{http_code}\n' -L "$(unzip -p essence2.aar META-INF/NOTICE.txt | grep -o 'https://repo1[^ ]*relink.zip')" curl -o /dev/null -s -w '%{http_code}\n' -L "https://repo1.maven.org/maven2/ai/bithuman/essence2-android/0.5.2/essence2-android-0.5.2-relinkX.zip" ``` ```text https://repo1.maven.org/maven2/ai/bithuman/essence2-android/0.5.2/essence2-android-0.5.2-relink.zip 200 404 rc=0 ``` The `relinkX` line is the control: it proves the 200 is the artifact and not Maven Central answering 200 to everything. --- ## What is not in the kit, and why that is allowed **Not included:** the Android NDK (the compiler) and Bionic — `libc`, `libm`, `libdl`, `liblog`, `libandroid`. §6 exempts "anything that is normally distributed … with the major components (compiler, kernel, and so on) of the operating system on which the executable runs". **Included by accompaniment:** `libonnxruntime.so` and `libc++_shared.so` are *not* covered by that exception, and they are not omitted — they ship in the same AAR at `jni/arm64-v8a/`, and `relink.sh` reads `libonnxruntime.so` out of it. **Not included, and not required:** bitHuman's own engine source. §6(a) asks for "the complete machine-readable 'work that uses the Library', as object code and/or source code" — object code is what is given, which is exactly what lets this obligation be met without publishing the engine. --- ## Status of the relink itself > **UNVERIFIED on this page.** Every command above was executed on Linux > x86_64 on 2026-09-03 and re-executed on 2026-09-11 against Central's current > `` artifacts. **`relink.sh` was > not run here** — it needs an Android NDK toolchain and an FFmpeg built for > `arm64-v8a`, neither of which exists on the machine that checked this page. > Treat the relink as *offered and materially complete* — which is what the > commands above establish — rather than as reproduced by us today. The AAR's own `NOTICE.txt` records that the materials were exercised end to end before publication: an FFmpeg built from the shipped tarball with a deliberate one-line modification was relinked into `lible_jni.so` by the shipped `relink.sh`, and the change was observed in rendered output on a handset. That is the artifact's claim, reproduced here as its claim. If you exercise the offer and it does not work, that is a bug in the offer and we want it: [hello@bithuman.ai](mailto:hello@bithuman.ai). --- ## Build facts, as recorded in `MANIFEST.json` | Field | Value | |---|---| | FFmpeg version | 7.1 | | FFmpeg licence, as `configure` selected it | LGPL version 2.1 or later | | `--enable-gpl` / `--enable-nonfree` | `0` / `0` | | Patches applied | `0` | | Upstream source | `https://ffmpeg.org/releases/ffmpeg-7.1.tar.xz` | | Source sha256 | `40973d44…7abe6` | | NDK | 28.0.13004108 | | Android API | 29 | | ABI | `arm64-v8a` | | FFmpeg symbols defined in `lible_jni.so` | 618 (re-counted in `0.5.2`'s `lible_jni.so`, 2026-09-11) | | FFmpeg symbols undefined in the relink archive | 30 | The other licence texts travel inside the AAR too — `META-INF/licenses/` carries `ffmpeg-7.1-COPYING.LGPLv2.1.txt`, `llvm-libcxx-LICENSE.txt` and `onnxruntime-1.26.0-LICENSE.txt`. See the [Android SDK page](/sdk/android) for the coordinates and what is measured about the artifact itself. --- # API Platform URL: https://docs.bithuman.ai/api/overview ## What the API does The bitHuman API lets you create, manage, and drive avatar agents from any programming language. Reach for it when you don't need a native SDK — backends, CI scripts, or platforms where Python or Swift aren't a fit. Everything is plain HTTPS + JSON. One header authenticates every request. ## Base URL ```text https://api.bithuman.ai ``` All endpoints are relative to this URL and require an `api-secret` header. [Get a free API secret →](https://www.bithuman.ai/developer/api-keys) ## Authentication Pass your API secret in the `api-secret` header on every request: ```http api-secret: YOUR_API_SECRET ``` Treat the secret like a password — never commit it to source control and never embed it in client apps. For browser-side embeds, mint a short-lived token with the [embed token flow](/api/embedding) instead. See [Authentication](/api/authentication) for the full model. ## What you can build - **Generate avatars** — turn a prompt, portrait, and voice sample into a new agent. See [Agents](/api/agents). - **Synthesize voice** — text-to-speech in 30+ languages with 10 built-in voices, plus an OpenAI-compatible drop-in. See [Text to Speech](/api/text-to-speech). - **Drive live sessions** — make a hosted agent speak or inject silent knowledge into an active room. See [Agents](/api/agents). - **Add gestures** — generate and toggle conversational animations. See [Dynamics](/api/dynamics). - **Ground agents in your docs** — ingest files and URLs into knowledge bases from code. See [Knowledge](/api/knowledge). - **Add realtime voice** — mint a browser client secret for OpenAI-Realtime sessions. See [Realtime](/api/realtime). - **Bring your own keys** — use your own LLM/STT/TTS provider keys. See [Providers](/api/providers). - **Render talking videos** — generate a finished mp4 of an agent speaking, from a text script or hosted audio. See [Video API](/api/video). - **Embed in any page** — mint a token and drop an iframe. See [Embedding](/api/embedding). - **Track credits** — read balance and per-mode minute estimates. See [Billing](/api/billing). - **Manage keys & teams** — rotate [API keys](/api/api-keys), watch [runtime sessions](/api/runtime-sessions), and run [organizations](/api/organizations) programmatically. - **Get notified** — register [webhooks](/api/webhooks) for signed `agent.ready` / `agent.failed` events instead of polling. - **Drive it from an AI agent** — every endpoint below is also exposed as a tool via the [MCP server](/guides/mcp-server), so Claude, Cursor, and other MCP clients can call bitHuman directly. ## How agents are identified Every endpoint identifies an agent by its **agent code** — a short string like `A80HVD8577`. You receive one when you [generate an agent](/api/agents), or find it in your [Library](https://www.bithuman.ai/#library) (click an agent to reveal the code). > **Note** Different endpoint paths use slightly different parameter names for > the same value: `{code}`, `{agent_code}`, or `{agent_id}`. They all expect the > same string — the agent code shown in your Library. ## Next steps - [Quickstart](/api/quickstart) — make your first API call and drive a live agent. - [Authentication](/api/authentication) — get an API secret and runtime tokens. - [Essence 2 & Expression 2](/concepts/models-v2) — the second-generation models and which to choose (`essence-2` / `expression-2` / `auto`). - [Essence vs Expression](/concepts/models) — the first-generation family comparison. - [API reference](/api/reference) — the interactive Scalar reference for every endpoint, with a live request console. - [Errors](/api/errors) and [Rate limits](/api/rate-limits) — the operational contract. - [MCP server](/guides/mcp-server) — call every endpoint as a tool from an AI agent. ## Status and versioning `v1` endpoints are generally available. Breaking changes ship under new path prefixes (`/v2/...`); new endpoints land additively without forcing migrations. Live API status is at [status.bithuman.ai](https://status.bithuman.ai). --- # API quickstart URL: https://docs.bithuman.ai/api/quickstart The fastest way to start: **pick an agent that already exists and embed it.** No generation, no polling, no credits to create one — a live, talking avatar in two steps. (Want your own custom face later? See [Generate your own agent](#generate-your-own-agent-optional).) ## 1. Pick an agent Every agent has a short **code** like `A78WKV4515`. Browse the [agent gallery](https://www.bithuman.ai/explore) and copy the code of any one you like — or use `A78WKV4515` to follow along. ## 2. Embed it Drop it onto any page as an iframe. It's live and talking immediately — the user can speak to it and it responds: ```html ``` Replace `A78WKV4515` with your chosen code. That's the whole quickstart — open the page and start talking. > **Note** The iframe needs delegated `microphone` permission to hear the user, > and the `*` in the `allow` attribute is load-bearing — the embed URL redirects > cross-origin to `agent.viewer.bithuman.ai`, so a bare `allow="microphone"` > leaves the mic silently blocked. If your page sets a restrictive > `Permissions-Policy`, the avatar loads but the mic stays silent — allowlist > `agent.viewer.bithuman.ai`. For per-visitor > session tracking and rate limiting, mint a short-lived embed token on your > backend — see [Embedding](/api/embedding). --- ## Going further The steps above need no API key. The rest of the platform does — get an API secret at [Developer → API Keys](https://www.bithuman.ai/developer/api-keys) (free tier, no credit card) and export it: ```bash export BITHUMAN_API_SECRET=your_api_secret ``` Verify it with the cheapest call there is — no credits, no agent needed: ```bash curl -X POST https://api.bithuman.ai/v1/validate \ -H "api-secret: $BITHUMAN_API_SECRET" ``` It always returns HTTP `200` — read the body: `{"valid": true}` means you're set, `{"valid": false}` means the secret is missing or wrong. ### Look up an agent Fetch **one of your own** agents by code. List them first — `GET /v1/agents` returns every agent on your account: ```bash curl "https://api.bithuman.ai/v1/agents?limit=5" \ -H "api-secret: $BITHUMAN_API_SECRET" ``` Then read one by its code (substitute a code from the list above): ```bash curl https://api.bithuman.ai/v1/agent/YOUR_AGENT_CODE \ -H "api-secret: $BITHUMAN_API_SECRET" ``` > **Note** `/v1/agent/{code}` is **owner-scoped**. A gallery code you do not own > — including the `A78WKV4515` used for the embed above — returns > `404 NOT_FOUND` (`"Agent not found for code: …"`) even with a perfectly valid > key. That is an ownership answer, not a "this agent doesn't exist" answer: > the same code still embeds and still mints an > [embed token](/api/embedding). Only agents on your own account are readable, > speakable, and renderable through the [Video API](/api/video). ### Make it speak from your backend When one of **your** agents has an **active session** (an embed of it, or a LiveKit room), push text into it and the avatar speaks it aloud: ```bash curl -X POST https://api.bithuman.ai/v1/agent/YOUR_AGENT_CODE/speak \ -H "api-secret: $BITHUMAN_API_SECRET" \ -H "content-type: application/json" \ -d '{"message": "Hello! Great to meet you."}' ``` > **Note** `/speak` and `/add-context` need both **ownership** and an active > session, and both failures are reported as `404 NOT_FOUND` — read the message > to tell them apart. `"Agent not found for code: "` means the agent is > not on your account; `"No active rooms found > for agent "` means it is yours but idle — open the embed first, or start a > [LiveKit worker](/api/embedding). ### Voice without an avatar Text-to-speech needs no agent at all — one call returns a WAV: ```bash curl -X POST https://api.bithuman.ai/v1/tts \ -H "api-secret: $BITHUMAN_API_SECRET" \ -H "content-type: application/json" \ -d '{"text": "Hello from bitHuman.", "voice": "F1"}' \ --output hello.wav ``` See [Text to Speech](/api/text-to-speech) for languages, voices, and streaming. ## Generate your own agent (optional) Prefer a custom face and persona? Generation is asynchronous — it returns an `agent_id` immediately and takes a few minutes for the default `expression` + `v1` engine (Expression 1, 250 credits). Select an engine with `model` (`expression` default, or `essence`) plus `version` (`v1` default, or `v2`) — `essence` + `v2` → Essence 2, `expression` + `v2` → Expression 2; the full engine names (`essence-1` … `expression-2`) still work directly too. The [second-generation models](/concepts/models-v2) train a real per-identity model, so they take roughly 45 minutes to 1.5 hours and cost more — 500 credits for Essence 2, 2000 for Expression 2; see [per-model creation](/api/agents#model-specific-inputs-and-creation-times). Creation is image-only — a seamless 10-second identity video is generated internally: ```bash curl -X POST https://api.bithuman.ai/v1/agent/generate \ -H "api-secret: $BITHUMAN_API_SECRET" \ -H "content-type: application/json" \ -d '{ "prompt": "You are a friendly fitness coach.", "image": "https://example.com/headshot.jpg", "model": "expression", "version": "v2", "aspect_ratio": "9:16", "transparency": false }' ``` > **Note — `image` must be publicly fetchable, and this is not checked at > submit time.** The `https://example.com/…` URLs above are placeholders. > Posting one verbatim returns `HTTP 200` with > `{"success": true, "status": "processing"}`, and the job only fails seconds > later with `Image processing failed: Failed to download after 3 attempts: > 404`. The credits are charged at submit and **automatically refunded** on that > failure (verified 2026-07-28: `-500` then `+500` within 4 s), so nothing is > lost — but a `200` here is not confirmation that your image was accepted. Poll > [`GET /v1/agent/status/{agent_id}`](/api/agents#poll-status) before assuming > the creation started. Then poll [`GET /v1/agent/status/{agent_id}`](/api/agents) until `ready` and embed it exactly like step 2. See [Agents](/api/agents) for the full lifecycle. ## Next steps - [Authentication](/api/authentication) — keys, tokens, and how auth works. - [Agents API](/api/agents) — generate, update, and drive agents. - [Embed widget](/api/embedding) — drop your agent into any page. - [API reference](/api/reference) — every endpoint with a live console. - [Python SDK](/sdk/python) — the same engine, in-process, with the canonical [push-audio/drain-frames loop](/concepts/audio-streaming). - [Swift SDK (iOS, iPadOS, macOS)](/sdk/ios) — the same avatar rendered on-device inside an iPhone, iPad or Mac app. - [Android SDK (Kotlin)](/sdk/android) — the same avatar on-device in an Android app, from Maven Central. --- # Authentication URL: https://docs.bithuman.ai/api/authentication ## One credential, two names bitHuman uses a single shared credential per account that authenticates every SDK and the REST API. There are two equivalent environment-variable names depending on which surface you're using: - **`BITHUMAN_API_SECRET`** — Python SDK, REST API, LiveKit plugin, and CLI. - **`BITHUMAN_API_KEY`** — Swift SDK on Apple platforms. Same value, different name to match Apple convention. > **Tip** You only need a key when an **avatar is rendering**. Audio-only voice > agents (Swift `VoiceChat` with no `config.avatar`) run fully offline without > one — see [pricing](/api/billing) for what's free vs. metered. ## Get a key 1. Sign in at [bithuman.ai](https://www.bithuman.ai) (free tier, no credit card). 2. Go to [Developer → API Keys](https://www.bithuman.ai/developer/api-keys). 3. Click **Create new key**, name it (e.g. `production-mac`), and copy the value. **You won't be able to view it again** — store it somewhere durable. ## Sign in from the CLI If you use the [CLI](/sdk/cli), you don't have to copy a key by hand. Run: ```bash bithuman login ``` This opens your browser, you sign in to your bitHuman account and approve the request, and the CLI mints a **per-device API key** — scoped to your account and aliased `cli@` — then stores it in your OS keychain. From then on every CLI command (and any SDK process that inherits the environment) authenticates automatically; there's nothing to `export`. On SSH or headless hosts where the browser can't reach the machine, use `bithuman login --device` and enter the short code it prints from any browser. See [CLI → Signing in](/sdk/cli/reference#signing-in). Because each device gets its own key, it's individually **revocable** — run `bithuman logout` on that machine, or revoke the `cli@` key from [Developer → API Keys](https://www.bithuman.ai/developer/api-keys). Revoking one device leaves your other keys untouched. > **CI, containers, automation** — keep setting `BITHUMAN_API_SECRET` > directly (next section). That path is fully supported and is what you want > anywhere there's no browser or interactive shell. `bithuman login` is a > convenience for interactive machines, not a replacement for it. ## Verify it works ```bash curl -X POST https://api.bithuman.ai/v1/validate \ -H "api-secret: YOUR_KEY" ``` A `200` with `{"valid": true}` means you're good. If you have the CLI installed, `bithuman doctor` also checks the credential, brain selection, caches, and host capabilities. ## Set it for each surface **REST API** — `api-secret` header on every request: ```bash curl -X POST https://api.bithuman.ai/v1/agent/A78WKV4515/speak \ -H "api-secret: $BITHUMAN_API_SECRET" \ -H "content-type: application/json" \ -d '{"message": "Hello"}' ``` **Python SDK** — env var, or pass directly: ```python runtime = await AsyncBithuman.create( model_path="avatar.imx", api_secret="your_key", ) ``` **Swift SDK** — env var or config; never hardcode in source: ```swift // development: env var config.apiKey = ProcessInfo.processInfo.environment["BITHUMAN_API_KEY"] // production: fetch from your backend via Keychain config.apiKey = await fetchFromBackend() ``` For DMG distribution, bake the key into Info.plist via a build script. For App Store, fetch from your own backend via Keychain on first launch — don't bundle. ## api-secret vs. runtime tokens The long-lived `api-secret` is never sent to the avatar engine or any third party. It only ever travels to `api.bithuman.ai` over TLS. The streaming runtime is authorized by a separate, short-lived **runtime token**: 1. Your code provides the API secret to the SDK or REST request. 2. The SDK exchanges it for a short-lived runtime token at `POST /v1/runtime-tokens/request`. 3. That token authorizes the avatar engine (heartbeat + frame production) for your account. 4. Tokens auto-renew roughly every 60 seconds via the heartbeat. 5. Bad keys fail at step 2 — fast — before any user-visible work. The runtime token is **not** an api-secret. It can't mint other tokens; it just authorizes the runtime to compute frames on behalf of your account. It is **account-scoped** (not per-session or per-agent): HS256-signed with a short ~5-minute TTL, carrying `iss=bitHuman`, `sub=`, and a constant `aud=bithuman-runtime` claim — there is no agent or session claim. The SDKs and LiveKit plugin handle this loop for you — you rarely call `/v1/runtime-tokens/request` directly. For browser embeds, use the more constrained [embed token flow](/api/embedding) instead. ## Audio-only Swift mode is unmetered If you only want on-device voice chat (no lip-synced avatar), skip the API key entirely: ```swift var config = VoiceChatConfig() config.systemPrompt = "You are a helpful assistant." config.voice = .preset("Aiden") // no config.avatar = ... let chat = VoiceChat(config: config) try await chat.start() // does not authenticate ``` This mode runs fully offline (after first-launch weight downloads), bills nothing, and doesn't require a key. ## Rotating keys Rotate from the [Developer dashboard](https://www.bithuman.ai/developer/api-keys). Rotation invalidates the old key immediately — there's no overlap window. Live sessions using the old key fail their next heartbeat (within ~60 s) and pause; restart with the new key to resume. Rotate during a maintenance window if you have production sessions running. ## Common errors | Error | Cause | Fix | |---|---|---| | `401` `MISSING_AUTH` | `api-secret` header absent | Add the header on every request. | | `401` `UNAUTHORIZED` | `api-secret` header present but invalid | Re-verify the secret with `/v1/validate`; rotate if needed. | | `Authentication failed` (Python) | Wrong/missing `BITHUMAN_API_SECRET` | Verify with the `curl /v1/validate` recipe. | | `VoiceChatError.missingAPIKey` (Swift) | Avatar mode without `apiKey` set | Set `config.apiKey` or export `BITHUMAN_API_KEY`. | | Heartbeat silent after 5 min | Network dropped on-device | Reconnect; the SDK pauses the avatar after the grace window and resumes when heartbeats succeed. | See the full [error reference](/api/errors). --- # Agents API URL: https://docs.bithuman.ai/api/agents ## The agent lifecycle From "I have a face and voice" to "live talking avatar": ```text Generate → Poll until ready → Resolve by code → Live session → Speak ``` This page covers the full REST lifecycle. For the in-process SDK flow, see the [Python SDK](/sdk/python) and [agent lifecycle](/concepts/agent-lifecycle) concepts. ## Validate your key `POST /v1/validate` — verify your API secret before making other calls. Costs no credits. ```bash curl -X POST https://api.bithuman.ai/v1/validate \ -H "api-secret: $BITHUMAN_API_SECRET" ``` ```json { "valid": true } ``` ## Generate an agent `POST /v1/agent/generate` — create a new avatar agent. Generation is asynchronous and billed **per model** — `expression-2` costs 2000 credits, `essence-2` 500, the v1 families (`essence-1`, `expression-1`) 250, and `auto` bills the routed model's rate (machine-readable schedule: [`GET /v1/pricing`](/api/billing#get-the-pricing-schedule)). The call returns immediately with an `agent_id` and `processing` status. | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `prompt` | string | no | random | System prompt / personality for the agent. | | `image` | string | no | — | Image URL or base64 data for appearance. A supplied image is treated as a **reference** and always regenerated via **Seedream 5 edit** to standardize it (never used raw); omit it and a portrait is generated from the `prompt` with **Seedream 5 pro**. | | `audio` | string | no | — | Audio URL or base64 data for voice cloning. | | `aspect_ratio` | string | no | `16:9` | Aspect ratio for the generated identity image **and** driver video — `16:9` landscape (default), `9:16` portrait, `1:1` square. Images are generated at 1080p. | | `transparency` | boolean | no | `false` | When `true`, the identity image is generated on a solid **green-screen** background for chroma-key / transparent embedding — the character itself never uses green. | | `framing` | string | no | `portrait` | `portrait` (default) frames head-and-shoulders; `full_body` shows the whole figure including the feet (kiosk / standing-avatar layouts). | | `agent_id` | string | no | auto | Custom agent identifier. | | `duration` | number | no | — | **Deprecated — omit it.** The internally generated identity video is standardizing on 10 seconds; the parameter is ignored as that rollout completes. | | `model` | string | no | `expression` | Avatar model **family** — `expression` (default) or `essence` — combined with `version` to pick the engine: `expression`+`v1` = **Expression 1** (the default), `essence`+`v1` = **[Essence 1](/concepts/models)**, `essence`+`v2` = **[`essence-2`](/concepts/essence-2)** (the [Essence 2 creation](#essence-2--the-photorealistic-creation)), `expression`+`v2` = **[`expression-2`](/concepts/expression-2)**. You may also pass a **full engine name** directly (`essence-1`, `essence-2`, `expression-1`, `expression-2`) or **`auto`** ([classify-and-route](#auto--let-the-platform-pick-the-model)) — those pass through unchanged and `version` is ignored, so existing integrations keep working. An omitted `model` defaults to `expression` at `v1` (Expression 1, 250 credits) — a v1 engine at the ungated 250-credit rate; a caller is never silently upgraded onto a v2 engine or a higher price. Invalid values return `400 VALIDATION_ERROR` (no credits charged); the retired `essence-2-light` name returns a targeted hint pointing at `essence-2`. See [models](/concepts/models) and [Essence 2 & Expression 2](/concepts/models-v2). | | `version` | string | no | `v1` | Engine generation for the chosen `model` family — `v1` (default) selects the first-generation engine (Essence 1 / Expression 1, 250 credits); `v2` selects the second-generation engine (Essence 2, 500 credits, or Expression 2, 2000 credits). Ignored when `model` is a full engine name or `auto`. | > **Agent creation is image-only.** Provide a portrait `image` (or let the > prompt generate one) — bitHuman generates a **10-second identity video > internally** (Seedance 1.5 pro, 25 fps), authored to loop seamlessly (its > first and last frames match). Video input is not part of the creation > contract for any model: a request carrying `video` is rejected with > [`400 VIDEO_INPUT_NOT_SUPPORTED`](/api/errors#agent-operations) before > anything is billed (verified against the live API, 2026-08-01). ### Model-specific inputs and creation times The `model` you pick changes what creation needs and how long it runs. All models share the same pipeline prefix — persona, voice, and image are prepared first (each generated from your prompt when not supplied) — then the model-specific identity step runs: | `model` | Identity input | Identity step | Typical creation time | |---|---|---|---| | `essence-1` | `image` (or generated from prompt); an identity video is generated internally if needed | Builds the portable `.imx` avatar | 2–5 minutes | | `expression-1` (default) | `image` (or generated from prompt) | None (animates the portrait at runtime) | ~1–2 minutes | | `essence-2` | `image` (or generated from prompt) — a 10-second identity video is generated from it internally (the `video` step) | Builds the Essence 2 identity bundle on a cloud GPU | 25–40 minutes typical; occasionally longer (allowed up to several hours) | | `expression-2` | `image` (or generated from prompt) | Trains a per-identity model on an H100-class GPU | About **2 hours** — measured 2026-09-09 over every model-add that carries both timestamps (n=10, median 122.5 min, 8 of 10 between 119 and 141 min) and every creation on the same day (121/124/125/128 min). Longer when the adaptive recipe extends to hold quality | | `auto` | `image` or prompt (classified automatically) | As the routed model — `essence-2` or `expression-2` | As the routed model | Set your polling timeout per model — a 5-minute client timeout is fine for `essence-1` but will falsely "fail" every `expression-2` and `essence-2` creation. Full model behavior (serving tiers, idle, pricing) is in each model's guide. ### `essence-2` — the photorealistic creation `model: "essence-2"` runs the [Essence 2](/concepts/essence-2) training: a single 500-credit charge builds the identity bundle from the **internally generated identity video**. Sessions launched as `essence-2` serve the auto-routed chain by default; pin a tier with `?model=` on the session/embed URL (or the `model` field on the [embed token](/api/embedding)) when you need to. Once ready, the agent's `supported_models` lists `essence-2` — a public name that can be sent straight back as a `model` value; the internal tier spellings `essence-2-light` / `essence-2-quality` are folded before the response is built and never appear in it. ### `auto` — let the platform pick the model `model: "auto"` runs an LLM **subject classifier** over your creation input — the `image` if you provided one, else the `prompt` — and routes it: - a **photorealistic person** → `essence-2` (the photorealistic creation above); - a **cartoon, animal, creature, or robot** → `expression-2` (the generative engine, which handles any subject and works best for stylized characters). `auto` never rejects on subject — it routes instead — and charges the routed model's rate (500 credits for `essence-2`, 2000 for `expression-2`). It is the default selection in the dashboard's create flow, but **API callers must pass it explicitly**: an omitted `model` defaults to `expression-1` (`v1`, 250 credits) for backward compatibility. ### The Essence 2 subject gate (422) An **explicit** Essence 2 creation (`essence-2`) requires a **photorealistic human subject** — the Essence identity pipelines train on real human faces. The same classifier runs on your input **before anything is billed** and before any agent row is created; a non-human or stylized subject is rejected with [`422 MODEL_SUBJECT_MISMATCH`](/api/errors#model-errors): ```json { "error": { "code": "MODEL_SUBJECT_MISMATCH", "message": "essence-2 requires a photorealistic human subject; this image looks like a cartoon — use expression-2", "httpStatus": 422 }, "status": "error", "status_code": 422 } ``` (With a prompt-only input the message reads "this description sounds like a …".) Use `expression-2` for those subjects, or `model: "auto"` to route automatically. A classifier outage never blocks creation — the gate fails open. > **Note** The Python examples below use > [`requests`](https://pypi.org/project/requests/), which is not in the standard > library — `pip install requests` first, or use `curl` / `urllib` instead. ```python import requests resp = requests.post( "https://api.bithuman.ai/v1/agent/generate", headers={"Content-Type": "application/json", "api-secret": "YOUR_API_SECRET"}, json={ "prompt": "You are a professional video content creator.", "image": "https://example.com/avatar.jpg", "model": "expression-2", }, ) print(resp.json()) ``` > **Note — `image` must be publicly fetchable, and this is not checked at > submit time.** The `https://example.com/…` URLs above are placeholders. > Posting one verbatim returns `HTTP 200` with > `{"success": true, "status": "processing"}`, and the job only fails seconds > later with `Image processing failed: Failed to download after 3 attempts: > 404`. The credits are charged at submit and **automatically refunded** on that > failure (verified 2026-07-28: `-500` then `+500` within 4 s), so nothing is > lost — but a `200` here is not confirmation that your image was accepted. Poll > [`GET /v1/agent/status/{agent_id}`](/api/agents#poll-status) before assuming > the creation started. ```json { "success": true, "message": "Agent generation started", "agent_id": "A80HVD8577", "status": "processing" } ``` > **Note** The generation endpoint is `POST /v1/agent/generate`. (Older docs > referenced `/v1/agent-generation` — that path is incorrect.) ### Idempotent retries — the `Idempotency-Key` header Creation is billed, so a network timeout on the response should never make you guess whether to retry. Send an **`Idempotency-Key`** header (any unique string you choose, e.g. a UUID) with the request: ```bash curl -X POST https://api.bithuman.ai/v1/agent/generate \ -H "Content-Type: application/json" \ -H "api-secret: $BITHUMAN_API_SECRET" \ -H "Idempotency-Key: order-42-avatar-1" \ -d '{"prompt": "You are a helpful retail assistant.", "model": "essence-2"}' ``` - A **repeated** request with the same key returns the **first response verbatim** — same `agent_id` — with an `Idempotency-Replayed: true` response header, and does **not** start a second billed generation. - When you don't supply your own `agent_id`, the agent id is **derived from the key**, so a retry that lands on a different server replica still converges on the same agent. - Use a **fresh key per intended creation** — reusing a key deliberately gives you the previous creation back. The header is also honored on [`POST /v1/video/generate`](/api/video) and [`POST /v1/dynamics/generate`](/api/dynamics). ## Poll status `GET /v1/agent/status/{agent_id}` — returns the current state of a generation request. Poll every 5 seconds. | Status | Description | |---|---| | `processing` | Initial state — generation queued. | | `generating` | Active generation in progress (sub-steps running). | | `completed` | An intermediate sub-step finished. **Not terminal** — it can appear early (even around ~5% `progress`), so do not stop polling on it. | | `success` | A **sub-step** finished — the voice/portrait step and the identity-video step each write it. **Not terminal**: it appears mid-run, normally at `progress` `0.2` and `0.45`, before training has even started. Count it as done only when `progress` is also `1.0` (some historical rows finished on `success` + `1.0`). | | `ready` | **Terminal success** — the model is available for use. Always written together with `progress: 1.0` and `current_step: "done"`. | | `failed` | Failure — check `error_message`. | Treat `ready` and `failed` as terminal. `processing`, `generating`, `completed` and `success` are all intermediate, so keep polling. **`success` is a step-level marker, not the end of the run** — a loop that stops on it exits at ~20% `progress` with a null `model_url` — and `completed` can appear long before the model is done. The safe terminal test is `status == "ready"`, or `status == "success"` **together with** `progress == 1.0`. Typical wall-clock is two to five minutes for `essence-1` — the second-generation models train real per-identity models and take longer (see [model-specific inputs and creation times](#model-specific-inputs-and-creation-times)). While a run is in flight, `current_step` reports the pipeline stage: | `current_step` | Progress | What's happening | |---|---|---| | `payment` | ~2% | Credits reserved (the model's creation cost — 250, 500, or 2000). | | `persona` | 5–15% | Persona / system prompt prepared. | | `voice_image` | ~20% | Voice and portrait generated (in parallel). | | `video` | ~45% | The 10-second identity video is generated internally (`essence-1` and `essence-2`) — authored to loop seamlessly. | | `awaiting_face_marking` | ~35% | Waiting on manual face marking (rare `essence-1` path). | | `lip_sync` | 70–99% | The model-specific identity step — `.imx` build (`essence-1`), bundle build (`essence-2`), or per-identity training (`expression-2`). The longest step for the v2 models. | | `done` | 100% | Terminal — the agent is `ready`. | ```json { "success": true, "data": { "agent_id": "A80HVD8577", "status": "ready", "progress": 1.0, "progress_msg": "Complete", "current_step": "done", "error_message": null, "system_prompt": "You are a professional video content creator.", "image_url": "https://...", "video_url": "https://...", "model_url": "https://...", "supported_models": ["essence-2", "expression-2"], "name": "agent name" } } ``` | Progress field | Type | Description | |---|---|---| | `progress` | float (0.0–1.0) | Generation progress as a fraction. `1.0` is complete. | | `progress_msg` | string | Human-readable progress description. | | `current_step` | string | Current generation step (see the table above). | | `supported_models` | string[] | The model families this agent can be **launched as right now**, spelled with the **public model names** — `essence-1`, `expression-1`, `essence-2`, `expression-2` — so every entry can be sent straight back as a `model` / `?model=` value. Trained families (`expression-2`, `essence-2`) appear once their per-identity model exists; `essence-1` appears when its `.imx` exists. Tier slugs inherit their family. Also returned on `GET /v1/agent/{code}`, `GET /v1/agents` items, and the embed-token response.

Every entry is a **public** name and is safe to send straight back: the internal tier spellings `essence-2-light` / `essence-2-quality` are folded before the response is built, so they never appear in this array. | ### Generate and poll ```python import os, time, requests BASE = "https://api.bithuman.ai" SECRET = os.environ["BITHUMAN_API_SECRET"] headers = {"Content-Type": "application/json", "api-secret": SECRET} resp = requests.post(f"{BASE}/v1/agent/generate", headers=headers, json={"prompt": "You are a friendly AI assistant."}) agent_id = resp.json()["agent_id"] print("agent_id:", agent_id) # save this — you can resume polling any time while True: r = requests.get(f"{BASE}/v1/agent/status/{agent_id}", headers={"api-secret": SECRET}, timeout=30) if r.status_code != 200: # Transient (429 rate limit, edge blip). Creation keeps running # server-side — keep polling rather than aborting the run. time.sleep(5) continue data = r.json()["data"] # Only `ready` is terminal. `success` is a STEP-level marker (the # voice/image and video steps each write it around 20% and 45%), and # `completed` is not terminal either — stopping on them exits mid-run # with model_url still null. if data["status"] == "ready" or ( data["status"] == "success" and data.get("progress") == 1.0 ): # model_url is null for models with no downloadable per-identity # artifact (expression-1) — that is success, not a failure. print("Ready:", agent_id, "| launchable as:", data["supported_models"]) print(" model_url:", data["model_url"]) break if data["status"] == "failed": raise SystemExit(f"Failed: {data['error_message']}") print(f" {data['status']} {data.get('current_step')} {data.get('progress')}") time.sleep(5) ``` ### Creation failure modes Two kinds of failure exist — **rejected before start** (HTTP error, nothing charged) and **failed during generation** (`status: "failed"`, credits automatically refunded): | Failure | Surface | Notes | |---|---|---| | Invalid `model` value | `400 VALIDATION_ERROR` — `Invalid model ''; must be one of: auto, essence, essence-1, essence-2, expression, expression-1, expression-2` | Rejected before dispatch; no credits charged. Retired names get a **targeted hint** instead of the bare list — e.g. `essence-2-light` → *"'essence-2-light' was consolidated into 'essence-2' (2026-07-05)…"*. | | Malformed body | `400 VALIDATION_ERROR` — `Request body must be valid JSON` / `…a JSON object` | Rejected before dispatch. | | `video` in the request body | [`400 VIDEO_INPUT_NOT_SUPPORTED`](/api/errors#agent-operations) — `Agent creation is image-only. Provide a portrait image; bitHuman generates a 10-second idle/driver video internally so it loops seamlessly (first frame == last frame). …` | Rejected before dispatch — nothing charged (verified live 2026-08-01). Send `image` instead — the identity video is always generated internally, for every model. | | Too many Essence 2 creations in flight | `status: "failed"` with a capacity `error_message` (queue position + an honest ETA derived from the measured drain rate) | `essence-2` creations are **admission-controlled**: at most **2 in-flight creations per account**, and a deep platform queue can also defer admission. Rejection happens **before billing** — nothing is charged, no refund needed. Wait for an in-flight creation to finish, then retry. | | A second-generation family paused for your account (rare) | [`503 MODEL_NOT_YET_AVAILABLE`](/api/errors#model-errors) — ` isn't available for generation yet. Specify 'essence-1' or 'expression-1' to generate now.` | Essence 2 / Expression 2 are **GA** (since July 10, 2026) — creation is open for all accounts, so this isn't returned in normal operation. It remains the safety response if a v2 family is ever re-paused; nothing is charged and the v1 families always work. | | Non-human subject on an explicit Essence 2 creation | [`422 MODEL_SUBJECT_MISMATCH`](/api/errors#model-errors) — `essence-2 requires a photorealistic human subject; this image looks like a — use expression-2` | Rejected **before billing** and before any agent row exists — see [the subject gate](#the-essence-2-subject-gate-422). `auto` routes instead of rejecting. | | Not enough credits | `402 INSUFFICIENT_BALANCE` (also surfaces as `status: "failed"` with a payment `error_message` if the reserve fails mid-pipeline) | Creation costs the model's rate — 250 (v1), 500 (Essence 2), or 2000 (`expression-2`). | | A pipeline step fails | `status: "failed"` + `error_message` naming the step (voice, image, video, or the model step) | Terminal for that `agent_id`; the creation credits are refunded automatically. Create again after fixing the input. | | v2 creation "stuck" at `lip_sync` | Not a failure | That's the training/prep step — the longest part for `expression-2` / `essence-2`. Keep polling; see [creation times](#model-specific-inputs-and-creation-times). | More session-time issues (connect latency, tier pinning, idle behavior): [Session behavior & troubleshooting](/guides/session-troubleshooting). ## Get an agent `GET /v1/agent/{code}` — retrieve full details for an agent by its code. ```python import requests code = "A80HVD8577" data = requests.get( f"https://api.bithuman.ai/v1/agent/{code}", headers={"api-secret": "YOUR_API_SECRET"}, ).json() agent = data["data"] print(agent["name"], agent["status"]) ``` ```json { "success": true, "data": { "agent_id": "A80HVD8577", "code": "A80HVD8577", "status": "ready", "model": "essence-2", "supported_models": ["essence-2"], "name": "My Agent", "system_prompt": "You are a friendly AI assistant", "voice_id": "aBc123…", "image_url": "https://assets.bithuman.ai/A80HVD8577/image_20260115_103000_000001.jpg", "video_url": "https://assets.bithuman.ai/A80HVD8577/video_20260115_103200_000002.mp4", "model_url": "https://assets.bithuman.ai/A80HVD8577/A80HVD8577.lebundle.imx" } } ``` The response carries the agent's full record (abridged above; verified against the live API 2026-08-01) — the **persona** (`system_prompt`, `name`, `description`, `language`, `gender`), the **voice** (`voice_id`), the **media** (`image_url`, `video_url` — the internally generated 10-second identity video — and `model_url`), the creation state (`status`, `progress`, `current_step`, `error_message`), and the launch surface (`model`, `supported_models` — every entry a public model name you can send straight back as a `model` / `?model=` value). ## List your agents `GET /v1/agents` — list the agents owned by your API secret, newest first. Paginated with `limit` (default 20, max 100) and `offset`; filter by generation state with `status`. ```python import requests resp = requests.get( "https://api.bithuman.ai/v1/agents", headers={"api-secret": "YOUR_API_SECRET"}, params={"limit": 20, "offset": 0, "status": "ready"}, ).json() for a in resp["data"]: print(a["code"], a["status"]) print(resp["pagination"]) # {limit, offset, total, has_more} ``` Page through with `offset` until `pagination.has_more` is `false`. ## Delete an agent `DELETE /v1/agent/{code}` — permanently delete an agent you own. Stored assets are cleaned up best-effort; usage history is retained for billing. Deleting a missing or non-owned agent returns `404`. ```python import requests requests.delete( "https://api.bithuman.ai/v1/agent/A80HVD8577", headers={"api-secret": "YOUR_API_SECRET"}, ).json() # {"success": true, "agent_code": "A80HVD8577", "deleted": true} ``` ## Update an agent's prompt `POST /v1/agent/{code}` — update the system prompt of an existing agent without regenerating it. The agent must already exist. For a new face or voice, generate a new agent. ```python import requests code = "A80HVD8577" resp = requests.post( f"https://api.bithuman.ai/v1/agent/{code}", headers={"Content-Type": "application/json", "api-secret": "YOUR_API_SECRET"}, json={"system_prompt": "You are a professional sales assistant."}, ) print(resp.json()) ``` ```json { "agent_code": "A80HVD8577", "updated": true } ``` ## Add a model to an existing agent `POST /v1/agent/{code}/models` — add an avatar model to an agent you already created, without re-creating it or regenerating its persona/voice/image. The agent must be in the `ready` state (else [`409 AGENT_NOT_READY`](/api/errors#model-errors)). Body: `{"model": ""}` — one of `essence-1`, `essence-2`, `expression-1`, `expression-2` (anything else returns `400 VALIDATION_ERROR` listing the options). | `model` | What happens | Prerequisites | Credits | Time | |---|---|---|---|---| | `expression-1` | **Instant enablement** — the shared v1 engine drives the agent's existing image + voice at runtime; nothing is trained | stored image **and** voice (else `422`) | **0** | immediate (this response) | | `expression-2` | Trains the per-identity Expression 2 model from the stored image | stored image (else `422`) | 2000 | about 1–1.5 h | | `essence-2` | Trains Essence 2 from the agent's stored identity video (generated internally at creation) | stored identity video (else `422 MODEL_PREREQUISITE_MISSING`) + photorealistic-human subject on the stored image (else `422 MODEL_SUBJECT_MISMATCH`) | 500 | 45 min–3 h | | `essence-1` | Builds the v1 `.imx` — reuses the stored identity video, or generates one internally from the stored image | stored identity video or image (else `422`) | 250 | ~10–20 min | ```python import requests resp = requests.post( "https://api.bithuman.ai/v1/agent/A66GYD8664/models", headers={"Content-Type": "application/json", "api-secret": "YOUR_API_SECRET"}, json={"model": "expression-2"}, ) print(resp.json()) ``` An **async** add (everything except `expression-1`) responds immediately: ```json { "success": true, "agent_id": "A66GYD8664", "model": "expression-2", "status": "processing", "credits": 2000, "supported_models": ["essence-1", "essence-2"], "message": "expression-2 model add started (typically 2-3 hours). 2000 credits are charged (refunded automatically if the add fails). Poll GET /v1/agent/status/A66GYD8664 until supported_models includes expression-2." } ``` The estimate embedded in the response `message` is advisory — the table above has the typical times, and both are now the same measurement. They were not: until 2026-09-09 this endpoint answered "typically 10-45 minutes" for a job whose every measured run took about two hours, and the table said 60–100 minutes for the same work. A caller who believed either opened a ticket long before the model was due. Poll [`GET /v1/agent/status/{code}`](#poll-status) until `supported_models` contains the new family. The agent keeps serving as-is while the add runs — `status` stays `ready` for the v2 adds. An **instant** add (`expression-1`, or a model the agent already has) returns `status: "ready"` with `credits: 0` in the same response — re-POSTing the same model never double-charges, and a failed add refunds automatically. Failure shapes: `400 VALIDATION_ERROR` · `404 NOT_FOUND` (unknown or not-owned agent) · `409 AGENT_NOT_READY` · [`422 MODEL_PREREQUISITE_MISSING` / `422 MODEL_SUBJECT_MISMATCH`](/api/errors#model-errors) · [`503 MODEL_NOT_YET_AVAILABLE`](/api/errors#model-errors) (only if a v2 family is paused — not returned in normal operation since the July 10, 2026 GA; nothing charged). ### Using Expression 1 on an existing agent Every model is a **capability of the agent**, not a property of it — an agent created with `essence-1` can render Expression 1 too, with no change to the agent, its persona, its voice, or your request. The models are compatible; the capability just has to be switched on once. Expression 1 is the only model that needs no training at all: the shared v1 engine drives the agent's stored image with its stored voice at render time. So enabling it is **one call, instant, and free**: ```python import requests code = "A66GYD8664" # an agent created with essence-1 head = {"Content-Type": "application/json", "api-secret": "YOUR_API_SECRET"} requests.post(f"https://api.bithuman.ai/v1/agent/{code}/models", headers=head, json={"model": "expression-1"}) # -> {"success": true, "status": "ready", "credits": 0, # "supported_models": ["essence-1", "expression-1", "essence-2"]} requests.post("https://api.bithuman.ai/v1/video/generate", headers=head, json={"model": "expression-1", "agent_code": code, "input": {"type": "text", "text": "Hello!"}}) ``` Until that call is made, `expression-1` is absent from the agent's `supported_models` and every Expression 1 request for it — talking video, [embed token](/api/embedding), model download — returns [`409 MODEL_NOT_GENERATED`](/api/errors#model-errors) *before any charge*, with the enabling call named in the message. Nothing else about the request changes: same `agent_code`, same `input`, only `model` differs. ## Download an agent's model `GET /v1/agent/{code}/model/download` — download the generated model artifact for an agent you own. The family defaults to the agent's own model; override with `?model=` (public names and runtime tier slugs fold onto their family — the `essence-2-{gpu,ane,cpu}` force slugs and the retired `essence-2-light` fold onto `essence-2`). `essence-2-quality` is **no longer accepted** and returns a `400`. What you get per family — and what opens each file, in one place: [what you get, per family](/sdk/cli/reference#what-you-get-per-family). > **The name in the Artifact column is the object in the store, not the file you > receive.** Whatever the stored object is called, the endpoint labels the > download `.imx` — that is the `filename` field and the > `Content-Disposition`, so `curl -LOJ` writes `A17ZTB0222.imx`. Measured > 2026-09-09 on two `expression-2` agents (stored `.avatar`) and four > `essence-2` agents (three stored `.lebundle.imx`): every one of the six was > delivered as `.imx`. Name your local file from the response, not from > this column. | Family | Artifact in the store | Notes | |---|---|---| | `essence-1` | `.imx` | The portable IMX container — [runs locally](/sdk/cli/reference) in the CLI and the [Python SDK](/sdk/python). | | `essence-2` | `.lebundle.imx` | The standard Essence 2 artifact — unified IMX container. **~85–105 MB** for an agent created on the current renderer (measured across the live fleet, 2026-07-28). Agents created before the 2026-07-27 renderer change carry a larger bundle — up to ~550 MB — until they are retrained; the artifact shrank roughly **5×**. Size is per identity: read `Content-Length` rather than assuming a fixed figure. **Licensed weights** — a local runtime must complete the license activation flow; today the model serves via bitHuman cloud. | | `expression-2` | `.avatar` | The per-identity Expression 2 artifact. Sizes range widely — two agents measured on 2026-09-09 were 193.6 MB and 192.9 MB, so read `Content-Length` rather than budgeting from a figure on this page. **The `.avatar` extension is historical: it is the frozen back-compat alias of `.imx`, not a distinct encoding.** Measured across all 110 published objects on 2026-09-01, **96 are `IMX\0` v2 containers** and **14 are still the pre-2026-07-12 CoreML zip** — those 14 will not be re-published, so check with `bithuman info ` rather than assuming either form. [Runs locally](/sdk/cli/reference) on macOS (Apple Silicon), and on Linux x86_64 once the CPU render host is installed (`bithuman engine install linux-x86_64`); also in the browser via [`?render=local`](/guides/browser-rendering), and served on bitHuman's cloud. | | `expression-1` | usually none; `.imx` for a lip-stepped agent | Expression 1 has no per-identity artifact of its own — the shared v1 engine renders server-side from the agent's image, so the normal answer is `400 MODEL_NOT_DOWNLOADABLE`. **One case does download:** an `expression-1` agent that went through the lip step owns a baked `.imx`, and the endpoint redirects to it exactly as it does for `essence-1`. | The default response is a **302 redirect** to the artifact (public URL for `essence-1`, **1-hour signed URL** for the private families), so a plain curl works: ```bash curl -LOJ -H "api-secret: $BITHUMAN_API_SECRET" \ "https://api.bithuman.ai/v1/agent/A17ZTB0222/model/download?model=expression-2" # → A17ZTB0222.imx (-LOJ takes the Content-Disposition name, which is always .imx) ``` > **`?model=` is the only way to reach a second family.** An agent that gained a > model through [add-a-model](#add-a-model-to-an-existing-agent) has more than > one downloadable artifact under the same code, and an omitted `?model=` > resolves to the family the agent was **created** with — not the one you added. > The [CLI](/sdk/cli/reference) has no flag for this: `bithuman pull ` > always takes the default. Read `supported_models` on > [`GET /v1/agent/{code}`](#get-an-agent) to see what an agent actually holds. Pass `?redirect=false` to get the URL as JSON instead (for UIs that want to fetch or label first): ```json { "success": true, "data": { "code": "A17ZTB0222", "model": "expression-2", "filename": "A17ZTB0222.imx", "url": "https://…signed…", "expires_in": 3600 } } ``` (`expires_in` is `null` for the public `essence-1` URL.) Errors ([full reference](/api/errors#model-errors)): | Status | Code | When | |---|---|---| | `400` | `VALIDATION_ERROR` | Unknown `model` value — the message lists the downloadable families. | | `400` | `MODEL_NOT_DOWNLOADABLE` | The family has no per-identity artifact for this agent — in practice `expression-1` on an agent with no lip-step `.imx`. Not retryable. | | `404` | `NOT_FOUND` | Agent unknown **or not owned by this account**. | | `404` | `MODEL_ARTIFACT_NOT_READY` | The family is supported but its artifact hasn't been published to the download store yet — the message says when to retry. **Poll on this code.** | | `409` | `MODEL_NOT_GENERATED` | The requested family isn't in the agent's `supported_models` (same gate as embed/session launch). | | `429` | `RATE_LIMITED` | Read-bucket rate limit. | > **Tip** The [bitHuman CLI](/sdk/cli/reference) wraps this endpoint: > `bithuman pull A17ZTB0222` downloads the artifact, > recognizes its model family, and prints what to do next — an `essence-1` > `.imx` runs locally with `bithuman run`. It calls this endpoint **without** > `?model=`, so on a multi-model agent it downloads the default family; use > `curl` with `?model=` for any other one. ## Make an agent speak `POST /v1/agent/{agent_code}/speak` — trigger the agent to speak a message to users in an active session. > **Requires a LIVE session.** `/speak` speaks into a conversation that is > already open — it cannot start one. With nobody connected there is no room to > deliver to and the call returns `404 NOT_FOUND` > (`"No active rooms found for agent "`). A successful call names the > sessions it reached in `rooms`, and the ones it could not in `rooms_skipped` > (live, but no agent worker attached) and `rooms_failed`. On a broadcast a > partial delivery is normal — read `rooms`, not the counts. If nothing could be > delivered you get a `404`, never a `200` with `delivered_to_rooms: 0`. | Parameter | Type | Required | Description | |---|---|---|---| | `message` | string | yes | Text the agent will speak. | | `room_id` | string | no | Speak into ONE session. Omit to broadcast to every deliverable session of this agent. | Get a `room_id` from [list live sessions](#list-an-agents-live-sessions). Omitting `room_id` is a **broadcast**: with three sessions open, all three avatars speak the message. ```bash curl -X POST https://api.bithuman.ai/v1/agent/A12345678/speak \ -H "Content-Type: application/json" \ -H "api-secret: $BITHUMAN_API_SECRET" \ -d '{ "message": "We have a 20% discount available today.", "room_id": "room-A12345678-x1y2-z3w4" }' ``` ```json { "agent_code": "A12345678", "context_type": "speak", "delivered_to_rooms": 1, "rooms": ["room-A12345678-x1y2-z3w4"], "rooms_skipped": [], "rooms_failed": [], "rooms_skipped_no_worker": 0 } ``` Every room targeted appears in exactly one of `rooms`, `rooms_skipped` or `rooms_failed`, so a broadcast tells you what it reached rather than only how many. ## List an agent's live sessions `GET /v1/agent/{agent_code}/sessions` — the sessions this agent has open now. Use a returned `room_id` to address one of them with `/speak` or `/add-context`. ```bash curl https://api.bithuman.ai/v1/agent/A12345678/sessions \ -H "api-secret: $BITHUMAN_API_SECRET" ``` ```json { "agent_code": "A12345678", "sessions": [ { "room_id": "room-A12345678-x1y2-z3w4", "num_participants": 3, "created_at": 1788480000, "deliverable": true, "matched_by": "name" }, { "room_id": "support-call-8842", "num_participants": 2, "created_at": 1788479100, "deliverable": false, "matched_by": "ledger" } ] } ``` `created_at` is the room's LiveKit creation time in Unix seconds. `deliverable` is the field that matters: a room can be live and listed while its agent worker has gone (a worker restart, or a render-only room), and `/speak` on one of those returns `404`. An agent with nothing open returns `200` with an empty `sessions` array, not a `404`. There is **no per-agent limit** on how many sessions `/speak` can address — every deliverable session of the agent is listed and addressable, including rooms you named yourself if you drive LiveKit directly. Your account's concurrent-session allowance still applies; see [Session concurrency](/api/rate-limits#session-concurrency). ## Inject knowledge `POST /v1/agent/{agent_code}/add-context` — add background knowledge the agent uses to inform future responses. Set `type` to `speak` to trigger speech instead. | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `context` | string | yes | — | Knowledge to inject (or message to speak). | | `type` | string | no | `add_context` | `add_context` injects knowledge silently; `speak` triggers a verbal response. | | `room_id` | string | no | — | Deliver to ONE session ([get one](#list-an-agents-live-sessions)). Omit to deliver to every deliverable session. | ```python import requests requests.post( "https://api.bithuman.ai/v1/agent/A12345678/add-context", headers={"Content-Type": "application/json", "api-secret": "YOUR_API_SECRET"}, json={ "context": "Customer has VIP status. Preferred name: Alex. Account since 2021.", "type": "add_context", "room_id": "room-A12345678-x1y2-z3w4", }, ) ``` > **Note** `/speak` and `/add-context` target agents created on the bitHuman > platform that have an **active session** — not local SDK agents. Without a > live room you'll get `404 NOT_FOUND`. Start a session via the > [embed flow](/api/embedding) or a LiveKit worker first, then call > [`GET /v1/agent/{agent_code}/sessions`](#list-an-agents-live-sessions) to see > what is open. ## Error codes | HTTP | Code | When | |---|---|---| | `401` | `UNAUTHORIZED` | Invalid or missing `api-secret`. | | `402` | `INSUFFICIENT_BALANCE` | Not enough credits (generation costs 250 for the v1 models, 500 for Essence 2, 2000 for `expression-2`). | | `404` | `NOT_FOUND` | No agent with the given code (`message`: `"Agent not found for code: "`). | | `404` | `NOT_FOUND` | Agent has no active session to `/speak` or `/add-context` (`message`: `"No active rooms found for agent "`). | | `400` | `VALIDATION_ERROR` | Invalid request body (e.g. bad `type` value, or an invalid / retired `model` name — the error message lists the accepted values). | | `400` | `VIDEO_INPUT_NOT_SUPPORTED` | [Agent creation](#generate-an-agent) with a `video` input. Creation is **image-only** — provide a portrait `image`; the 10-second identity video is generated internally so it loops seamlessly (first frame == last frame). Rejected before anything is billed — never send `video`. | | `503` | `MODEL_NOT_YET_AVAILABLE` | A second-generation family paused for your account. Essence 2 / Expression 2 are **GA** (since July 10, 2026) and open for all accounts, so [creation](#generate-an-agent) and [model add](#add-a-model-to-an-existing-agent) don't return this in normal operation — it's the safety response if a v2 family is ever re-paused. Nothing charged; the v1 families always work. | | `409` | `MODEL_NOT_GENERATED` | A launch surface (embed-token `model`, [talking video](/api/video), [model download](#download-an-agents-model)) requested a family the agent can't be launched as — it's missing from `supported_models`. Trained families: `"agent 's model hasn't been generated yet"`. [Add the model](#add-a-model-to-an-existing-agent) or create the agent with it. | | `409` | `AGENT_NOT_READY` | [`POST /v1/agent/{code}/models`](#add-a-model-to-an-existing-agent) on an agent that is still generating or failed — models can only be added to a `ready` agent. | | `422` | `MODEL_SUBJECT_MISMATCH` | An explicit Essence 2 creation or add whose input isn't a photorealistic human subject — see [the subject gate](#the-essence-2-subject-gate-422). Nothing is billed. | | `422` | `MODEL_PREREQUISITE_MISSING` | [Model add](#add-a-model-to-an-existing-agent) on an agent missing a stored asset the model needs (a stored identity video for `essence-2` — generated internally by Essence creations, never uploaded; image for `expression-2`; image + voice for `expression-1`). | | `400` | `MODEL_NOT_DOWNLOADABLE` | [Model download](#download-an-agents-model) for a family with no per-identity artifact (`expression-1`). | | `404` | `MODEL_ARTIFACT_NOT_READY` | [Model download](#download-an-agents-model) for a supported family whose artifact hasn't been published yet — retryable; the message says when. | See the full [error reference](/api/errors) and the interactive [API reference](/api/reference). --- # Text to speech URL: https://docs.bithuman.ai/api/text-to-speech bitHuman's text-to-speech runs the same in-house voice engine that powers live agents. One `POST` turns text into a WAV you can save or stream on the fly. It supports 30+ languages, ten built-in voices, fine-grained tuning, and **voice codes** — opaque handles for a voice you've designed in the [Voice Designer](https://www.bithuman.ai/voice). ## Authentication Every call uses your bitHuman API secret in the `api-secret` header. Get one at [Developer → API Keys](https://www.bithuman.ai/developer/api-keys) (free tier, no card), then export it so the examples below pick it up: ```bash export BITHUMAN_API_SECRET=your_api_secret ``` ## Synthesize speech `POST https://api.bithuman.ai/v1/tts` returns audio bytes (a WAV by default). **curl** ```bash curl -X POST https://api.bithuman.ai/v1/tts \ -H "api-secret: $BITHUMAN_API_SECRET" \ -H "content-type: application/json" \ -d '{"text": "Hello from bitHuman.", "voice": "F1", "language": "en"}' \ --output voice.wav ``` **Python** ```python import os, requests resp = requests.post( "https://api.bithuman.ai/v1/tts", headers={"api-secret": os.environ["BITHUMAN_API_SECRET"]}, json={"text": "Hello from bitHuman.", "voice": "F1", "language": "en"}, timeout=60, ) resp.raise_for_status() with open("voice.wav", "wb") as f: f.write(resp.content) ``` ### Request fields | Field | Type | Notes | | --- | --- | --- | | `text` | string | **Required.** Any length; multi-sentence is supported. | | `voice` | string | Built-in voice id (`M1`–`M5`, `F1`–`F5`). Defaults to `M1`. | | `voice_code` | string | A designed-voice handle (see [Voice codes](#voice-codes)). Takes precedence over `voice`. | | `axes` | object | Inline tuning — see [Tuning a voice](#tuning-a-voice). Ignored when `voice_code` is set. | | `language` | string | ISO-2 code. 30+ languages supported (call `GET /v1/voices` / the playground for the current list — the advertised count and the live server list don't always match exactly). Defaults to `en`. | | `total_steps` | integer | Quality vs. speed: `5` fast, `8` balanced (default), `12` highest. | | `speed` | number | Playback rate, `0.7`–`2.0`. Defaults to `1.05`. | ## List voices `GET /v1/voices` returns the catalog — ten built-ins (`M1`–`M5`, `F1`–`F5`) plus any custom voices. ```bash curl https://api.bithuman.ai/v1/voices -H "api-secret: $BITHUMAN_API_SECRET" # {"voices":[{"id":"F1","kind":"builtin"}, ... ]} ``` ## Tuning a voice Shape any built-in voice with semantic `axes` — `gender`, `pitch`, `rate`, and `brightness`. Offsets are small (roughly −0.3…0.3); `0` is neutral. Call `GET /v1/studio/axes` for each axis's suggested range and per-voice anchors. ```bash curl -X POST https://api.bithuman.ai/v1/tts \ -H "api-secret: $BITHUMAN_API_SECRET" \ -H "content-type: application/json" \ -d '{ "text": "Tuned, warm, and a touch brighter.", "voice": "F3", "axes": {"gender": 0.1, "pitch": 0.05, "rate": -0.1, "brightness": 0.2} }' \ --output voice.wav ``` ## Voice codes Rather than hand-tuning axes, design a voice from a description in the [Voice Designer](https://www.bithuman.ai/voice) ("a calm meditation guide", "a gruff old captain"). When you open **Use in your app**, you get a **voice code** — a single opaque handle that already encodes the base voice and its tuning. Pass it as `voice_code` and skip `voice`/`axes` entirely: ```bash curl -X POST https://api.bithuman.ai/v1/tts \ -H "api-secret: $BITHUMAN_API_SECRET" \ -H "content-type: application/json" \ -d '{"text": "Hello from my custom voice.", "voice_code": "YOUR_VOICE_CODE"}' \ --output voice.wav ``` A voice code is a UUID (e.g. `f8fb5feb-8a19-435c-89e5-a286a03565ec`). The endpoint expands it to the underlying voice + tuning, so your integration only ever references the code — re-tune the voice in the playground without touching your code path. An unknown or revoked `voice_code` returns `404 VOICE_NOT_FOUND` — handle it rather than assuming a fallback voice. ## Stream and play on the fly `/v1/tts` returns standard WAV bytes, so you can pipe the response straight into a player instead of saving a file — handy for quick local testing: ```bash curl -sN -X POST https://api.bithuman.ai/v1/tts \ -H "api-secret: $BITHUMAN_API_SECRET" \ -H "content-type: application/json" \ -d '{"text": "Playing instantly.", "voice_code": "YOUR_VOICE_CODE"}' \ | ffplay -autoexit -nodisp -i - ``` For sentence-by-sentence streaming of length-prefixed PCM frames (lowest latency for long text), set `"stream": true`. ## OpenAI-compatible endpoint Already calling OpenAI's TTS? Point existing clients at `POST /v1/audio/speech` — swap the base URL to `https://api.bithuman.ai/v1` and the auth header to `api-secret`. See the [API reference](/api/reference#tag/voice) for the full schema. ## Errors `401` means a missing or invalid `api-secret`; `400` is a malformed body; `404` (`VOICE_NOT_FOUND`) means the `voice_code` doesn't resolve to a known voice; `503` means the queue is briefly full — retry with backoff. See [Errors](/api/errors). --- # Dynamics API URL: https://docs.bithuman.ai/api/dynamics ## Overview Dynamics are conversational gesture animations (wave, nod, laugh, idle motions) for an avatar. Generate them asynchronously, then toggle them on to make the gesture model the active one for live sessions. During conversation, gestures fire automatically on keyword mapping — or you can trigger an exact gesture from your code. Dynamics generation costs 250 credits. > **Trigger a specific gesture from your code — in either mode.** Self-hosted: > `runtime.push(VideoControl(action="…"))`. Managed cloud: send a `trigger_dynamics` > RPC to the avatar participant (`avatar.avatar_identity`). Both are deterministic — > no keyword, no randomness. See > [Trigger avatar actions from code](/guides/avatar-actions). ## Generate dynamics `POST /v1/dynamics/generate` — generate movements for an agent. Returns immediately with `processing`; use the GET endpoint to check completion. | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `agent_id` | string | yes | — | Agent ID to generate dynamics for. | | `image_url` | string | no | from agent | Source image URL. Defaults to the agent's primary image. | | `duration` | number | no | `5` | Duration of each motion in seconds. | | `model` | string | no | `seedance` | Gesture-video generation preset: `seedance` (default) or `auto` (recommended — selects the best backend). The retired `kling` value is still accepted but is coerced to `seedance`. | > **Note** The Python examples below use > [`requests`](https://pypi.org/project/requests/), which is not in the standard > library — `pip install requests` first, or use `curl` / `urllib` instead. ```python import requests resp = requests.post( "https://api.bithuman.ai/v1/dynamics/generate", headers={"Content-Type": "application/json", "api-secret": "YOUR_API_SECRET"}, json={"agent_id": "A80HVD8577", "duration": 5, "model": "auto"}, ) print(resp.json()) ``` ```json { "success": true, "message": "Dynamics generation started", "agent_id": "A80HVD8577", "status": "processing" } ``` **Duration guidance:** 1–3 s for quick gestures (waves, nods), 3–5 s for standard motions (default), 5–10 s for extended animations. **Model:** the default is `seedance`; `auto` (recommended) selects the best gesture-video backend automatically. The retired `kling` value is still accepted for backward compatibility but is coerced to `seedance` — don't use it in new code. ## Get dynamics `GET /v1/dynamics/{agent_id}` — list the current dynamics configuration and available gestures for an agent. ```python import requests agent_id = "A80HVD8577" resp = requests.get( f"https://api.bithuman.ai/v1/dynamics/{agent_id}", headers={"api-secret": "YOUR_API_SECRET"}, ) gestures = resp.json()["data"].get("gestures", {}) print(list(gestures.keys())) ``` ```json { "success": true, "data": { "url": "https://assets.bithuman.ai/A80HVD8577/my_agent_20260115_103500_000003.imx", "status": "ready", "agent_id": "A80HVD8577", "gestures": { "mini_wave_hello": "https://assets.bithuman.ai/A80HVD8577/mini_wave_hello_20260115_104000_000004.mp4", "talk_head_nod_subtle": "https://assets.bithuman.ai/A80HVD8577/talk_head_nod_subtle_20260115_104100_000005.mp4", "blow_kiss_heart": "https://assets.bithuman.ai/A80HVD8577/blow_kiss_heart_20260115_104200_000006.mp4" } } } ``` | Field | Type | Description | |---|---|---| | `url` | string \| null | URL to the dynamics model file, or null if not yet generated. | | `status` | string | `generating` while in progress, `ready` when complete. **Also returned as `ready` (with `url: null` and empty `gestures`) for agents whose dynamics were never generated** — treat `url != null`, not `status`, as the has-dynamics signal. | | `agent_id` | string | The agent ID. | | `gestures` | object | Map of gesture action name → video URL. | Before generation completes, `url` is `null` and `gestures` is an empty object. ## Update dynamics `PUT /v1/dynamics/{agent_id}` — update the dynamics configuration. After a successful update, background-movements regeneration is automatically triggered. | Parameter | Type | Required | Description | |---|---|---|---| | `dynamics` | object | yes | Configuration to merge with existing data. | | `dynamics.enabled` | boolean | no | Enable or disable dynamics for this agent. | | `toggle_enabled` | boolean | no | `true` switches to the dynamics model; `false` restores the default talking model. | ```json { "dynamics": { "enabled": true }, "toggle_enabled": true } ``` ```json { "success": true, "message": "Dynamics updated successfully and movements regeneration started", "agent_id": "A80HVD8577", "regeneration_status": "started" } ``` If regeneration fails to start, `regeneration_status` is `failed` and a `regeneration_error` message is included. ## Gesture names Generated gestures use descriptive action identifiers. The exact set depends on what was generated — call `GET /v1/dynamics/{agent_id}` to discover them. | Gesture action | Category | Typical use | |---|---|---| | `mini_wave_hello` | wave | Greeting | | `talk_head_nod_subtle` | nod | Agreement, acknowledgment | | `blow_kiss_heart` | expression | Playful reaction | | `laugh_react` | expression | Humor response | | `idle_subtle` | idle | Background movement | These action names are what you pass to `VideoControl(action=...)` or the `trigger_dynamics` RPC in a live session. ## Error codes | HTTP | Meaning | |---|---| | `400` | Invalid parameters. | | `401` | Unauthorized. | | `402` | Insufficient credits. | | `404` | Agent not found. (An agent **without** dynamics is not a 404 — `GET /v1/dynamics/{agent_id}` returns `200` with `url: null`.) | | `500` | Internal server error. | See the full [error reference](/api/errors) and the interactive [API reference](/api/reference). --- # File Upload API URL: https://docs.bithuman.ai/api/files ## Upload a file `POST /v1/files/upload` — upload a file for processing. Supports both URL downloads and direct base64 uploads. Files are automatically organized by type: | Category | Storage path | Extensions | |---|---|---| | **Images** | `assets/image/` | `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.bmp`, `.svg` | | **Videos** | `assets/video/` | `.mp4`, `.avi`, `.mov`, `.wmv`, `.flv`, `.webm`, `.mkv` | | **Audio** | `assets/audio/` | `.mp3`, `.wav`, `.flac`, `.aac`, `.ogg`, `.m4a` | | **Documents** | `assets/docs/` | `.pdf`, `.doc`, `.docx`, `.txt`, `.ppt`, `.pptx`, `.xls`, `.xlsx`, `.csv` | ## Method 1: URL upload Download a file from a publicly accessible URL. | Parameter | Type | Description | |---|---|---| | `file_url` | string | URL of the file to download. | | `file_type` | string | One of `auto`, `image`, `video`, `audio`, `document`. | > **Note** The Python examples below use > [`requests`](https://pypi.org/project/requests/), which is not in the standard > library — `pip install requests` first, or use `curl` / `urllib` instead. ```python import requests resp = requests.post( "https://api.bithuman.ai/v1/files/upload", headers={"Content-Type": "application/json", "api-secret": "YOUR_API_SECRET"}, json={"file_url": "https://example.com/presentation.pdf", "file_type": "auto"}, ) print(resp.json()) ``` ## Method 2: direct upload Upload base64-encoded file data directly. | Parameter | Type | Description | |---|---|---| | `file_data` | string | Base64-encoded file data. | | `file_name` | string | Original filename with extension. | | `file_type` | string | One of `auto`, `image`, `video`, `audio`, `document`. | ```python import base64, requests with open("document.pdf", "rb") as f: file_data = base64.b64encode(f.read()).decode("utf-8") resp = requests.post( "https://api.bithuman.ai/v1/files/upload", headers={"Content-Type": "application/json", "api-secret": "YOUR_API_SECRET"}, json={"file_data": file_data, "file_name": "document.pdf", "file_type": "auto"}, ) print(resp.json()) ``` ## Response Both methods return the same shape: ```json { "success": true, "message": "File uploaded successfully", "data": { "file_url": "https://assets.bithuman.ai/bithuman/YOUR_USER_ID/assets/docs/20260515_103000_document.pdf", "original_source": "https://example.com/document.pdf", "file_type": "auto", "file_size": 1024000, "mime_type": "application/pdf", "asset_category": "docs", "uploaded_at": "2026-05-15T10:30:00Z" } } ``` Use the returned `file_url` as the `image` or `audio` input to [agent generation](/api/agents). (Agent creation is image-only — `video` files upload fine as assets, but `video` is not a creation input; the 10-second identity video is generated internally.) ## Size limits | Category | Max size | |---|---| | Images | 10 MB | | Videos | 100 MB | | Audio | 50 MB | | Documents | 10 MB | Exceeding a limit returns HTTP `413`. ## URL vs. direct upload | Method | Best for | Pros | Cons | |---|---|---|---| | URL upload | External files, cloud storage | No request-size limit, efficient | Requires a publicly accessible URL | | Direct upload | Local files, form uploads | Works with any file source | Limited by request size | ## Error codes | HTTP | Code | Meaning | |---|---|---| | `400` | `DOWNLOAD_FAILED` | Could not download the URL — ensure it's publicly accessible. | | `401` | `UNAUTHORIZED` | Invalid API secret. | | `413` | `FILE_TOO_LARGE` | File exceeds the size limit for its category. | | `415` | `UNSUPPORTED_TYPE` | File type not supported. Supported: JPEG, PNG, WebP, MP4, WAV, MP3, OGG. | | `500` | `INTERNAL_ERROR` | Server-side error. | See the full [error reference](/api/errors) and the interactive [API reference](/api/reference). --- # Knowledge API URL: https://docs.bithuman.ai/api/knowledge ## Overview The Knowledge API lets you ingest source material and build **knowledge bases (KBs)** your agents answer from — entirely from code. It covers the ingest → create → build → resync loop: upload a file or URL, create a KB from those files, and rebuild it when the sources change. > **Requires an organization API key.** These endpoints are scoped to an organization — > use a key created under [Organizations → API keys](/api/organizations#organization-api-keys). > A personal key returns `403`. Everything you create is owned by the organization and > counts against its plan quotas. Ingestion and builds run **asynchronously**: each call returns immediately with the created row, and conversion/indexing continues in the background. There is no per-call credit charge — limits are your plan's file-count and KB-count quotas, plus a build concurrency cap (1 at a time) and a daily build cap (20/day). Base URL `https://api.bithuman.ai`. Authenticate with the `api-secret` header. ## Ingest a file or URL `POST /v1/knowledge/files` — add one source. Accepts either an uploaded file (multipart) or a URL to fetch (JSON). Returns the created file row at status `uploaded`; conversion runs in the background. **Upload a file** (multipart/form-data): | Field | Type | Required | Description | |-------|------|----------|-------------| | `file` | file | yes | The document bytes. Max 100 MB. | | `name` | string | no | Display name; defaults to the uploaded filename. | **Fetch a URL** (application/json): | Field | Type | Required | Description | |-------|------|----------|-------------| | `url` | string | yes | A public `http(s)` URL to fetch and convert. | | `name` | string | no | Display name; defaults to the URL host. | | `sync_freq` | string | no | Re-sync cadence: `never` (default), `daily`, `weekly`, `monthly`. | ```bash # Upload a file curl -X POST https://api.bithuman.ai/v1/knowledge/files \ -H "api-secret: $ORG_API_SECRET" \ -F "file=@handbook.pdf" -F "name=Employee Handbook" # Or ingest a URL curl -X POST https://api.bithuman.ai/v1/knowledge/files \ -H "api-secret: $ORG_API_SECRET" -H "content-type: application/json" \ -d '{"url":"https://example.com/help","name":"Help Center","sync_freq":"weekly"}' ``` **`201 Created`** — the file row: ```json { "id": "3f2b9c14-8a7e-4d21-9f10-2c5b6e0a11dd", "org_id": "org_9d1f", "file_name": "handbook.pdf", "ext": "pdf", "size_bytes": 428193, "status": "uploaded", "source_kind": "upload", "created_at": "2026-07-15T12:00:00Z" } ``` Use the returned `id` when creating a KB. Errors: `413` file over 100 MB · `422` empty file, missing `file`/`url`, non-public URL, or bad `sync_freq` · `409` `QUOTA_EXCEEDED` (file-count limit reached). ## Create a knowledge base `POST /v1/knowledge/kbs` — create a KB and (by default) kick off its first build. | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | yes | KB name (1–120 chars). | | `description` | string | no | Up to 2000 chars. | | `file_ids` | string[] | no | File `id`s to attach (must be your org's own files). | | `build` | boolean | no | Build immediately after create. Default `true`. | ```bash curl -X POST https://api.bithuman.ai/v1/knowledge/kbs \ -H "api-secret: $ORG_API_SECRET" -H "content-type: application/json" \ -d '{"name":"Support KB","file_ids":["3f2b9c14-8a7e-4d21-9f10-2c5b6e0a11dd"]}' ``` **`201 Created`** — the KB row, plus `file_ids` and `build_dispatched`: ```json { "id": "b71e0a92-4c3d-4f88-8a01-9e2f4c6d7b10", "org_id": "org_9d1f", "name": "Support KB", "status": "stale", "content_rev": 1, "file_ids": ["3f2b9c14-8a7e-4d21-9f10-2c5b6e0a11dd"], "build_dispatched": true } ``` `build_dispatched` is `true` only when `build` was `true`, files were attached, and the build queued successfully. The KB is still created (`201`) otherwise — build it later with the rebuild call. Errors: `404` a `file_id` doesn't exist · `403` a `file_id` isn't yours · `409` `QUOTA_EXCEEDED` (KB-count limit). ## List knowledge bases `GET /v1/knowledge/kbs` — the organization's KBs, newest first. Each row includes computed `agent_count` and `source_count`. ```bash curl https://api.bithuman.ai/v1/knowledge/kbs -H "api-secret: $ORG_API_SECRET" ``` ```json { "kbs": [ { "id": "b71e0a92-4c3d-4f88-8a01-9e2f4c6d7b10", "name": "Support KB", "status": "ready", "content_rev": 2, "built_rev": 2, "artifact_url": "https://…/artifact.zip", "agent_count": 1, "source_count": 3 } ], "total": 1 } ``` ## Rebuild a knowledge base `POST /v1/knowledge/kbs/{kb_id}/rebuild` — re-index a KB after its sources change. Returns the queued build row; indexing runs in the background. ```bash curl -X POST https://api.bithuman.ai/v1/knowledge/kbs/b71e0a92-…/rebuild \ -H "api-secret: $ORG_API_SECRET" ``` ```json { "id": "e5c9a1f7-0b44-4a6e-9c22-77d0b3e18a55", "kb_id": "b71e0a92-4c3d-4f88-8a01-9e2f4c6d7b10", "status": "queued", "started_at": "2026-07-15T12:10:00Z" } ``` Errors: `404` unknown KB · `422` `KB_EMPTY` (no source files) · `409` `BUILD_IN_PROGRESS` (one already running on this KB — the request flags a rebuild-when-done), `BUILD_CONCURRENCY` (another build is running for the account), or `BUILD_DAILY_CAP` (20/day reached). ## Scope & limits The `/v1/knowledge` surface covers ingest → create → build → resync. Status polling, agent attach/detach, sharing, Q&A curation, and build history are managed in the dashboard (the session-authed `/v2/knowledge` surface) and aren't on the developer key yet. --- # Realtime API URL: https://docs.bithuman.ai/api/realtime ## Overview The Realtime API mints a **short-lived client secret** your browser (or client app) uses to open an OpenAI-Realtime voice session directly — without exposing your bitHuman API key or an OpenAI key to the client. Your server calls this endpoint with its `api-secret`, hands the returned `value` to the client, and the client connects to OpenAI Realtime with it. Base URL `https://api.bithuman.ai`. Authenticate with the `api-secret` header. ## Mint an ephemeral token `POST /v1/realtime/ephemeral-token` | Field | Type | Required | Description | |-------|------|----------|-------------| | `model` | string | no | OpenAI Realtime model to request. Defaults to the current bitHuman default (`gpt-realtime-mini`). | ```bash curl -X POST https://api.bithuman.ai/v1/realtime/ephemeral-token \ -H "api-secret: $BITHUMAN_API_SECRET" -H "content-type: application/json" \ -d '{}' ``` **`200 OK`** ```json { "data": { "value": "ek_68f0c2…", "expires_at": 1751500000, "model": "gpt-realtime-mini" }, "status": "success", "status_code": 200 } ``` - `value` — the OpenAI client secret (`ek_…`). Pass it to the client to open the realtime session. - `expires_at` — epoch seconds when the secret expires. Mint a fresh one per session. ## Limits & billing - **Balance-gated:** you must have a positive credit balance to mint (`402 INSUFFICIENT_BALANCE` otherwise). No credits are deducted at mint time — realtime voice is metered per active minute (10 credits/min) once the session runs. See [Pricing](/guides/pricing). - **Rate limit:** up to 20 mints per minute per account (`429 RATE_LIMITED`). - Other errors: `401` missing/invalid key · `404` account not found · `502` if OpenAI is unreachable or rejects the request. > Mint on the **server** and pass only the `value` to the client. Never ship your `api-secret` > to a browser. --- # Providers (BYOK) URL: https://docs.bithuman.ai/api/providers ## Overview The Providers API lets you **bring your own provider keys** (BYOK) — e.g. your own OpenAI or Deepgram key — for your agents to use. Keys are stored **encrypted at rest** and are never returned in plaintext; reads return only presence, a masked hint, and timestamps. Base URL `https://api.bithuman.ai`. Authenticate with the `api-secret` header. The `{user_id}` in the path is your account id — get it from [`GET /v1/me`](/api/billing#account-status). Set it once in your shell before the examples below — with `$USER_ID` unset the paths collapse to `/v2//…` and the API answers `404 {"detail":"Not Found"}`: ```bash export USER_ID=$(curl -s https://api.bithuman.ai/v1/me \ -H "api-secret: $BITHUMAN_API_SECRET" \ | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['user_id'])") ``` ## Get provider config `GET /v2/{user_id}/providers` — your configured providers, secrets masked. ```bash curl "https://api.bithuman.ai/v2/$USER_ID/providers" \ -H "api-secret: $BITHUMAN_API_SECRET" ``` ```json { "user_id": "3f9a…", "providers": { "entries": [ { "id": "b7e1…", "platform": "openai", "label": "OpenAI", "options": {}, "secrets": { "apiKey": { "present": true, "hint": "sk…4f2a", "updated_at": "2026-07-02T09:30:00Z" } } } ] } } ``` Sensitive keys (`apiKey`, `api_key`, `secret`, `apiSecret`, `clientSecret`, `accessToken`, `privateKey`) are returned only as `{present, hint, updated_at}` — never in full. ## Update provider config `PUT /v2/{user_id}/providers` — replace your provider list. Send the full set of entries you want stored. **Body:** `{ "entries": ProviderEntry[] }`. Each entry: | Field | Type | Required | Description | |-------|------|----------|-------------| | `platform` | string | yes | Provider id, e.g. `openai`, `deepgram`. | | `id` | string | no | Existing entry id to update; omit to add a new one. | | `label` | string | no | Display name; defaults to the platform name. | | `credentials` | object | no | Key/value credentials. Sensitive keys are encrypted at rest. | | `options` | object | no | Per-capability configuration. | | `clearSecrets` | string[] | no | Sensitive credential keys to remove. | | `metadata` | object | no | Freeform metadata. | ```bash curl -X PUT "https://api.bithuman.ai/v2/$USER_ID/providers" \ -H "api-secret: $BITHUMAN_API_SECRET" -H "content-type: application/json" \ -d '{"entries":[{"platform":"openai","label":"OpenAI","credentials":{"apiKey":"sk-…"}}]}' ``` Returns the same masked shape as `GET`. Existing entries are matched by `id` and merged (unspecified credentials are preserved); new entries get a generated id. > **`entries` replaces the whole list.** Sending `{"entries": []}` **clears all** providers — > to edit one provider, send the full set you want to keep. To drop a single secret, use > `clearSecrets`. Errors: `401`/`403` auth · `404` account not found · `500` on a storage failure. ## Point an agent at your provider Registering a key does **not** change any agent by itself — it just stores the credential. Each agent chooses its providers independently, so the last step is to attach the entry you registered to the agent that should use it. `POST /v1/agent/{agent_code}` — set the per-capability selection. Send only the capabilities you want to change; the rest of the agent's settings are preserved. | Capability | Value | Meaning | |---|---|---| | `llm` / `stt` / `tts` / `realtime` | `"default"` | Use the bitHuman-managed provider (the default). | | `llm` / `stt` / `tts` / `realtime` | `{"mode": "custom", "provider_id": ""}` | Use one of your registered entries. | `provider_id` is the `id` returned by `GET`/`PUT /v2/{user_id}/providers`. ```bash curl -X POST https://api.bithuman.ai/v1/agent/A12345678 \ -H "api-secret: $BITHUMAN_API_SECRET" -H "content-type: application/json" \ -d '{"providers": {"llm": {"mode": "custom", "provider_id": "b7e1…"}}}' ``` ```json { "agent_code": "A12345678", "updated": true } ``` A `provider_id` you have not registered is rejected with `400 VALIDATION_ERROR` naming the id — the selection is never stored half-configured. The change applies to the **next session**; a call already in progress keeps the provider it started with. ### OpenAI-compatible endpoints (self-hosted, proxies, gateways) Any endpoint that speaks the OpenAI chat-completions API works: register it with `platform: "openai"` and put your endpoint in `credentials.baseUrl`. ```bash curl -X PUT https://api.bithuman.ai/v2/$USER_ID/providers \ -H "api-secret: $BITHUMAN_API_SECRET" -H "content-type: application/json" \ -d '{"entries":[{ "platform": "openai", "label": "My gateway", "credentials": {"apiKey": "…", "baseUrl": "https://llm.example.com/v1"}, "options": {"llm": {"model": "my-model"}} }]}' ``` - **`baseUrl` is the base, not the full route.** Use `https://llm.example.com/v1` — we append `/chat/completions` ourselves. A pasted full endpoint is tolerated and trimmed. - **`options.llm.model`** is the model name we send in the request body. - **There is no host allowlist.** Any reachable HTTPS endpoint is called as configured; you do not need to ask us to permit a domain. ### Knowing which end user a call belongs to Requests to your endpoint carry the visitor's identifier in the standard OpenAI `user` field, so you can attribute a call without any bitHuman-specific parsing: ```json { "model": "my-model", "messages": [ … ], "stream": true, "user": "29830a59917f82805468b3170b0d9082" } ``` The value is resolved in this order, and the first one present wins: 1. the **`endUserId`** claim in the embed token you minted (also accepted as `fingerprint`) — see [Embedding](/api/embedding#production-mint-a-token); 2. an explicit **`?end_user_id=`** on the embed URL (aliases: `endUserId`, `visitor_id`, `fingerprint`); 3. the **session correlator** — a per-conversation identifier we generate. Options 1 and 2 are *durable*: mint or pass the same string for a returning visitor and you get the same value on every call, across sessions. Option 3 is the automatic fallback so the field is **never empty** for your endpoint, but it changes each session — if you see a value that varies per conversation, that means no durable identifier reached us and you should set one. > **Fixed 2026-08-18.** Embed tokens carrying `endUserId` were re-minted without > the claim before reaching the runtime, so customers who followed option 1 > received the per-session correlator instead of their own identifier. The claim > is now carried through. If you supply `endUserId` and still see a changing > value, tell us — that is a bug, not a configuration issue. Two limits worth knowing: - **Sent only to your own endpoint.** Agents on bitHuman's default LLM do not send it, and neither does the fallback if your endpoint is unreachable. - **Treat it as a hint, not an authenticated identity.** The value originates in the page that embeds the agent, so a determined visitor can change it. Use it to group and attribute traffic; do not use it alone to authorise access to one user's data. Each request also carries `X-LiveKit-Room-ID` and `X-LiveKit-Job-ID` headers. Those identify the live session, not the person, and are useful for correlating a single conversation's calls. --- # Video API URL: https://docs.bithuman.ai/api/video ## Overview The Video API renders a complete **talking-video mp4** of one of your agents speaking — from a **text** script (the agent's voice synthesizes it) or from a **hosted audio** file. It is asynchronous by default: submit a job, then poll for the finished video URL — or pass [`wait: true`](#blocking-mode-wait-true) for a blocking render that returns the mp4 in the response. On success you get a public CDN URL, the output duration, and the credits charged. `essence-2` renders at **1080p** — `1080×1920` portrait or `1920×1080` landscape, matching the source orientation and capped at the source's long side. `expression-2` renders at its native `416×720`. Talking videos bill **per minute of output, rounded up**: `expression-1`, `expression-2`, and `essence-2` are 4 credits/min; `essence-1` is 2 credits/min (`essence-2` is the standard render; the former `essence-2-light` name is retired). **How the charge actually lands.** Submitting a job charges the **120-second cap** up front — 2 × the per-minute rate — then refunds the difference once the real duration is known. So a 6-second `essence-2` render moves your balance `−8` then `+4`, settling at the documented 4 credits, and you need **8** credits free at submit time, not 4. Two consequences worth designing for: - a `402 INSUFFICIENT_BALANCE` at submit reflects the *up-front cap*, so it can fire even when your balance covers the render's true cost; - a `credit_refund_…` row appears in [`GET /v1/usage`](/api/billing) for **every** render, successful or not. On success it is the true-up of the over-charge; only a *full* refund of the up-front amount means the render failed. Compare the refund to the charge, don't treat any refund as a failure. Limits: up to **120 seconds** of output and **5000 characters** of text. ## Generate a talking video `POST /v1/video/generate` — submit a render job. **Async by default:** returns immediately with a `job_id` and `status: "processing"`; poll the GET endpoint for completion. Pass **`wait: true`** for [blocking mode](#blocking-mode-wait-true) — the call holds the connection until the render finishes and returns the finished mp4 directly. | Parameter | Type | Required | Description | |---|---|---|---| | `model` | string | yes | Engine: `essence-1`, `expression-1`, `expression-2`, or `essence-2`. All four render talking video today. | | `agent_code` | string | yes | An agent you own — supplies the avatar identity (and, for text, the default voice). | | `input` | object | yes | The render source — see below. | | `input.type` | string | yes | `text` or `audio`. | | `input.text` | string | for text | Script to speak (≤ 5000 chars). | | `input.voice` | string | no | Voice id override for text input. Defaults to the agent's own voice. | | `input.audio_url` | string | for audio | Public URL to a WAV or MP3 file. | | `wait` | boolean | no | Blocking mode. `false` (default) returns a `job_id` to poll. `true` blocks until the render finishes (up to ~90s) and returns the finished `video_url` — plus `duration_seconds` and `credits_charged` — directly in this response; if it exceeds the cap you get the async `{ job_id }` to poll instead. Accepted as a JSON/multipart field or as a `?wait=true` query parameter. | ### Text input > **Note** The Python examples below use > [`requests`](https://pypi.org/project/requests/), which is not in the standard > library — `pip install requests` first, or use `curl` / `urllib` instead. ```python import requests resp = requests.post( "https://api.bithuman.ai/v1/video/generate", headers={"Content-Type": "application/json", "api-secret": "YOUR_API_SECRET"}, json={ "model": "essence-2", "agent_code": "A80HVD8577", "input": {"type": "text", "text": "Hello, welcome to bitHuman."}, }, ) print(resp.json()) ``` ```json { "success": true, "job_id": "vid_3f9a2c1b8e7d4a6f0b21", "status": "processing" } ``` ### Audio input ```python resp = requests.post( "https://api.bithuman.ai/v1/video/generate", headers={"Content-Type": "application/json", "api-secret": "YOUR_API_SECRET"}, json={ "model": "expression-2", "agent_code": "A80HVD8577", "input": {"type": "audio", "audio_url": "https://example.com/speech.wav"}, }, ) print(resp.json()) ``` ### Blocking mode (`wait: true`) Add `"wait": true` (a JSON/multipart field, or `?wait=true` as a query parameter) to hold the connection until the render finishes and get the mp4 back in the same response — no polling. If the render exceeds the ~90-second cap you get the async `{ job_id }` to poll instead. ```python resp = requests.post( "https://api.bithuman.ai/v1/video/generate", headers={"Content-Type": "application/json", "api-secret": "YOUR_API_SECRET"}, json={ "model": "essence-2", "agent_code": "A80HVD8577", "input": {"type": "text", "text": "Hello, welcome to bitHuman."}, "wait": True, }, ) print(resp.json()) ``` ```json { "success": true, "job_id": "vid_3f9a2c1b8e7d4a6f0b21", "status": "completed", "video_url": "https://assets.bithuman.ai/.../vid_3f9a2c1b8e7d4a6f0b21.mp4", "duration_seconds": 6.5, "credits_charged": 4 } ``` A `402` (`INSUFFICIENT_BALANCE`) is returned at submit time if your balance can't cover the render. An invalid `model`, a missing/invalid `input`, or text over the limit returns `400` before any charge. Requesting a model the agent can't be launched as returns [`409 MODEL_NOT_GENERATED`](/api/errors#model-errors) — also **before any charge**: for `expression-2` / `essence-2` that means the trained per-identity model doesn't exist yet (`agent 's model hasn't been generated yet`); `essence-1` needs the agent's `.imx` model file (present on every completed essence-1 creation), and `expression-1` needs an expression-1 agent — or the free, instant expression-1 model add on any agent with an image and a voice ([how](/api/agents#using-expression-1-on-an-existing-agent)). Every 409 here names the call that fixes it. Check the agent's `supported_models` on the [Agents API](/api/agents#poll-status), or [add the model](/api/agents#add-a-model-to-an-existing-agent) first. ## Get talking-video status `GET /v1/video/{job_id}` — poll a render job. ```python import requests job_id = "vid_3f9a2c1b8e7d4a6f0b21" resp = requests.get( f"https://api.bithuman.ai/v1/video/{job_id}", headers={"api-secret": "YOUR_API_SECRET"}, ) print(resp.json()) ``` While rendering (note: job responses echo the **public** model name you requested — `essence-2` reads back as-is): ```json { "success": true, "job_id": "vid_3f9a2c1b8e7d4a6f0b21", "status": "processing", "model": "essence-2" } ``` When complete: ```json { "success": true, "job_id": "vid_3f9a2c1b8e7d4a6f0b21", "status": "completed", "model": "essence-2", "video_url": "https://assets.bithuman.ai/.../vid_3f9a2c1b8e7d4a6f0b21.mp4", "duration_seconds": 6.5, "credits_charged": 4 } ``` | Field | Type | Description | |---|---|---| | `status` | string | `processing`, `completed`, or `failed`. | | `model` | string | The engine used. | | `video_url` | string | Public mp4 URL (present when `completed`). | | `duration_seconds` | number | Output duration (present when `completed`). | | `credits_charged` | integer | Credits charged for this render (present when `completed`). | | `error` | object | Failure detail (present when `failed`); the charge is refunded. | > **Note** Treat `video_url` as **opaque** — read it from the response, never > construct it. The sample bodies above abbreviate it, but finished renders are > currently served from the object-storage host > (`https://.supabase.co/storage/v1/object/public/bithuman//.mp4`), > not from `assets.bithuman.ai`. If you allowlist egress hosts or proxy the > download, allowlist what the API returns. ## Polling pattern ```python import time, requests def wait_for_video(job_id, api_secret, timeout=600): while timeout > 0: r = requests.get( f"https://api.bithuman.ai/v1/video/{job_id}", headers={"api-secret": api_secret}, ).json() if r["status"] == "completed": return r["video_url"] if r["status"] == "failed": raise RuntimeError(r.get("error")) time.sleep(3) timeout -= 3 raise TimeoutError("render did not finish in time") ``` See [Talking video generation](/concepts/talking-video) for the concept and [Billing & credits](/api/billing) for how credits are consumed. --- # Embedding API URL: https://docs.bithuman.ai/api/embedding ## Embed an avatar Drop an agent onto any page as an iframe — no SDK install required: ```html ``` Replace `A78WKV4515` with your agent code. > **Warning** The iframe needs delegated `microphone` permission to hear the > user — and the `*` in the `allow` attribute is load-bearing. The embed URL > redirects cross-origin to `agent.viewer.bithuman.ai`, so a bare > `allow="microphone"` (which pins to the iframe's `src` origin) leaves the > mic silently blocked after the redirect. Use `microphone *` (or allowlist > `https://agent.viewer.bithuman.ai` explicitly). The same applies if the > embedding page sets a restrictive `Permissions-Policy`. ## Production: mint a token For per-visitor session tracking and rate limiting, mint a short-lived embed token on your **backend** (never expose your API secret in frontend code) and append it to the iframe URL. `POST /v1/embed-tokens/request` | Field | Type | Required | Description | |---|---|---|---| | `agent_id` | string | yes | Agent code (e.g. `A78WKV4515`). | | `fingerprint` | string | yes | Stable per-visitor hex string. Used for per-visitor rate limiting, to key the agent's conversation memory so a returning visitor is recognised, and — if you run your own LLM — sent to your endpoint as the OpenAI `user` field so you can tell whose call it is ([details](/api/providers#knowing-which-end-user-a-call-belongs-to)). Supply one value per end user and reuse it across their visits. | | `model` | string | no | Request a specific avatar model for the session — a model name (`essence-1`, `expression-1`, `essence-2`, `expression-2`) or a force-tier slug (`essence-2-gpu/-ane/-cpu`, `expression-2-gpu/-cpu/-ane` — [per model](/concepts/models-v2#advanced-pin-a-serving-tier)). Validated **early**: unknown values return `400` listing the accepted names; requesting a family the agent can't be launched as (missing from its `supported_models` — a trained model that doesn't exist yet) returns [`409 MODEL_NOT_GENERATED`](/api/errors#model-errors) instead of a failed session later. Omitted → the agent's own default model. | > **Reading `supported_models` back into `model`.** The mint response (and > `GET /v1/agent/status/{id}`) returns `supported_models`. Every entry is a > **public** model name and can be sent back verbatim — the internal tier > spellings `essence-2-light` / `essence-2-quality` are folded before the > response is built, so they never appear in the array. ```js // server: mint token (api-secret never reaches the browser) const res = await fetch("https://api.bithuman.ai/v1/embed-tokens/request", { method: "POST", headers: { "api-secret": process.env.BITHUMAN_API_SECRET, "content-type": "application/json", }, body: JSON.stringify({ agent_id: "A78WKV4515", fingerprint: visitorFingerprint, // stable per-device hex }), }); const { data: { token } } = await res.json(); ``` ### Response ```json { "status": "success", "status_code": 200, "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "sid": "f3c9...", "supported_models": ["essence-2", "expression-2"] } } ``` The `token` is a **1-hour, HS256-signed JWT**. Mint one per visitor session. `supported_models` lists the canonical model families the agent can be launched as right now (useful for building your own model picker); when you requested a `model`, the response also echoes the `model` baked into the token. ### Use the token in the iframe Pass it as a query string (or as the `data-token` attribute on the embed widget script tag): ```html ``` ## Notes - The embed token is more constrained than a [runtime token](/api/authentication) — it's purpose-built for cross-origin iframe authentication. - WebRTC requires a secure context: serve the embedding page over **HTTPS** or the browser will block microphone access (except on `localhost`). - The `fingerprint` should be generated once per device and persisted, so per-visitor rate limits track the same visitor across sessions. See the interactive [API reference](/api/reference) for the full request and response schema. --- # Billing API URL: https://docs.bithuman.ai/api/billing ## The credits model bitHuman bills in **credits**. The free tier includes 99 credits per month (no credit card); paid plans range from $20/month (Creator) to $999/month (Enterprise), plus contact-sales Custom for on-prem / volume. Top-up credits are $1 = 100 credits and never expire. Some operations are one-time costs; live sessions bill per minute. | Action | Cost | |---|---| | Agent generation — v1 models (`essence-1`, `expression-1`) | 250 credits (one-time, per avatar) | | Agent generation — Essence 2 (`essence-2` combined; Max included) | 500 credits (one-time, per avatar) | | Agent generation — Expression 2 (`expression-2`) | 2000 credits (one-time, per avatar) | | Agent generation — `auto` | The routed model's rate (500 or 2000) | | [Add a model to an existing agent](/api/agents#add-a-model-to-an-existing-agent) | Same per-model rates (250 / 500 / 2000); adding `expression-1` is **free** (instant enablement, no training) | | Dynamics generation (one-time, per avatar) | 250 credits | | Book creation (one-time, per book) | 250 credits | | Talking video — Essence 1 | 2 credits/min (rounded up) | | Talking video — Expression 1, Expression 2, Essence 2 | 4 credits/min (rounded up) | | Live session — Essence 1, self-hosted | 1 credit/min | | Live session — Essence 1, cloud | 2 credits/min | | Live session — Expression 1 / Expression 2 / Essence 2, self-hosted | 2 credits/min | | Live session — Expression 1 / Expression 2 / Essence 2, cloud | 4 credits/min | | Voice chat (managed agent, no avatar) | 10 credits/min | | Camera chat (managed agent, camera on) | 30 credits/min | See the [Pricing guide](/guides/pricing) for the full plan ladder (Free, Creator, Pro, Business, Enterprise, Custom) and annual pricing. ## Account status `GET /v1/me` — your identity, plan, and current credit balance in one call. Handy as a pre-flight check (it's what the CLI uses) and to look up your `user_id` for the account endpoints. ```bash curl https://api.bithuman.ai/v1/me -H "api-secret: $BITHUMAN_API_SECRET" ``` ```json { "data": { "user_id": "3f9a…-uuid", "email": "user@example.com", "plan": "pro", "plan_code": "membership_pro", "credit_balance": 1240, "plan_credits_remaining": 1000, "topup_credits_remaining": 240, "account_status": "active" }, "status": "success", "status_code": 200 } ``` Read-only, no billing side effects. `credit_balance` is the sum of your plan and top-up credits. Use `user_id` in the `/v2/{user_id}/…` account endpoints ([API keys](/api/api-keys), [Runtime sessions](/api/runtime-sessions), [Providers](/api/providers)). ## Get the pricing schedule `GET /v1/pricing` — the machine-readable credit schedule, so you can estimate cost before a billable call. Agent creation is priced **per model** — read `agent_generation.by_model` (there is no flat creation rate): ```bash curl https://api.bithuman.ai/v1/pricing \ -H "api-secret: $BITHUMAN_API_SECRET" ``` ```json { "success": true, "data": { "unit": "credits", "agent_generation": { "unit": "credits", "by_model": { "essence-1": 250, "expression-1": 250, "expression-2": 2000, "essence-2": 500, "auto": 2000 }, "note": "One-time charge per agent created via POST /v1/agent/generate, PER MODEL …" }, "talking_video": { "unit": "credits_per_minute", "billing": "ceil(minutes) * rate, minimum 1 minute", "rates": { "essence-1": 2, "expression-1": 4, "essence-2": 4, "expression-2": 4 } }, "dynamics_generation": { "flat": 250, "note": "…" }, "notes": "Authoritative charges are enforced server-side at request time. …" } } ``` `by_model` keys are the **canonical** `model` values `POST /v1/agent/generate` accepts. The retired `essence-2-quality` spelling is **no longer accepted** (removed 2026-07-29); use `essence-2`. `essence-2` is the [photorealistic Essence 2 creation](/api/agents#essence-2--the-photorealistic-creation) (one 500-credit charge) and `auto` [classifies and routes](/api/agents#auto--let-the-platform-pick-the-model), charging the routed model's rate — 500 for `essence-2`, 2000 for `expression-2` (the `auto` entry in `by_model` shows the worst case). [Post-generation model adds](/api/agents#add-a-model-to-an-existing-agent) charge the same per-model rates (adding `expression-1` is free). Authoritative charges are always enforced server-side — treat this endpoint as an estimate and reference. ## Check credit balance `GET /v2/credit-summaries` — returns the live balance for the **authenticated account** (the owner of the `api-secret`), broken down by plan vs. topup credits, plus an estimate of how many minutes of each session type it can afford at current rates. Safe to call frequently (cached read-through, no side effects). The endpoint always returns the caller's own balance; there is no way to look up another user. (A `user_id` query param is silently ignored — it does not switch accounts and never returns `404`.) | Query param | Type | Required | Default | Description | |---|---|---|---|---| | `app` | string | no | `imaginex` | App identifier for multi-app subscription support. | | `app_key` | string | no | same as `app` | Explicit subscription key for collection-scoped apps. | ```bash # Your own balance — just the key: curl https://api.bithuman.ai/v2/credit-summaries \ -H "api-secret: $BITHUMAN_API_SECRET" ``` ```json { "success": true, "data": { "user_id": "229be55d-1c1e-42b9-8517-a22c742668ef", "balance": 1842.0, "plan_credits": 99.0, "topup_credits": 1743.0, "is_enterprise": false, "minutes_estimate": { "essence_2_cloud": 460, "essence_2_self_hosted": 921, "expression_2_cloud": 460, "expression_2_self_hosted": 921, "essence_1_cloud": 921, "essence_1_self_hosted": 1842, "expression_1_cloud": 460, "expression_1_self_hosted": 921, "voice_chat": 184, "camera_chat": 61, "essence_cloud": 921, "essence_self_hosted": 1842, "expression_cloud": 460, "expression_self_hosted": 921 }, "isEnterprisePlanUser": false } } ``` ### Response fields | Field | Type | Notes | |---|---|---| | `balance` | number (float) | Sum of plan + topup + reward credits, returned as a float (e.g. `5910592.0`). Can go negative down to `-11` (grace window before suspension). | | `plan_credits` | number | Remaining credits from the active subscription; resets at billing-period end. | | `topup_credits` | number | Credits from one-time top-ups; do not reset. | | `is_enterprise` | boolean | `true` for org-pooled (enterprise) billing. | | `isEnterprisePlanUser` | boolean | Backward-compat alias for `is_enterprise` — prefer the snake_case field. | | `minutes_estimate` | object | Floor-division of `balance` by each mode's credits/min rate. | There is one `minutes_estimate` key per serving mode. **The rate differs by model** — read the key for the model you actually run: | Key | Meaning | Rate | |---|---|---| | `essence_2_cloud` | Essence 2 on bitHuman cloud | balance ÷ 4 | | `essence_2_self_hosted` | Essence 2 on your hardware | balance ÷ 2 | | `expression_2_cloud` | Expression 2 on bitHuman cloud | balance ÷ 4 | | `expression_2_self_hosted` | Expression 2 on your hardware | balance ÷ 2 | | `essence_1_cloud` | Essence 1 on bitHuman cloud | balance ÷ 2 | | `essence_1_self_hosted` | Essence 1 on your hardware | balance ÷ 1 | | `expression_1_cloud` | Expression 1 on bitHuman cloud | balance ÷ 4 | | `expression_1_self_hosted` | Expression 1 on your hardware | balance ÷ 2 | | `voice_chat` | Managed cloud agent, no avatar | balance ÷ 10 | | `camera_chat` | Managed cloud agent, camera on | balance ÷ 30 | > **`essence_*` and `expression_*` without a version are Essence 1 and Expression 1** > > `essence_cloud`, `essence_self_hosted`, `expression_cloud` and > `expression_self_hosted` predate the second-generation models and are aliases of > the `essence_1_*` / `expression_1_*` rows above. **They are not the Essence 2 > rate.** If you serve Essence 2 and read `essence_cloud`, you will > over-estimate your remaining minutes by 2x. Use the > model-specific key, or compute from the > [serving rates](/guides/pricing#serving--credits-per-live-minute). Estimates are advisory. The authoritative charge is always computed server-side at request time from the live rate schedule. ## Usage history `GET /v1/usage` returns your account's metered events, newest first. Paginate with `limit` (default 50, max 200) and `offset`; narrow with `start` / `end` (ISO-8601 timestamps) and `agent_code`. > **Note** The Python examples below use > [`requests`](https://pypi.org/project/requests/), which is not in the standard > library — `pip install requests` first, or use `curl` / `urllib` instead. ```python import requests resp = requests.get( "https://api.bithuman.ai/v1/usage", headers={"api-secret": "YOUR_API_SECRET"}, params={"limit": 50, "start": "2026-06-01T00:00:00Z"}, ).json() for ev in resp["data"]: print(ev["created_at"], ev["pricing_code"], ev["credits_change"]) print(resp["pagination"]) # {limit, offset, total, has_more} ``` Each row carries `activity_type`, `pricing_code`, `agent_code`, `created_at`, and `credits_change` — the signed credit delta (usage events are recorded as **positive** credits consumed). This is an audit trail; for an authoritative balance use `GET /v2/credit-summaries` above. > **Note — a `credit_refund_…` row does not mean something failed.** > [Talking-video renders](/api/video) charge the 120-second cap up front and > then refund the overcharge, so **every** render — successful ones included — > writes a matched pair. A real 6-second `essence-2` render looks like this: > > ```text > usage_talking_video_essence_2_by_api 8 > credit_refund_usage_talking_video_essence_2_by_api 4 ← true-up, not a failure > ``` > > Net 4 credits, which is the published rate. Only a refund equal to the **full** > up-front charge means the render failed. Reconcile refunds against their > charge rather than alerting on the `credit_refund_` prefix. ## Notes - **Balance is the source of truth**, not the sum of activity rows. The activity ledger is a best-effort audit trail; sub-cent rounding and historical drift mean it can differ from `balance` by small amounts. Quote `balance` to users. - The minute estimates use floor-division on the balance and treat the suspension grace window (`-11..0`) as zero minutes. - For suspension-status UI, compare `balance` to the documented threshold `-11` rather than relying on a separate flag. - Check your balance before heavy operations (agent generation at 250–2000 credits per model, or dynamics at 250) to avoid wasted calls that fail with `402` — [`GET /v1/pricing`](#get-the-pricing-schedule) gives the exact per-model rates. ## Errors | HTTP | Code | When | |---|---|---| | `401` | `UNAUTHORIZED` / `MISSING_AUTH` | Missing or invalid `api-secret`. | | `402` | `INSUFFICIENT_BALANCE` | Balance too low for the requested operation. | | `500` | `INTERNAL_ERROR` | Upstream database error. | See [Rate limits](/api/rate-limits) for the plan-tiered request limits and the full [error reference](/api/errors). ## Next steps - [Pricing & credits](/guides/pricing) — how credits and per-minute rates work - [Rate limits](/api/rate-limits) — quotas and limits per endpoint --- # Webhooks URL: https://docs.bithuman.ai/api/webhooks Agent generation is asynchronous — a few minutes for the first-generation models, roughly 45 minutes to 1.5 hours for the second generation. Instead of polling `GET /v1/agent/status/{id}`, register a **webhook** and bitHuman will POST a signed event to your endpoint the moment the work finishes. ## Events | Event | Fires when | |-------|------------| | `agent.ready` | An agent finished generating and is ready to use. | | `agent.failed` | Agent generation failed (`data.error` has the reason). | More event types will be added over time. Subscribe to a subset, or omit `events` (or pass `[]`) to receive all of them. ## Register an endpoint ```bash curl -X POST https://api.bithuman.ai/v1/webhooks \ -H "api-secret: $BITHUMAN_API_SECRET" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/bithuman/webhook", "events": ["agent.ready", "agent.failed"], "description": "prod" }' ``` ```json { "success": true, "data": { "id": "f2cd30a2-…", "url": "https://example.com/bithuman/webhook", "events": ["agent.ready", "agent.failed"], "active": true, "secret": "whsec_60fe3d…" } } ``` > The `secret` is returned **only once**. Store it — it signs every delivery and > is redacted from all later responses. ## What a delivery looks like ```http POST /bithuman/webhook HTTP/1.1 Content-Type: application/json X-BitHuman-Event: agent.ready X-BitHuman-Delivery: evt_a1b2c3… X-BitHuman-Timestamp: 1780417371 X-BitHuman-Signature: sha256=9f86d081… {"id":"evt_a1b2c3…","event":"agent.ready","created":1780417371,"data":{"agent_id":"A80HVD8577","code":"A80HVD8577"}} ``` ## Verify the signature Compute `HMAC-SHA256` over `"{timestamp}.{raw_body}"` using your secret and compare it — in constant time — to the hex digest in `X-BitHuman-Signature`. Reject anything older than a few minutes to prevent replays. ```python import hashlib, hmac, time def verify(secret: str, headers, raw_body: bytes) -> bool: ts = headers["X-BitHuman-Timestamp"] if abs(time.time() - int(ts)) > 300: # 5-minute replay window return False sent = headers["X-BitHuman-Signature"].removeprefix("sha256=") expected = hmac.new( secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(sent, expected) ``` Always verify against the **raw** request body — re-serializing the JSON will change the bytes and break the signature. ## Delivery & retries - Respond `2xx` quickly (within 10s). Do heavy work asynchronously. - Failed deliveries are retried up to **3 times** with backoff. - Every attempt is logged. Inspect recent attempts: ```bash curl https://api.bithuman.ai/v1/webhooks/{id}/deliveries \ -H "api-secret: $BITHUMAN_API_SECRET" ``` ## Manage webhooks ```bash # Send a test ping to confirm reachability curl -X POST https://api.bithuman.ai/v1/webhooks/{id}/test -H "api-secret: $BITHUMAN_API_SECRET" # List curl https://api.bithuman.ai/v1/webhooks -H "api-secret: $BITHUMAN_API_SECRET" # Delete curl -X DELETE https://api.bithuman.ai/v1/webhooks/{id} -H "api-secret: $BITHUMAN_API_SECRET" ``` See the [API reference](/api/reference#tag/webhooks) for the full schema. --- # Rate limits & quotas URL: https://docs.bithuman.ai/api/rate-limits ## Request limits API requests are rate-limited **per account** (every API secret on the same account shares the same buckets) by endpoint **cost tier**, and the limits scale with your plan. Each cell below is requests per minute, implemented as a token bucket — the per-minute number is also the burst capacity, and it refills continuously at that rate. | Cost tier | Free | Creator | Pro | Business | Enterprise* | |---|---|---|---|---|---| | **Generate** | 4 | 10 | 30 | 60 | 120 | | **Write** | 30 | 60 | 180 | 360 | 720 | | **Read** | 120 | 240 | 720 | 1440 | 2880 | \* Enterprise defaults shown — custom limits are available; [talk to sales](https://www.bithuman.ai/sales). What each cost tier covers: | Cost tier | Covers | Examples | |---|---|---| | **Generate** | Heavy generation jobs | `POST /v1/agent/generate`, `POST /v1/dynamics/generate`, video and book generation | | **Write** | Every other `POST` / `PUT` / `PATCH` / `DELETE`, including TTS synthesis | prompt / context / speak / file uploads, `POST /v1/tts` | | **Read** | `GET` requests | `GET /v1/agent/status/*`, voice lists, `GET /v2/credit-summaries` | Your column is determined by your subscription; accounts without one get the Free limits. Plan changes reach the limiter within about a minute — no key rotation needed. Check your plan and keys at [Developer → API Keys](https://www.bithuman.ai/developer/api-keys). Exceeding a bucket returns `429` with a `Retry-After` header (see [Response headers](#response-headers)) and the standard [error envelope](/api/errors): ```json { "error": { "code": "RATE_LIMITED", "message": "Too many generate requests for this api-secret. Retry in ~6s.", "httpStatus": 429 }, "status": "error", "status_code": 429 } ``` ### Never rate-limited Two surfaces are deliberately exempt from the request limiter: - **Webhooks** — webhook traffic is never rate-limited, so signed event deliveries and their retries always go through. - **Live-session heartbeats** — the runtime-token routes (`/v1/runtime-tokens*`, `/v1/runtime/*`) that keep a live avatar session authenticated and billing. An active session is never cut off with a `429`; live usage is bounded by your credit balance and spend caps instead. ### Failed authentication Repeated failed authentication on key-authenticated endpoints is throttled **per client IP** at 30 failures per minute — once exceeded, further attempts return `429` until the window clears. Requests with a valid secret are never affected by this throttle. Anonymous, self-authenticating endpoints (token mints, `/v1/me`, CLI login) carry an additional per-IP limit of 120 requests/minute. ## Session concurrency Each plan includes an allowance of **concurrent avatar sessions** — live sessions running at the same time, across cloud and self-hosted: | Plan | Concurrent avatar sessions | |---|---| | Free | 1 | | Creator | 3 | | Pro | 10 | | Business | 50 | | Enterprise | 200 | | Custom (contact sales) | Unlimited | Sessions beyond the allowance are rejected at session start with `403 CONCURRENCY_LIMIT_REACHED` (enforcement is rolling out; a session that is already live is never cut off mid-stream by this limit). Within the allowance, usage is governed by **credits and spend caps** — each active session bills per minute, so run as many as your balance supports. | Resource | Limit | Notes | |---|---|---| | Cloud avatar sessions | Plan allowance + credits | Each active session bills per minute. | | Agent generation | Queued | Heavy jobs queue and run as capacity frees up. | | Dynamics generation | Queued | Heavy jobs queue and run as capacity frees up. | Self-hosted deployments render on your own hardware but count toward the same per-plan session allowance. ## Credit rates Live sessions bill per minute by model and host; some operations are one-time. | Feature | Credits/min | |---|---| | Voice chat (managed agent, no avatar) | 10 | | Camera chat (managed agent, camera on) | 30 | | Essence 1 — cloud | 2 | | Essence 1 — self-hosted | 1 | | Expression 1 / Expression 2 / Essence 2 — cloud | 4 | | Expression 1 / Expression 2 / Essence 2 — self-hosted | 2 | | One-time operation | Credits | |---|---| | Agent generation — per model (v1 250 · Essence 2 500 · Expression 2 2000) | 250–2000 | | Dynamics generation | 250 | Check your balance with `GET /v2/credit-summaries` — see [Billing](/api/billing). ## Endpoint guidelines | Endpoint | Guidance | |---|---| | `POST /v1/validate` | Lightweight — use for health checks. | | `POST /v1/agent/generate` | Heavy — a 2–5 min async operation. | | `GET /v1/agent/status/*` | Poll at 5 s intervals; avoid sub-second polling. | | `POST /v1/agent/*/speak` | Per active session — agent must be in a room. | | `POST /v1/files/upload` | 10 MB image, 100 MB video; size limits enforced. | | `POST /v1/dynamics/generate` | Heavy — triggers video generation. | ## Handling limits If you exceed limits or run out of credits, the API returns an error: ```json { "error": { "code": "INSUFFICIENT_BALANCE", "message": "Insufficient credits", "httpStatus": 402 }, "status": "error", "status_code": 402 } ``` Common status codes: `402` (no credits), `429` (rate limited), `503` (workers busy). See the full [error reference](/api/errors). ### Response headers **Metered** endpoints carry your current rate-limit state, so you can throttle proactively instead of waiting for a `429`: | Header | Meaning | |--------|---------| | `X-RateLimit-Limit` | Your plan's limit for the cost tier this request uses. | | `X-RateLimit-Remaining` | Whole tokens left right now. | | `X-RateLimit-Reset` | Unix time when the bucket is fully refilled. | | `Retry-After` | (On `429` only) seconds to wait before retrying. | | `X-Request-Id` | Correlation id for the request — include it in support reports. | > **Note** Coverage is uneven, and not in the direction you would guess. > Measured 2026-07-28: `POST /v1/tts` **does** return `x-ratelimit-limit` / > `-remaining` / `-reset` even though its body is a streamed audio response, > while `POST /v1/validate` returns **none** of them. Don't assume any given > `/v1` response carries them — read them defensively and fall back to > exponential backoff when they are absent. ## Recommended retry strategy Use exponential backoff with jitter for `429` and `503`, honoring `Retry-After` when present: ```python import time, random, requests def api_request_with_retry(url, headers, max_retries=3): for attempt in range(max_retries): resp = requests.post(url, headers=headers) if resp.status_code not in (429, 503): return resp wait = float(resp.headers.get("Retry-After", 2 ** attempt)) time.sleep(wait + random.uniform(0, 1)) return resp # last response if all retries exhausted ``` ## Best practices ### Use webhooks instead of polling Rather than polling `/v1/agent/status/{id}` in a loop, register a [webhook](/api/webhooks) and get a signed `agent.ready` / `agent.failed` event the moment generation finishes. ### Cache agent details Agent data rarely changes. Cache `GET /v1/agent/{code}` responses locally and refresh only when needed. ### Reuse sessions Keep avatar sessions alive between conversations instead of creating new ones — session creation is the most expensive operation. ### Check credits before heavy operations Call `GET /v2/credit-summaries` before agent generation (250–2000 credits depending on `model` — see [`GET /v1/pricing`](/api/billing#get-the-pricing-schedule)) or dynamics creation (250 credits) to avoid calls that fail with `402`. ## Need more capacity? Higher plans raise your request limits (see the matrix above) and come with more credits — upgrade (Creator → Pro → Business → Enterprise) on the [pricing page](https://www.bithuman.ai/pricing), or top up at $1 = 100 credits from the dashboard. For volume, on-prem / air-gapped, or bespoke SLAs beyond Enterprise, [talk to sales](https://www.bithuman.ai/sales) or reach us via [Discord](https://discord.gg/ES953n7bPA) or [hello@bithuman.ai](mailto:hello@bithuman.ai). --- # Errors URL: https://docs.bithuman.ai/api/errors ## Error response format Every error follows the same structured envelope: ```json { "error": { "code": "ERROR_CODE", "message": "Human-readable description of what went wrong.", "httpStatus": 401 }, "status": "error", "status_code": 401 } ``` > **Important** The HTTP transport status **always matches** `status_code` and > `error.httpStatus` — there is no "200-on-error". An auth failure returns HTTP > `401`, a validation failure returns HTTP `400`, and so on. You can branch on > either the HTTP status line or the parsed `error.code`; they never disagree. ## HTTP status codes | Status | Meaning | Common cause | |---|---|---| | `200` | Success | Request completed. | | `302` | Redirect | Not an error — [`GET /v1/agent/{code}/model/download`](/api/agents#download-an-agents-model) redirects to the artifact URL by default. | | `400` | Bad Request | Malformed JSON, missing required parameter (`MISSING_PARAM`), failed validation (`VALIDATION_ERROR`), or a request that can never succeed as posed (`MODEL_NOT_DOWNLOADABLE`). | | `401` | Unauthorized | Invalid `api-secret` (`UNAUTHORIZED`) or absent `api-secret` header (`MISSING_AUTH`). | | `402` | Payment Required | Insufficient credits — top up to continue. | | `404` | Not Found | Agent, resource, or endpoint doesn't exist — or a model artifact not published to the download store yet (`MODEL_ARTIFACT_NOT_READY`, retryable). | | `409` | Conflict | The request is valid but the agent's **state** doesn't allow it yet (`MODEL_NOT_GENERATED`, `AGENT_NOT_READY`) — a state change (generate/add the model, wait for `ready`) fixes it. | | `413` | Payload Too Large | File exceeds the size limit. | | `415` | Unsupported Media Type | File type not supported. | | `422` | Unprocessable Entity | The request is well-formed but semantically incompatible with the target model (`MODEL_SUBJECT_MISMATCH`, `MODEL_PREREQUISITE_MISSING`) — change the input or asset, not the request syntax. | | `429` | Rate Limited | Too many requests — see [rate limits](/api/rate-limits). | | `500` | Internal Error | Server-side error — retry or contact support. | | `503` | Service Unavailable | All workers busy — retry with backoff. Also `MODEL_NOT_YET_AVAILABLE` — a second-generation family temporarily paused for your account (rare — Essence 2 / Expression 2 are GA since July 10, 2026). Transient in either case; all five avatar models render [talking video](/api/video) today. | ## Error codes ### Authentication | Code | HTTP | Resolution | |---|---|---| | `UNAUTHORIZED` | 401 | The `api-secret` header is present but invalid. Get a valid secret from [Developer → API Keys](https://www.bithuman.ai/developer/api-keys). | | `MISSING_AUTH` | 401 | The `api-secret` header is absent. Add it to your request. | | `ACCOUNT_SUSPENDED` | 401/403 | Balance below the `-11` suspension floor. Top up, then contact support if it persists. | | `INSUFFICIENT_BALANCE` | 402 | Top up credits at [www.bithuman.ai](https://www.bithuman.ai). | ### Agent operations | Code | HTTP | Resolution | |---|---|---| | `NOT_FOUND` | 404 | Returned both when no agent matches the code **and** when an agent has no active session for `/speak` / `/add-context`. Distinguish by the `message` string: `"Agent not found for code: "` vs `"No active rooms found for agent "`. | | `VALIDATION_ERROR` | 400 | Body failed schema validation. Include all required fields. | | `VIDEO_INPUT_NOT_SUPPORTED` | 400 | [Agent creation](/api/agents#generate-an-agent) with a `video` input. Creation is **image-only** for every model — provide a portrait `image`; bitHuman generates the 10-second identity video internally so it loops seamlessly (first frame == last frame). This rejection is rolling out platform-wide (nothing charged when it fires) — never send `video`. | | `MISSING_PARAM` | 400 | A required parameter was not provided. | ### Model errors The model-release surfaces — [creation](/api/agents#generate-an-agent), [model add](/api/agents#add-a-model-to-an-existing-agent), [model download](/api/agents#download-an-agents-model), the [embed-token `model` field](/api/embedding), and [talking video](/api/video) — share these codes: | Code | HTTP | Resolution | |---|---|---| | `MODEL_NOT_GENERATED` | 409 | The requested model family isn't in the agent's `supported_models` — it can't be launched (or downloaded) as that family yet. **The message names the fix**: the exact [model-add](/api/agents#add-a-model-to-an-existing-agent) call and its cost when this agent qualifies for it, or the missing asset when it doesn't. Trained families (`expression-2`, `essence-2`): `"agent 's model hasn't been generated yet — add it with POST /v1/agent//models …"`. `expression-1` reads `"isn't enabled on this agent yet"` instead — nothing is ever trained for it, and the add is **instant and free** (see [Using Expression 1 on an existing agent](/api/agents#using-expression-1-on-an-existing-agent)). Checked **before any charge**. | | `AGENT_NOT_READY` | 409 | [`POST /v1/agent/{code}/models`](/api/agents#add-a-model-to-an-existing-agent) on an agent that is still generating or failed. Wait for the current generation to finish, or fix/re-create a failed agent first. | | `MODEL_SUBJECT_MISMATCH` | 422 | An explicit Essence 2 creation or add whose input is not a **photorealistic human subject** — e.g. `"essence-2 requires a photorealistic human subject; this image looks like a cartoon — use expression-2"`. Nothing is billed and no agent row is created. Use `expression-2` for stylized/non-human subjects, or `model: "auto"` to route automatically. See [the subject gate](/api/agents#the-essence-2-subject-gate-422). | | `MODEL_PREREQUISITE_MISSING` | 422 | A [model add](/api/agents#add-a-model-to-an-existing-agent) needs a stored asset this agent doesn't have — a stored identity video for `essence-2` (generated internally by Essence creations, never uploaded), face image for `expression-2`, image + voice for `expression-1`, stored identity video or image for `essence-1`. Add the missing image/voice asset, then retry. | | `MODEL_NOT_DOWNLOADABLE` | 400 | [Model download](/api/agents#download-an-agents-model) for a family with no per-identity artifact — `expression-1` renders server-side from the agent's image. A `400` because no state change can fix it (unlike the 409s). | | `MODEL_NOT_YET_AVAILABLE` | 503 | Essence 2 / Expression 2 are **GA** (since July 10, 2026), so [creation](/api/agents#generate-an-agent) and [model add](/api/agents#add-a-model-to-an-existing-agent) don't return this in normal operation — it's the safety response if a v2 family is temporarily paused. All five avatar models render [talking video](/api/video) today, so a `503` there is transient too. Nothing charged; retry later or use another model. | | `MODEL_ARTIFACT_NOT_READY` | 404 | [Model download](/api/agents#download-an-agents-model) for a **supported** family whose artifact hasn't been published to the download store yet. Retryable — the message carries a per-family retry hint; poll on this code. | ### File operations | Code | HTTP | Resolution | |---|---|---| | `FILE_TOO_LARGE` | 413 | Images 10 MB, video 100 MB, audio 50 MB, docs 10 MB. | | `UNSUPPORTED_TYPE` | 415 | Supported: JPEG, PNG, WebP, MP4, WAV, MP3, OGG. | | `DOWNLOAD_FAILED` | 400 | Ensure the URL is publicly accessible and returns a valid file. | ### Session & infrastructure | Code | HTTP | Resolution | |---|---|---| | `RATE_LIMITED` | 429 | Back off and retry. See [rate limits](/api/rate-limits). | | `SESSION_LIMIT` | 429 | Concurrent-session capacity reached. Wait for an active session to end, then retry. | | `CONCURRENCY_LIMIT_REACHED` | 403 | A new session start would exceed your plan's [concurrent avatar session allowance](/api/rate-limits#session-concurrency) (enforcement rolling out). End an active session or upgrade the plan, then retry — live sessions are never cut off mid-stream by this limit. | | `NO_AVAILABLE_WORKERS` | 503 | All workers busy. Retry with exponential backoff (up to 5 times). | | `INTERNAL_ERROR` | 500 | Retry once. If persistent, report via [Discord](https://discord.gg/ES953n7bPA). | ## Handling errors in Python > **Note** The Python examples below use > [`requests`](https://pypi.org/project/requests/), which is not in the standard > library — `pip install requests` first, or use `curl` / `urllib` instead. ```python import os import requests api_secret = os.environ["BITHUMAN_API_SECRET"] resp = requests.post( "https://api.bithuman.ai/v1/agent/generate", headers={"api-secret": api_secret, "Content-Type": "application/json"}, json={"prompt": "You are a helpful assistant"}, ) # The HTTP status always matches the body's status_code, so either is safe to # branch on. On error, the body is the structured envelope: {"error": {...}}. if resp.ok: body = resp.json() print("Agent generating:", body["data"]["agent_id"] if "data" in body else body.get("agent_id")) elif resp.status_code in (401, 403): print("Auth failed. Check BITHUMAN_API_SECRET.") elif resp.status_code == 429: print("Rate limited. Wait and retry with backoff.") elif resp.status_code == 503: print("Workers busy. Retry in a few seconds.") else: err = resp.json()["error"] print(f"Error {err['code']}: {err['message']}") ``` For `429` and `503`, use exponential backoff with jitter — see [rate limits](/api/rate-limits) for the recommended retry strategy. --- # API keys URL: https://docs.bithuman.ai/api/api-keys ## Overview Manage your account's **API secrets** from code — create new keys, list them (masked), reveal a key's value, or delete one. Handy for rotating keys or provisioning per-server credentials. Base URL `https://api.bithuman.ai`. Authenticate with an existing `api-secret`. The `{user_id}` in the path is your own account id — get it from [`GET /v1/me`](/api/billing#account-status). Set it once in your shell before the examples below — with `$USER_ID` unset the paths collapse to `/v2//…` and the API answers `404 {"detail":"Not Found"}`: ```bash export USER_ID=$(curl -s https://api.bithuman.ai/v1/me \ -H "api-secret: $BITHUMAN_API_SECRET" \ | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['user_id'])") ``` You can only manage your own keys. ## Create a key `POST /v2/{user_id}/api-secrets` | Field | Type | Required | Description | |-------|------|----------|-------------| | `alias` | string | no | A label for the key (≤32 chars). Auto-generated if omitted. | ```bash curl -X POST "https://api.bithuman.ai/v2/$USER_ID/api-secrets" \ -H "api-secret: $BITHUMAN_API_SECRET" -H "content-type: application/json" \ -d '{"alias":"prod-server"}' ``` **`200 OK`** — the **only** response that returns the full secret. Store it now; it can't be listed in plaintext later. ```json { "alias": "prod-server", "secret": "k7m2p9x4…Sn3Q8vT1w…aC8e" } ``` Errors: `409` alias already exists · `404` account not found. ## List keys `GET /v2/{user_id}/api-secrets` — your keys, masked. ```bash curl "https://api.bithuman.ai/v2/$USER_ID/api-secrets" -H "api-secret: $BITHUMAN_API_SECRET" ``` ```json { "user_id": "8f14e45f-…", "data": [ { "alias": "prod-server", "name": "prod-server", "key_display": "k7m2p9x4••••••••••••••••••••aC8e", "created_at": "2026-07-15T15:30:45Z" } ], "total_count": 1 } ``` The raw secret is never returned here — only `key_display` (masked). `name`, `key`, and `alias` all carry the same alias label. ## Reveal a key `GET /v2/{user_id}/api-secrets/{alias}/get-value` — return one key's full value. ```bash curl "https://api.bithuman.ai/v2/$USER_ID/api-secrets/prod-server/get-value" \ -H "api-secret: $BITHUMAN_API_SECRET" ``` ```json { "value": "k7m2p9x4…aC8e" } ``` Errors: `404` no key with that alias. ## Delete a key `DELETE /v2/{user_id}/api-secrets/{alias}` — remove a key and revoke it at the runtime. ```bash curl -X DELETE "https://api.bithuman.ai/v2/$USER_ID/api-secrets/prod-server" \ -H "api-secret: $BITHUMAN_API_SECRET" ``` ```json { "alias": "prod-server", "message": "API secret deleted successfully" } ``` Deleting a key adds it to a runtime denylist immediately — only that key stops working; your other keys keep running. Errors: `404` no key with that alias. > Need to stop **every** key at once (a leak)? See > [Runtime sessions → revoke all](/api/runtime-sessions#revoke-all-keys). --- # Runtime sessions URL: https://docs.bithuman.ai/api/runtime-sessions ## Overview See and control the avatar sessions running on your account — list live sessions with their burn rate, read a session's transcript, terminate one, or (in an emergency) revoke every runtime key at once. Base URL `https://api.bithuman.ai`. Authenticate with your `api-secret`. The `{user_id}` in the path is your own account id — get it from [`GET /v1/me`](/api/billing#account-status). Set it once in your shell before the examples below — with `$USER_ID` unset the paths collapse to `/v2//…` and the API answers `404 {"detail":"Not Found"}`: ```bash export USER_ID=$(curl -s https://api.bithuman.ai/v1/me \ -H "api-secret: $BITHUMAN_API_SECRET" \ | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['user_id'])") ``` ## List sessions `GET /v2/{user_id}/runtime-sessions` — derived sessions plus live account KPIs. | Query | Type | Default | Description | |-------|------|---------|-------------| | `window` | string | `all` | `live` (only running), `recent` (idle + ended), or `all`. | | `kind` | string | `all` | `conversations` (cloud), `self_hosted`, or `all`. | | `limit` | int | `50` | Max sessions to return (1–200). | ```bash curl "https://api.bithuman.ai/v2/$USER_ID/runtime-sessions?window=live" \ -H "api-secret: $BITHUMAN_API_SECRET" ``` ```json { "success": true, "data": { "active_count": 1, "live_burn_rate_cr_per_min": 12.5, "credits_this_hour": 340, "sessions": [ { "id": "a3f1c8e2-…-9b02", "agent_code": "agent_greeter", "agent_name": "Greeter", "billing_type": "usage_agent_video_chat_on_imaginex", "key_alias": "prod-server", "room_name": "room_a3f1c8e2", "started": "2026-07-15T15:20:00Z", "last_seen": "2026-07-15T15:33:00Z", "minutes": 13.0, "credits": 162, "burn_rate_cr_per_min": 12.5, "status": "live", "has_transcript": true } ] } } ``` The KPIs (`active_count`, `live_burn_rate_cr_per_min`, `credits_this_hour`) are account-wide over the last hour; `sessions` is the filtered list. Use a session's `id` for the calls below. ## Read a transcript `GET /v2/{user_id}/runtime-sessions/{session_id}/messages` — the conversation, oldest first. ```bash curl "https://api.bithuman.ai/v2/$USER_ID/runtime-sessions/a3f1c8e2-…/messages" \ -H "api-secret: $BITHUMAN_API_SECRET" ``` ```json { "success": true, "data": { "session_id": "a3f1c8e2-…", "agent_code": "agent_greeter", "started": "2026-07-15T15:20:00Z", "ended": "2026-07-15T15:33:00Z", "live": true, "messages": [ { "timestamp": "2026-07-15T15:20:03Z", "role": "user", "message": "Hi there" }, { "timestamp": "2026-07-15T15:20:05Z", "role": "assistant", "message": "Hello! How can I help?" } ] } } ``` Only cloud voice/chat sessions record a transcript; self-hosted sessions return `messages: []`. ## Terminate a session `POST /v2/{user_id}/runtime-sessions/{session_id}/terminate` — stop **one** session. Never touches an API key. | Field | Type | Required | Description | |-------|------|----------|-------------| | `reason` | string | no | Free-text stop reason. Default `user-terminated`. | ```bash curl -X POST "https://api.bithuman.ai/v2/$USER_ID/runtime-sessions/a3f1c8e2-…/terminate" \ -H "api-secret: $BITHUMAN_API_SECRET" -H "content-type: application/json" -d '{}' ``` ```json { "success": true, "data": { "session_id": "a3f1c8e2-…", "closed": true, "ended": true, "self_hosted": false, "note": "Session ended — the avatar disconnected and the conversation closed." } } ``` For a cloud session this ends the LiveKit room; for a self-hosted runtime it closes the activity record (the runtime is on your hardware). Errors: `404` no such session (or not yours). ## Revoke all keys `POST /v2/{user_id}/runtime/revoke-all` — the emergency stop. Disables **every** runtime key on the account (e.g. a leak). Live sessions stop within one refresh cycle (~5 min). | Field | Type | Required | Description | |-------|------|----------|-------------| | `reason` | string | no | Free-text reason. | ```bash curl -X POST "https://api.bithuman.ai/v2/$USER_ID/runtime/revoke-all" \ -H "api-secret: $BITHUMAN_API_SECRET" -H "content-type: application/json" -d '{}' ``` ```json { "success": true, "data": { "revoked": true, "effective_in_seconds": 420, "runtime_suspended": true, "note": "All runtime keys disabled. Creating a new API key restores runtime access." } } ``` This does **not** delete your keys — it suspends runtime token issuance. It's self-recoverable: creating a new API key clears the suspension. --- # Organizations URL: https://docs.bithuman.ai/api/organizations ## Overview Organizations let a team share agents, knowledge bases, and API keys under one account, with role-based access and per-member usage tracking. Base URL `https://api.bithuman.ai`. Authenticate with your `api-secret`; the acting account is resolved from the key. **Organizations require a Pro, Business, or Enterprise plan** — creating one or inviting members on a lower plan returns `403`. **Roles.** Every member is an `owner`, `admin`, or `member`. The table notes the minimum role each call needs; insufficient role returns `403`. Seat caps by plan: Enterprise 50, Business 25, Pro 10. ## Organizations ### Create `POST /v2/organizations` — create an org (Pro+). Adds you as `owner`. One org per user today. | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | yes | 1–100 chars. | | `slug` | string | no | URL slug (lowercase alphanumeric + hyphens, 3–64). Auto-derived from `name` if omitted. | ```bash curl -X POST https://api.bithuman.ai/v2/organizations \ -H "api-secret: $BITHUMAN_API_SECRET" -H "content-type: application/json" \ -d '{"name":"Acme Inc"}' ``` ```json { "id": "org_a1b2c3", "name": "Acme Inc", "slug": "acme-inc", "owner_user_id": "user_123", "plan": "membership_business", "max_members": 25, "created_at": "2026-07-15T10:00:00Z" } ``` Errors: `403` plan gate · `409` you already own an org, or the slug is taken · `422` bad slug. ### Get · Update · Delete | Method / path | Role | Notes | |---|---|---| | `GET /v2/organizations/{org_id}` | member | The org object. | | `PATCH /v2/organizations/{org_id}` | admin | Body: `name` and/or `slug`. | | `DELETE /v2/organizations/{org_id}` | owner | Reverts members' agents to personal ownership. | | `POST /v2/organizations/{org_id}/transfer-ownership` | owner | Body: `new_owner_member_id`. Old owner becomes `admin`. | | `GET /v2/users/{user_id}/organizations` | self | Orgs you own or belong to, each with your `role`. | ## Members ### List `GET /v2/organizations/{org_id}/members` — any member. Owners/admins see pending & removed members too; regular members see only active ones. ```json { "org_id": "org_a1b2c3", "members": [ { "id": "mem_1", "user_id": "user_123", "email": "owner@acme.com", "role": "owner", "status": "active" }, { "id": "mem_3", "email": "invitee@acme.com", "role": "admin", "status": "pending" } ], "total": 2 } ``` ### Invite `POST /v2/organizations/{org_id}/members/invite` — owner or admin. | Field | Type | Required | Description | |-------|------|----------|-------------| | `email` | string | yes | Invitee email. | | `role` | string | no | `admin` or `member` (default `member`). | ```json { "message": "Invitation created", "invite_token": "…", "member_id": "mem_9" } ``` The invite token is valid 7 days; the invite link is `…/invite?token=` (emailed if SMTP is configured, otherwise share it yourself). Errors: `403` seat limit reached or owner no longer Pro+ · `409` already a member or pending. ### Other member operations | Method / path | Role | Notes | |---|---|---| | `POST …/members/{member_id}/resend-invite` | admin | Re-issues a token for a pending invite. | | `POST /v2/organizations/accept-invite` | self | Body: `token`. Your email must match the invite. | | `PATCH …/members/{member_id}` | admin* | Body: `role` (`admin`/`member`). Only owners can change an admin's role. | | `DELETE …/members/{member_id}` | admin* | Remove a member; a member can remove themselves (leave). Only owners remove admins; the owner can't leave. | ## Organization API keys Org-scoped keys are what the [Knowledge API](/api/knowledge) and other org-shared resources use. | Method / path | Role | Notes | |---|---|---| | `POST /v2/organizations/{org_id}/api-secrets` | admin | Body: `alias` (optional). Returns the full `secret` **once**. | | `GET /v2/organizations/{org_id}/api-secrets` | member | Lists keys, masked. | | `DELETE /v2/organizations/{org_id}/api-secrets/{alias}` | admin | Deletes a key. | ```json // POST → the secret is shown only on creation { "alias": "ci-pipeline", "secret": "k7m2…aC8e" } ``` ## Usage & audit | Method / path | Role | Notes | |---|---|---| | `GET /v2/organizations/{org_id}/usage` | admin | Per-member credit usage, highest first. | | `GET /v2/organizations/{org_id}/audit-log` | admin | Recent org events; `?limit` (default 50, max 200). | ```json // usage { "org_id": "org_a1b2c3", "usage": [ { "user_id": "user_456", "email": "dev@acme.com", "total_credits_used": 1240.5 } ] } ``` Audit events include `create_org`, `invite_member`, `accept_invite`, `update_role`, `remove_member`, `create_api_key`, `delete_api_key`, `transfer_ownership`, and `delete_org`. --- # CLI URL: https://docs.bithuman.ai/sdk/cli ## Install ```bash curl -fsSL https://raw.githubusercontent.com/bithuman-product/homebrew-bithuman/main/install.sh | sh ``` What it prints when it succeeds: ```text install: version: cli-v2.6.7 install: sha256 ok install: installed: libessence 3.1.3 ABI 7 ``` One self-contained binary on your `PATH`, sha256-verified against the release. On Apple Silicon `brew install bithuman-product/bithuman/bithuman-cli` installs the same tarball. Published for **macOS Apple Silicon** and **Linux x86_64** only; on an Intel Mac or a Linux ARM box the installer names the platform and exits 1 without downloading anything ([exact output](/sdk/cli/reference#platforms-with-no-binary)). ## Get a model Nothing to fetch for the first frame: `bithuman run` with no argument downloads the free **Wise Pup** avatar (`A23WJF0199`, an [Expression 2](/concepts/expression-2) identity) itself — the slice for this platform only, sha-verified. Twenty more showcase avatars download with no account: ```bash bithuman avatars # the showcase catalogue — slug, name, model bithuman pull marmalade # prints ~/.cache/bithuman/showcase/marmalade.imx ``` Your own agent needs a sign-in once, then the same command: ```bash bithuman login # opens your browser; the key lands in your OS keychain bithuman pull # prints the cached path; --model essence-2 picks a family ``` ## Minimal code Two operations — there is no third: ```bash bithuman run # 1. live avatar in your browser bithuman render "$(bithuman pull marmalade)" -a speech.wav -o out.mp4 # 2. offline: audio in, MP4 out ``` `run` takes a path too (`bithuman run "$(bithuman pull marmalade)"`); `render` needs a 16 kHz mono WAV — `curl -fsSLo speech.wav https://tmoobjxlwcwvxvjeppzq.supabase.co/storage/v1/object/public/web/showcase/demo_sample.wav` is one. `bithuman open ` prints what an avatar is before you render it. ## Run Open the printed `http://127.0.0.1:8088/`, grant the microphone, talk. Without a sign-in the avatar renders but does not answer; `bithuman login` adds the managed conversation brain, and [local mode](/sdk/cli/local-mode) runs the brain entirely on your own hardware instead — no LLM or TTS vendor. `run` and `render` differ on purpose: **`render` refuses without a credential** (exit 77, no output file) while a live `run` keeps rendering and prints `★ UNMETERED RENDER` if the meter cannot be reached, because a metering failure must never stop a live session. A self-hosted session is metered — [pricing](/guides/pricing) is the authority. ## Performance Expression 2 renders at **54 fps** on an Apple M4 and **34 fps** on an x86 workstation; Essence 2 at **2** and **1** — unpaced, as fast as the engine can; avatars play at 20 and 25 fps. Until Essence 2 GPU rendering lands, plan an offline `render` for Essence 2 rather than a live CPU session. The first Essence 2 render on a machine fetches one shared audio encoder (~377 MB, once) into `~/.bithuman/engines/essence-2/`. Every platform side by side: [Performance](/sdk/performance). ## What renders locally, and where | Platform | Expression 2 | Essence 2 | Essence 1 | |---|---|---|---| | **macOS Apple Silicon** | yes | yes (2.6.1+) | live only | | **Linux x86_64** | yes | yes (2.6.1+) | live only | Those are the only two targets with a published binary. `render` on an Essence 1 avatar exits 70 — use the [Video API](/api/video) for that family; [Expression 1](/concepts/expression-1) is GPU-only by design and serves through the [cloud API](/api/overview). ## Troubleshooting | You see | It means | Do this | |---|---|---| | the installer names your platform and exits 1 | no binary for an Intel Mac or Linux ARM | the [web](/sdk/web), the [cloud API](/api/overview), or the Linux x86_64 binary in a container | | `render` exits 77, no output file | no credential | `bithuman login`, or `export BITHUMAN_API_SECRET=…` ([credential order](/sdk/cli/reference#credential-resolution-order)) | | `pull ` exits 77 | your agent code, but no sign-in | `bithuman login`, then pull again | | `pull ` exits 66 with `404 NOT_FOUND` | not an agent on your account, and not a showcase slug | check the code under [your agents](/api/agents); `bithuman avatars` lists the public ones | | `pull ` exits 66 with `409 MODEL_NOT_GENERATED` | the agent has no model of that family yet | [add the model](/api/agents#add-a-model-to-an-existing-agent), or `--model` the family it was created with | | `pull ` exits 66 with `MODEL_ARTIFACT_NOT_READY` | trained, not yet published to the download store | poll: run the same `pull` again in a minute | | `SLUG_NOT_FOUND` | the slug is not in the catalogue | `bithuman avatars` and copy a slug from it | | `render` exits 70 on an `.imx` | an Essence 1 avatar — the CLI renders that family live only | `bithuman run `, or the [Video API](/api/video) | | `bithuman doctor` exits 1 | no credential and no brain configured yet — the check working | `bithuman login`; `run`, `pull` and `render` of a showcase file never needed it | Every failure prints one JSON object to stderr and a [stable exit code](/sdk/cli/reference#exit-codes); branch on the code, not the text. ## See also - [CLI reference](/sdk/cli/reference) — every command, flag, exit code and environment variable - [Local mode](/sdk/cli/local-mode) — the conversation brain fully on-device - [macOS](/sdk/macos) — the same binary through Homebrew, and the native Swift package - [Python](/sdk/python) — the same engines as a library - [SDK](/sdk) — every platform on one table --- # Python URL: https://docs.bithuman.ai/sdk/python ## Install ```bash pip install "bithuman[expression-2]" ``` Python **3.10–3.14** on Apple Silicon macOS (14 or newer), Linux x86_64 and Linux aarch64 (glibc, `manylinux_2_28`). No Windows, Intel Mac or Alpine wheels — pin `bithuman>=3` so the resolver refuses out loud rather than handing you a 2.x release. The `[expression-2]` extra opens `.avatar` files; add `"bithuman[offline]"` for the Essence 2 clip-to-file route. `pip install bithuman` puts **no `bithuman` command** on your `PATH` — the command-line tool is the [CLI](/sdk/cli), a separate artifact. ## Get a model A showcase avatar is a plain anonymous download — no account, no key: ```bash curl -fsSLO "https://tmoobjxlwcwvxvjeppzq.supabase.co/storage/v1/object/public/web/showcase/{A23WJF0199.avatar,demo_sample.wav}" ``` `A23WJF0199.avatar` is the free Wise Pup (Expression 2); `demo_sample.wav` is 16 kHz mono, something for it to say. Download the `.avatar`, not the `.imx` — the `.imx` is the [CLI](/sdk/cli)'s form. Your own agent's file comes from [`GET /v1/agent/{code}/model/download`](/api/agents#download-an-agents-model) or `bithuman pull `; an Essence 2 agent arrives as an `.imx`, an Expression 2 agent as an `.avatar`, and `bithuman.open` takes either. ## Minimal code ```python # hello.py import bithuman with bithuman.open("A23WJF0199.avatar") as avatar: # an Essence 2 .imx or an Expression 2 .avatar — one call for image in avatar.render("demo_sample.wav"): # (height, width, 3) uint8, RGB, at the avatar's own frame rate print(image.shape) # hand it to your display — OpenCV wants image[:, :, ::-1] ``` That is the whole surface: **open an avatar, render audio through it.** `audio` is 16 kHz mono as a file path, an `int16` or `float32` array, raw 16-bit little-endian bytes, or any iterable of those — a microphone stream and a file are the same program; `frames = avatar.render(...)` then `frames.close()` stops early. Eight public names: `open`, `render`, `Avatar`, and four errors, all `AvatarError` — `InvalidAvatar` (not found, or not usable: fix the path or fetch it again), `NotSupported` (cannot run here: install the extra, or use the cloud), `NotAuthorised` (the key is missing, invalid, or out of credit), `Failed` (the message says why: retry, then report it). There is no execution-provider, thread or delegate option. A whole Essence 2 clip to an MP4 on the CPU is `from bithuman.offline import render_offline`, then `render_offline("agent.imx", "speech.wav", out_mp4="rendered.mp4")`, with the `[offline]` extra (install the CPU build of `torch` first on a machine with no GPU). ## Run ```bash export BITHUMAN_API_SECRET=… # free at https://www.bithuman.ai/developer/api-keys python hello.py ``` The download is free; **the render is metered** and refuses before the first frame without a key — [pricing](/guides/pricing) is the authority. A rejected key (HTTP 401, 402 or 403) gets a 300-second grace with a warning naming the seconds left, then `NotAuthorised` on the next frame; an unreachable service never stops a live render but logs `★ UNMETERED RENDER` (`BITHUMAN_METER_ENFORCE=1` refuses a rejected key before the first frame instead). The first use on a machine prepares the avatar into `BITHUMAN_CACHE_DIR` (`~/.cache/bithuman`); an Essence 2 avatar also fetches the shared audio encoder once (~377 MB, sha256-verified, kept under `BITHUMAN_DEPS_DIR`, `~/.bithuman/deps` — mirror it with `BITHUMAN_DEPS_BASE_URL`, or point at a copy with `BITHUMAN_W2V_ONNX`). ## Performance Unpaced (frames drained as fast as they are produced), whole process including `open`: | Hardware | Model | fps (unpaced) | Measured | |---|---|---:|---| | Ryzen Threadripper PRO 5955WX (Linux x86_64), Python 3.14, `bithuman` 3.1.0 | Expression 2 (Wise Pup) | **26** | 2026-09-10 — 309 frames of 416×720 in 12.1 s; playback is 20 fps | | the same machine, [CLI](/sdk/cli) 2.6.5, 8 threads | Essence 2 | **1** | 2026-09-11 — 408 frames at 1920×1080 in 371 s; render a clip to a file, not a live CPU session | On Apple Silicon the CLI's CoreML figure on the same engine is on [macOS](/sdk/macos#performance). Every platform side by side: [Performance](/sdk/performance). ## Troubleshooting | You see | It means | Do this | |---|---|---| | `ModuleNotFoundError: No module named 'bithuman'` | not installed in the active environment | `pip install bithuman --upgrade` in the venv you run from | | `No matching distribution found for bithuman` | no wheel for this platform — Intel Mac, Windows, musl, or a Python outside 3.10–3.14 | a supported platform, or the [cloud API](/api/overview) | | `NotSupported` opening a `.avatar` | the Expression 2 extra is missing | `pip install "bithuman[expression-2]"` | | the first `render` raises `NotAuthorised` or `Failed: the render stopped` | no usable key in the running shell | `export BITHUMAN_API_SECRET=…`, then `curl -s -X POST https://api.bithuman.ai/v1/validate -H "api-secret: $BITHUMAN_API_SECRET"` — it always answers 200; the body must say `{"valid":true}` | | `InvalidAvatar` on an Essence 2 file you were given | its recorded-mouth data is missing, and no frame is ever rendered with another mouth | send the agent code to [hello@bithuman.ai](mailto:hello@bithuman.ai) for re-publishing | | `404 NOT_FOUND` from `/v1/agent//model/download` | not an agent on your account, and not a public showcase | check the code under [your agents](/api/agents) or on the [showcase](/showcase) | | `409 MODEL_NOT_GENERATED` from the download | the agent has no model of that family yet | [add the model](/api/agents#add-a-model-to-an-existing-agent), then poll `GET /v1/agent/` until it is listed | | `MODEL_ARTIFACT_NOT_READY` from the download | trained, not yet published to the download store | poll the same download URL; it clears on its own | | frames look blue | frames are RGB; your sink wants BGR | `image[:, :, ::-1]` | | the first `render` is slow, with a large download | the ~377 MB shared encoder is being fetched, once | wait; on an air-gapped box mirror it (`BITHUMAN_DEPS_BASE_URL`) or point at a copy (`BITHUMAN_W2V_ONNX`) | | code written for 2.10.0 fails — `api_secret=`, BGR frames, fourteen error classes | 3.0.0 was a clean break: eight public names, four errors, RGB frames, the key from the environment only | port to the snippet above — the 2.x line ends at 2.9.0 on PyPI, so a `bithuman<3` pin no longer gets you 2.10.0 | --- # Android URL: https://docs.bithuman.ai/sdk/android ## Install ```kotlin // settings.gradle.kts dependencyResolutionManagement { repositories { google() // AGP resolves its own aapt2 here — every Android project needs it mavenCentral() // the bitHuman AAR } } // app/build.gradle.kts android { defaultConfig { minSdk = 26 // the AAR's own floor ndk { abiFilters += "arm64-v8a" } // the only ABI published } packaging { jniLibs { useLegacyPackaging = true } } // not optional — see Troubleshooting } dependencies { implementation("ai.bithuman:expression2-android:0.4.1") implementation("com.qualcomm.qti:qnn-litert-delegate:2.49.0") // the Hexagon path — both on Maven Central, implementation("com.qualcomm.qti:qnn-runtime:2.49.0") // no Qualcomm account; +67 MB of APK } ``` Without the two Qualcomm lines the avatar still renders, on the CPU, slower. The whole project, file by file: [Kotlin / Android — Hello, avatar](/examples/kotlin-android-hello). ## Get a model `Expression2ModelStore` downloads a published identity over **anonymous HTTPS** into app-private storage — no account, no key, no host argument: ```kotlin val model = Expression2ModelStore(context).fetch("A02HCY0444") // ~158 MB the first time; never on the main thread ``` **A public agent needs no credential at all.** `A02HCY0444` (Shelly Tidewater) is one; so is every expression-2 code `bithuman list` prints — `A58ZFA5978`, `A68HQB7720`, `A74NWD9723`, `G06MARMALAD`, `X02GRUMBLEW` and the rest of the [showcase](/showcase). Your own agent's code comes from [Agents](/api/agents). ★ **Since 0.4.0 the store fetches through the metered door at `api.bithuman.ai`, not the anonymous storage mirror 0.3.1 used**, so the refusals you can hit changed shape and it is worth knowing which is which (measured 2026-09-11): | what you asked for | answer | |---|---| | a **public** agent, no credential | `302` to a signed URL, `x-bithuman-meter-id` set — this is the keyless path | | a **private** agent, no credential | `401 MISSING_AUTH` — pass its owner's key: `Expression2ModelStore(context, urlResolver = Expression2ModelStore.MeteredDoorResolver(""))` | | a member that is not published | `404 MODEL_ARTIFACT_NOT_READY`, naming `web_manifest.json` as the member list | A code that is simply *private* therefore answers `401`, not "not found" — if a code you expected to be keyless refuses, check its visibility before you check your spelling. Two member files you already hold open with `Expression2Model.combined(File(dir, "combined_fp32.tflite"), File(dir, "canon.bin"))`. ## Minimal code 16 kHz mono `FloatArray` in, `Bitmap` frames out. The frame contract is on the class as constants: `FRAME_WIDTH` 416, `FRAME_HEIGHT` 720, `FRAMES_PER_SECOND` 20, `SAMPLE_RATE` 16000. ### Calling it — audio in, frames out ```kotlin import ai.bithuman.expression2.Expression2Avatar import ai.bithuman.expression2.Expression2ModelStore import ai.bithuman.expression2.Expression2Options import android.content.Context import android.graphics.Bitmap /** [pcm16k] is 16 kHz MONO float32 in [-1, 1] — one float per sample, not ShortArray. */ fun render(context: Context, agentCode: String, pcm16k: FloatArray, show: (Bitmap) -> Unit) { // Blocks on the network the first time (~158 MB). Never on the main thread. val model = Expression2ModelStore(context).fetch(agentCode) // anonymous HTTPS // 0.4.1 defaults: the Hexagon decoder when the handset has one, 6 threads — 58 fps on a // Galaxy S25+; a refused graph falls back to the CPU instead of throwing. Expression2Avatar.create(context, model, Expression2Options()).use { avatar -> val frame = avatar.newFrameBitmap() // ARGB_8888, 416 x 720 — allocate once avatar.feed(pcm16k) // renders each complete 1.6 s chunk avatar.flushTail() // the padded tail is the last sentence while (true) { if (avatar.pull(frame) != null) { show(frame); continue } if (!avatar.hasPendingTail && avatar.queuedFrames == 0) break } } } ``` Log `avatar.accelerator`, `avatar.routing`, `avatar.acceleratorNote` and `avatar.initMs` after `create` — the only honest answer to whether the accelerator did anything. On an Adreno instead of a Hexagon the same shape is `Expression2Options(routing = Routing.GPU_DECODER)` with `Expression2Options.QNN_OPTIONS_ADRENO_FP32`. ## Run ```bash ./gradlew :app:installDebug ``` A terminal build needs three things Android Studio sets for you: `ANDROID_HOME` (or `sdk.dir=` in `local.properties`), a **JDK 17** launcher (AGP 8.7.3 refuses newer ones illegibly), and the Gradle wrapper written **last** — `gradle wrapper --gradle-version 8.11.1` after `settings.gradle.kts` and `app/` exist. The first `create()` on the Hexagon path takes about 45 s while the delegate compiles the graph — do it on a background thread at app start, once. No account, no key, no credit spend in this run; a [self-hosted session](/guides/pricing) is metered once you attach a key. ## Performance Measured on a **Galaxy S25+** (SM-S936U1, Snapdragon 8 Elite / SM8750, Hexagon V79, Android 16) through the published AAR, unpaced: | Model · options | fps (unpaced) | Measured | |---|---:|---| | Expression 2 · `expression2-android:0.4.1` · bare `Expression2Options()` — the snippet above | **58** | 2026-09-10 — one 20 s clip, 405 frames; 48 sustained as the mean of three four-clip runs (48.6 / 47.8 / 48.2) | | Expression 2 · 0.3.1 · `Expression2Options(routing = Routing.HTP_DECODER, overlapDecoder = true, threads = 6)` | 58 | 2026-09-10 — 1,642 frames; worst heat-soaked 10 s window 43; the same frames pixel for pixel. A bare `Expression2Options()` on 0.3.1 is all-CPU: 7 | | Essence 2 · `essence2-android` | — | 0.5.2 (published 2026-09-11) is not yet measured end to end; 0.5.1 measured 1 on the CPU, with no model host and no audio-in call | The model plays at 20 fps, so anything above 20 is headroom. No other handset has been measured. Every platform side by side: [Performance](/sdk/performance). ## Troubleshooting | You see | It means | Do this | |---|---|---| | `acceleratorNote` = *"no libQnnTFLiteDelegate.so in this APK"* | the Qualcomm artifacts are missing, or `useLegacyPackaging` is off so no `.so` is on disk | add the two `com.qualcomm.qti` lines and `packaging { jniLibs { useLegacyPackaging = true } }` | | `acceleratorNote` = *"the Hexagon refused this graph, fell back to XNNPACK"* | this Hexagon rejected the graph; you still get frames on the CPU | nothing to fix in your app; log it and ship | | `Expression2Exception: TfLiteInterpreterCreate returned null (graph rejected) … on the NPU` | you named `accelerator = Accelerator.NPU`, which makes a refusal fatal | leave `accelerator` at `AUTO` | | `acc=CPU`, ~7 fps | `expression2-android:0.3.1` with a bare `Expression2Options()` never asks for the accelerator | move to 0.4.1, or on 0.3.1 name `Expression2Options(routing = Routing.HTP_DECODER, overlapDecoder = true, threads = 6)` | | `fetch` fails with `HTTP 400 … Object not found` | the code is not on the public mirror | use a [showcase](/showcase) code, or open the two member files with `Expression2Model.combined(...)` | | `404 NOT_FOUND` from `GET /v1/agent//model/download` | not an agent on your account, and not public | check the code under [your agents](/api/agents) | | `409 MODEL_NOT_GENERATED` from the download | the agent has no Expression 2 model yet | [add the model](/api/agents#add-a-model-to-an-existing-agent) and poll until it is listed | | `MODEL_ARTIFACT_NOT_READY` from the download | trained, not yet published to the download store | poll the same URL; it clears on its own | | `UnsatisfiedLinkError` on an emulator | the AAR is arm64-v8a only; an x86_64 image installs, then cannot load it | run on a physical arm64 handset | | `SDK location not found` | no `ANDROID_HOME` and no `local.properties` | set one of them | | AGP fails with `What went wrong: 26.0.2.1` (or another bare version) | `JAVA_HOME` points at a JDK newer than 17 | use a JDK 17 launcher | | `gradle wrapper` refuses an empty directory | Gradle 9 | write `settings.gradle.kts` and `app/` first, the wrapper last | | the first `create()` takes ~45 s | the Hexagon compiles the graph once per process | create on a background thread at app start; do not persist a QNN context cache — it makes decode 74–97× slower | | you want Essence 2 or Essence 1 on Android | `essence2-android` 0.5.1 had no model host and no audio-in call (0.5.2 is published, not yet measured; metering from 0.5.1: a rejected key gets 300 s, an unreachable service logs `★ UNMETERED RENDER`); `ai.bithuman:sdk:2.3.6` (Essence 1) fails `Avatar.load` with `SSL peer certificate … was not OK` on every device | Expression 2 is the Android rail today | **Licence.** *Proprietary — bitHuman SDK License* (`https://bithuman.ai/license`); the bundled LiteRT is Apache-2.0 (notices in the AAR's `META-INF/`); FFmpeg is linked statically under LGPL §6(a), relink on request to [hello@bithuman.ai](mailto:hello@bithuman.ai). --- # iOS & iPadOS URL: https://docs.bithuman.ai/sdk/ios ## Install In Xcode, *File → Add Package Dependencies…* and paste the URL, or in `Package.swift`: ```swift .package(url: "https://github.com/bithuman-product/homebrew-bithuman.git", from: "2.11.0") // product: .product(name: "Expression2", package: "homebrew-bithuman") ``` `from:` is a floor — it resolves the newest 2.x tag. Pre-compiled xcframeworks, every dependency statically linked, zero transitive packages. You need **Xcode 26+**, an **Apple Developer team** and a **physical iPhone or iPad** — the Simulator cannot run this engine. `Expression2` is the [Expression 2](/concepts/expression-2) engine alone (`ios-arm64`, `macos-arm64`). The package's other products are not this page: `bitHumanKit` (an on-device voice agent; iPhone 16 Pro / M3 Mac or later) and `Essence2` (the [Essence 2](/concepts/essence-2) engine — builds, renders on no iPhone today). Never take `Expression2` and `Essence2` in one app: 116 duplicate symbols at the device link. ## Get a model Three anonymous downloads — the identity, the shared engine graphs it does not carry, and something for it to say. No account, no key, no credits: ```bash curl -fLO "https://tmoobjxlwcwvxvjeppzq.supabase.co/storage/v1/object/public/web/{showcase/A08CCD3871.avatar,engines/expression-2/mac-arm64-1.0.0.engine}" curl -fLo speech16k.wav "https://api.bithuman.ai/v1/agent/A08CCD3871/model/download?member=demo_speech_16k.wav&model=expression-2" ``` `A08CCD3871` is a bitHuman-owned public showcase identity; any code on the [showcase](/showcase) works the same way. The `setup.sh` on [the example page](/examples/swift-ios-expression2) fetches these, unpacks the container and stages the engine directory for you. Your own agent's `.avatar` comes from [`GET /v1/agent/{code}/model/download`](/api/agents#download-an-agents-model) with your key. ## Minimal code ```swift import Expression2 let engine = try Expression2Engine.create(modelPath: avatarDirectory, // the unpacked .avatar sharedEngineDir: sharedEngineDirectory) engine.feed(samples) // [Float] PCM, 16 kHz mono engine.flushTail() // at the end of an utterance // Generation is ASYNCHRONOUS: pull() returns nil until a chunk lands. A bare // `while let` on the line after feed() drains nothing and your view stays empty. Poll. var idleTicks = 0 while idleTicks < 100 { // 100 x 50 ms with nothing = done var got = false while let (frame, _) = engine.pull() { // frame: [UInt8], BGR, engine.width * engine.height * 3 got = true show(frame) } if got { idleTicks = 0 } else { idleTicks += 1; try await Task.sleep(nanoseconds: 50_000_000) } } ``` Every file of a working app — `Info.plist`, the Xcode settings, the whole of `App.swift`, the unpack script — is printed on [Swift / iOS — a talking avatar on the iPhone you have](/examples/swift-ios-expression2). ## Run Signing & Capabilities → your team → select your iPhone → **Run**. Automatic signing creates a development profile for a paired, trusted phone the first time. From the command line the working shape is `CODE_SIGN_STYLE=Automatic DEVELOPMENT_TEAM= xcodebuild … -allowProvisioningUpdates`, run from a logged-in GUI session (over SSH the keychain reports 0 signing identities and the install fails with `0xe800801c`). `Info.plist` needs `NSMicrophoneUsageDescription` to hear the user. Only a **metered render with your own key** needs `BITHUMAN_API_KEY` (the Swift SDK's spelling of `BITHUMAN_API_SECRET` — same value); the showcase identity above renders without one. A self-hosted session is metered — [pricing](/guides/pricing) is the authority. ## Performance Unpaced (frames produced as fast as the engine can, 100 % talk duty, one process), through the published `Expression2` product: | Hardware | Model | fps (unpaced) | Measured | |---|---|---:|---| | iPhone 15 (A16), iOS 26.6.1, Xcode 26.3 | Expression 2, `Expression2` 2.11.x | **107** | 2026-09-09 — 36,021 frames, 1,801.6 s of speech in 338.0 s; worst 10 s window 99.9; first frame 263 ms after `feed()`; CoreML placed the work on the Neural Engine | | Apple Silicon Mac | Expression 2, the same package (`macos-arm64`) | see [macOS](/sdk/macos#performance) | the CLI's CoreML figure on the same engine | | iPhone 16 Pro or later | Essence 2, `Essence2` | — | builds; no per-identity bundle to open on a phone today | The model plays at 20 fps. `Expression2` carries no device floor — it has rendered on an iPhone 15, two generations below the `Essence2` / `bitHumanKit` floor (iPhone 16 Pro or later, A18 Pro). Every platform side by side: [Performance](/sdk/performance). ## Troubleshooting | You see | It means | Do this | |---|---|---| | the app runs, no error, no avatar; `pull()` keeps returning `nil` | you drained synchronously on the line after `feed()` — frames arrive asynchronously | poll as in the snippet above | | `product 'Expression' … not found in package 'homebrew-bithuman'` | the older products are not published; `swift package resolve` does not check product names, `swift build` does | name `Expression2`, `bitHumanKit` or `Essence2` | | 116 duplicate symbols at the device link, Simulator green | `Expression2` and `Essence2` in one app | take one of them | | `Provisioning profile "…" is Xcode managed, but signing settings require a manually managed profile` | a `PROVISIONING_PROFILE_SPECIFIER` on an automatic profile | `CODE_SIGN_STYLE=Automatic` + `DEVELOPMENT_TEAM` + `-allowProvisioningUpdates` | | `0xe800801c (No code signature found.)` at install | built over SSH — the session's keychain has no signing identity | build from a logged-in GUI session | | `unsupported hardware — iPhone15,4 detected … requires iPhone 16 Pro or later` | the `Essence2` / `bitHumanKit` floor, checked at warm-up; there is no override | `Expression2` on that phone, or an A18 Pro / M-series device | | mic or speech start fails silently | missing `Info.plist` privacy strings; the OS caches the denial | add `NSMicrophoneUsageDescription` (and `NSSpeechRecognitionUsageDescription` for `bitHumanKit`) | | avatar disappears on re-render | a fresh renderer view on every SwiftUI update | return the same instance from `makeUIView` and `updateUIView` | | `404 NOT_FOUND` from `/v1/agent//model/download` | not an agent on your account, and not public | check the code under [your agents](/api/agents) or on the [showcase](/showcase) | | `409 MODEL_NOT_GENERATED` from the download | the agent has no Expression 2 model yet | [add the model](/api/agents#add-a-model-to-an-existing-agent), then poll | | `MODEL_ARTIFACT_NOT_READY` from the download | trained, not yet published to the download store | poll the same URL; it clears on its own | | `Essence2` refuses the file you downloaded: *need a directory with meta.json {"format":"elevatedir-v*" \| "essence2-light-dir-v*"}* | the Essence 2 engine opens an unpacked bundle directory, not the single `.imx` the download door serves; no such bundle is published for a phone today | use `Expression2`; Essence 2 renders through the [cloud API](/api/overview) | | a metered render refuses | `BITHUMAN_API_KEY` unset in the app's environment | set it — the Swift SDK reads that name, not `BITHUMAN_API_SECRET` | --- # macOS URL: https://docs.bithuman.ai/sdk/macos ## Install ```bash brew install ffmpeg # bithuman render writes the MP4 through ffmpeg curl -fsSL https://raw.githubusercontent.com/bithuman-product/homebrew-bithuman/main/install.sh | sh export PATH="$HOME/.local/bin:$PATH" # installs bithuman 2.6.7 (libessence 3.1.3 ABI 7) ``` One self-contained binary, sha256-verified against the release; `brew install bithuman-product/bithuman/bithuman-cli` installs the same tarball. **Apple Silicon only** — an Intel Mac has no binary and no Python wheel; use the [web](/sdk/web) or the [cloud API](/api/overview). ## Get a model Twenty showcase identities download with no account and no key — 4 Essence 2 (photoreal human), 16 Expression 2 (stylized or animal): ```bash bithuman list --manifest https://api.bithuman.ai/v1/models/showcase # slug, model, size MODEL=$(bithuman pull marmalade --manifest https://api.bithuman.ai/v1/models/showcase) # Expression 2 MODEL=$(bithuman pull afro-latina-astrophysics-mentor --manifest https://api.bithuman.ai/v1/models/showcase) # Essence 2 ``` `$MODEL` is a local `.imx` under `~/.cache/bithuman/`. `bithuman run` with no argument fetches the free Wise Pup avatar itself. Your own agent needs `bithuman login` once, then `bithuman pull ` (`--model essence-2` picks a family). ## Minimal code ```bash bithuman run "$MODEL" # live at http://127.0.0.1:8088/ — CoreML, Neural Engine ``` From **Python**, `pip install "bithuman[expression-2]"` (macOS 14 or newer, arm64) — everything after the install is on the [Python page](/sdk/python). From **Swift**, the `Expression2` product of the package on the [iOS page](/sdk/ios) builds for `macos-arm64` too. ## Run Open `http://127.0.0.1:8088/`, grant the microphone, talk. Without a sign-in the avatar renders but does not answer; `bithuman login` adds the conversation brain. To render a clip to a file instead: ```bash bithuman login # once; a free account is enough bithuman render "$MODEL" -a speech.wav -o clip.mp4 # 16 kHz mono WAV in, MP4 out ``` `render` refuses with exit 77 without a credential. A showcase identity measured 0 credits for every download and render on 2026-09-10; a session on your own agent is metered — [pricing](/guides/pricing) is the authority. ## Performance Unpaced (as fast as the engine renders, not paced to playback), from the published CLI 2.6.5 on an Apple M4: | Model | fps (unpaced) | Measured | |---|---:|---| | Expression 2 | **54** | 2026-09-10 — steady state over 32-frame chunks, 54–69; a whole 16 s clip including CoreML model load renders at 31 | | Essence 2 | **2** | 2026-09-11 — an offline `render` of 408 frames at 1920×1080 from a 16 s clip, 8 threads | Playback is 20 fps for Expression 2 and 25 fps for Essence 2. Every platform side by side: [Performance](/sdk/performance). ## Troubleshooting | You see | It means | Do this | |---|---|---| | the installer names your platform and exits 1 | Intel Mac — no binary has ever been built ([exact output](/sdk/cli/reference#platforms-with-no-binary)) | [web](/sdk/web) or the [cloud API](/api/overview) | | `bithuman: command not found` after the install | `~/.local/bin` is not on your `PATH` | `export PATH="$HOME/.local/bin:$PATH"` — the installer prints the same line | | `pull` exits 66: `--model applies to YOUR agent codes … not to the showcase slug` | on 2.6.5 a showcase identity is pulled by slug with `--manifest`, not by CODE with `--model` | the `pull` lines above | | `pull ` exits 77 | your own agent code, no sign-in (a showcase slug never needs one) | `bithuman login`, then pull again | | `render` exits 77, no output file | no credential | `bithuman login`, or `export BITHUMAN_API_SECRET=…` | | `Error: No available formula` | the tap is not known to Homebrew yet | `brew tap bithuman-product/bithuman`, then install again | | `No matching distribution found for bithuman` | macOS older than 14, or an Intel Mac | upgrade macOS, or use an Apple Silicon Mac | --- # Web URL: https://docs.bithuman.ai/sdk/web ## Install Nothing. The hosted route renders in any modern browser. ## Get a model Every showcase agent is public. `A74NWD9723` is one; browse the rest on the [showcase](/showcase) and use any code you see there. Your own agent's code comes from [Agents](/api/agents). ## Minimal code ```html ``` Or open the agent directly: `https://www.bithuman.ai/A74NWD9723?rendering_mode=browser` — a live, lip-synced agent that listens and answers, rendered in the tab. Start on `www.bithuman.ai`; that host mints the session, the viewer host does not. Swap in your own agent code and it works the same way. The `rendering_mode` switch and its three values are on [browser rendering](/guides/browser-rendering); embedding options are on [deploy an embed](/guides/deploy-embed). An [Essence 1](/concepts/essence-1) agent renders in the tab with `?render=local` instead: the tab fetches the agent's `.imx` and runs the audio front end in a published WebAssembly module, pinned to a commit in `https://tmoobjxlwcwvxvjeppzq.supabase.co/storage/v1/object/public/web/essence1-web/3.1.2/manifest.json`. ## Run ```bash open "https://www.bithuman.ai/A74NWD9723?rendering_mode=browser" ``` Grant the microphone, talk. A session is metered per active minute at the rate on [pricing](/guides/pricing); the free tier covers a first conversation. ## Performance The hosted route plays at the model's own frame rate — 20 fps for Expression 2, 25 fps for Essence 2 — because the frames are produced server-side and the tab decodes video. Rendering **in** the tab, unpaced (frames rendered back to back): | Hardware | Model | fps (unpaced) | Measured | |---|---|---:|---| | Chrome 141 on Linux x86_64, WASM, 4 threads | reduced Essence 2 renderer — keypoint-driven, without the teeth pipeline | **12** | 2026-09-10 — needs cross-origin isolation, or WASM clamps to 1 thread | That row is the one published in-browser package: a June student of the Essence 2 renderer that ships one built-in identity and a recorded loop. Use it to measure in-browser speed on your hardware, not to render your agent. Its bundle is `https://models.bithuman.ai/web/libelevate-web-v0.1.0/manifest.json` (every file with a sha256; `index.js` documents `createAvatar`; the path keeps a [retired name](/concepts/models-v2)), served with `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`. No in-browser Expression 2 package is published. Every platform side by side: [Performance](/sdk/performance). ## Troubleshooting | You see | It means | Do this | |---|---|---| | The hosted URL shows a page but no avatar | you opened the viewer host directly | start on `https://www.bithuman.ai/?rendering_mode=browser` | | `404` on the hosted URL | the agent code is wrong or the agent is not public | check the code on the [showcase](/showcase) or in your [agents](/api/agents) | | The in-tab renderer runs at ~8 fps instead of 20 | `crossOriginIsolated` is `false`, so WASM clamped to 1 thread | send the two headers above, or ship the bundle's `coi-serviceworker.js` | | `WebGPU not available in this browser` | `ep: "webgpu"` on a browser with no adapter | pass `"wasm"`, or [probe first](/examples/browser-webgpu-check#check-2--does-this-browser-have-a-real-webgpu-adapter) | | You want a JavaScript SDK | there is no npm package today | drive a served avatar over [LiveKit](/sdk/livekit), or embed the hosted route | --- # CLI reference URL: https://docs.bithuman.ai/sdk/cli/reference The two-command quickstart is on [the CLI page](/sdk/cli). This page is everything else: the subcommands, the flags, the environment, the cache, the exit codes and the machine-readable contract. Anything here can be re-derived from the binary itself — `bithuman --help` for one command, `bithuman __schema` for the whole command / flag / exit-code tree as JSON, generated from the binary so it cannot drift from your install. ## Version ```text $ bithuman --version libessence 3.1.3 ABI 7 bithuman 2.6.7 ``` **`cli-v2.6.7` is the current release**, the same version on macOS arm64 and Linux x86_64. The first line names the engine version — a separate axis from the CLI's own number, printed under the engine's legacy spelling because it is the string you have to grep for; the product name is [essence-2](/concepts/essence-2). Do not pin a CLI version unless you have a reason: the installer takes the newest release, and that is the tested one. ## Subcommands | Command | What it does | | --- | --- | | `bithuman run [avatar]` | Live avatar. No argument fetches and renders the free Wise Pup `expression-2` avatar; pass a file or an agent code to run your own | | `bithuman render -a