API reference
The Bitmesh API puts chat, image, video and transcription models from many providers behind one endpoint, along with ready-made image tools such as background removal and virtual try-on. Your app talks to Bitmesh with an API key you issue and can limit; the provider keys stay on our side, so there is nothing worth stealing in a mobile bundle or a browser tab.
The API is organised around plain HTTP. Requests carry JSON (transcription and tool uploads are multipart), responses are JSON, and standard status codes tell you what happened.
Every API key is a pair: a consumer key that identifies it and a consumer secret that signs requests. The secret is never sent over the network. When you create a key you can cap its tokens or requests, lock it to one model, or give it an expiry date.
Paths are also served under a /v1 prefix. Unprefixed paths, used throughout this reference, point at the current version.
https://api.bitmesh.ai
-
POST
/chatCreate a completion -
POST
/imageGenerate an image -
GET
/imgrslt/{object_id}View an image -
POST
/videoGenerate a video -
GET
/video/{id}Retrieve a video -
GET
/imgrslt/{object_id}View a video -
POST
/transcribe-recordedCreate a transcription -
GET
/transcribe-recorded/{id}Retrieve a transcription -
POST
/tools/general/background-removalRemove a background -
POST
/tools/portrait/try-on-clothesTry on clothes -
POST
/tools/query-async-task-resultRetrieve a tool task -
POST
/tools/video/image-animateAnimate an image -
GET
/tools/video/image-animate/{task_id}Retrieve an animation -
GET
/balanceBalance
Quickstart
Three steps take you from nothing to a model's reply.
- Create an API key. Open API keys in the dashboard and create one. Copy the consumer key and the consumer secret.
- Install an SDK. There is one for PHP, Python, Swift and Dart, and it signs each request for you. For any other language, sign requests yourself.
- Send a message. Call
/chatwith a model and a list of messages.
Keep the secret out of source control: read it from an environment variable or your platform's secret store. While you are wiring things up, add "test": true to the body and the call costs nothing.
composer config repositories.bitmesh vcs https://github.com/bitmeshai/bitmesh-php-sdk composer require bitmeshai/bitmesh-php-sdk:^2.1
pip install "git+https://github.com/bitmeshai/bitmesh-python-sdk.git"
// Package.swift, or File > Add Package Dependencies in Xcode .package(url: "https://github.com/bitmeshai/bitmesh-ios-sdk.git", from: "1.0.0")
# pubspec.yaml
dependencies:
bitmesh_flutter_sdk:
git:
url: https://github.com/bitmeshai/bitmesh-flutter-sdk.git
ref: v0.3.0
use BitmeshAI\BitmeshClient; // Optional third argument: timeout in seconds (default 30) $client = new BitmeshClient('YOUR_CONSUMER_KEY', 'YOUR_CONSUMER_SECRET'); $response = $client->chat([ 'model' => 'qwen/qwen3.5-9b', 'messages' => [ ['role' => 'user', 'content' => 'Suggest three names for a hiking app.'], ], ]); echo $response['choices'][0]['message']['content'];
import os from bitmesh_ai import BitmeshClient # Optional third argument: timeout_seconds (default 30) client = BitmeshClient( os.environ["BITMESH_CONSUMER_KEY"], os.environ["BITMESH_CONSUMER_SECRET"], ) response = client.chat({ "model": "qwen/qwen3.5-9b", "messages": [ {"role": "user", "content": "Suggest three names for a hiking app."} ], }) print(response["choices"][0]["message"]["content"])
import Bitmesh
let client = BitmeshClient(key: consumerKey, secret: consumerSecret)
let text = try await client.chatContent(
"Suggest three names for a hiking app.",
model: "qwen/qwen3.5-9b"
)
import 'package:bitmesh_flutter_sdk/bitmesh_flutter_sdk.dart'; // Create one client and reuse it final client = BitmeshClient.fromKeys(consumerKey, consumerSecret); final completion = await client.chat.create(const ChatRequest( model: 'qwen/qwen3.5-9b', messages: [ ChatMessage.user('Suggest three names for a hiking app.'), ], )); print(completion.text);
Authentication
Bitmesh authenticates with one-legged OAuth 1.0: each request is signed with your consumer secret, and there are no user tokens or redirects. The secret itself never travels, so a captured request can't be used to make a different one.
The SDKs do all of this for you. Read on if you are signing requests yourself.
Shipping the key inside an iOS or Android app? Use a mobile key, which also needs a Firebase App Check token on every request.
A signed request carries two things:
- an
Authorizationheader holding the OAuth parameters and their signature, which proves who is calling; - an
X-Payload-Signatureheader, which ties the body to that signature so it can't be swapped in transit.
Build the OAuth signature
- Normalise the URL to
scheme://host/path. Leave the port out unless it is non-standard, and leave the query string out. - Collect the parameters: the five
oauth_*values below plus any query string parameters. The JSON body is not part of this signature. - Sort and encode them. Sort by name, URL-encode each name and value, join each pair with
=and the pairs with&. - Build the base string: the HTTP method, the normalised URL and the parameter string, each URL-encoded, joined with
&. - Sign it. The signing key is your URL-encoded consumer secret followed by
&. Compute the HMAC-SHA1 of the base string with that key and Base64-encode the result. That isoauth_signature.
Sign the payload
X-Payload-Signature is the SHA-256 hash, in hex, of three strings joined end to end: the raw request body, your consumer key, and the oauth_signature you just computed.
Hash the exact bytes you send. For a GET the body is an empty string. For a multipart upload, the body part is the JSON encoding of the non-file fields sorted by name; see Create a transcription and How tools work.
What gets rejected
A request is refused with a 401 when an OAuth parameter is missing, the timestamp is more than 60 seconds old, the key is unknown, inactive or expired, or either signature doesn't match. On a mobile key it is also refused when the App Check token is missing or doesn't verify. Generate the timestamp and nonce at the moment you send, and keep your server's clock in sync.
OAuth parameters
- oauth_consumer_key string Required
- The consumer key of your API key. It identifies the key, much like a username.
- oauth_signature_method string Required
-
Always
HMAC-SHA1(RFC 5849 §3.4.2). - oauth_timestamp string Required
- Unix time, in seconds, when the request was made. Requests older than 60 seconds are refused.
- oauth_nonce string Required
- A random string, unique to this request. 32 characters is a good length.
- oauth_version string
-
Always
1.0. - oauth_signature string Required
- The Base64 HMAC-SHA1 signature built in the steps above. It is the only parameter left out of its own base string.
Request headers
- Authorization header Required
-
OAuthfollowed by the parameters above as comma-separatedname="value"pairs, values URL-encoded. - X-Payload-Signature header Required
-
Hex SHA-256 of body + consumer key +
oauth_signature. - Content-Type header
-
application/jsonfor JSON bodies,multipart/form-datafor uploads. - Accept header
-
application/json.
Authorization: OAuth oauth_consumer_key="YOUR_CONSUMER_KEY",
oauth_signature_method="HMAC-SHA1",
oauth_timestamp="1766991207",
oauth_nonce="b2362ab1f26de6ea",
oauth_version="1.0",
oauth_signature="1Dk9QHMOfVpSJMUmRJjOcwqanSI%3D"
X-Payload-Signature: 09898af05784a4eb7d3a68323f0d56bea18598a71f4317c535297cdf7325b867
Content-Type: application/json
Accept: application/json
$consumerKey = 'YOUR_CONSUMER_KEY'; $consumerSecret = 'YOUR_CONSUMER_SECRET'; $method = 'POST'; $url = 'https://api.bitmesh.ai/chat'; $body = json_encode([ 'model' => 'qwen/qwen3.5-9b', 'messages' => [['role' => 'user', 'content' => 'Hello']], ]); // 1. OAuth parameters $oauth = [ 'oauth_consumer_key' => $consumerKey, 'oauth_signature_method' => 'HMAC-SHA1', 'oauth_timestamp' => (string) time(), 'oauth_nonce' => bin2hex(random_bytes(16)), 'oauth_version' => '1.0', ]; // 2. Sort, encode and join them (add query parameters here too) ksort($oauth); $pairs = []; foreach ($oauth as $name => $value) { $pairs[] = rawurlencode($name).'='.rawurlencode($value); } // 3. Base string: METHOD & URL & PARAMETERS $base = rawurlencode($method).'&'.rawurlencode($url).'&'.rawurlencode(implode('&', $pairs)); // 4. HMAC-SHA1 with "secret&" as the key $oauth['oauth_signature'] = base64_encode( hash_hmac('sha1', $base, rawurlencode($consumerSecret).'&', true) ); // 5. Authorization header $parts = []; foreach ($oauth as $name => $value) { $parts[] = $name.'="'.rawurlencode($value).'"'; } $authorization = 'OAuth '.implode(', ', $parts); // 6. Payload signature: body + consumer key + oauth signature $payloadSignature = hash('sha256', $body.$consumerKey.$oauth['oauth_signature']); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: '.$authorization, 'X-Payload-Signature: '.$payloadSignature, 'Content-Type: application/json', 'Accept: application/json', ], ]); $response = json_decode(curl_exec($ch), true);
App Check for mobile apps
If your API key ships inside an iOS or Android app, use a mobile key with Firebase App Check. If the key only lives on your own server, you don't need any of this.
The problem it solves
A mobile app has to carry its consumer key and secret to sign requests. Anything inside an app binary can be extracted: decompiling an APK or IPA takes minutes. Whoever has the pair can then sign requests from their own script and spend your credits, and to Bitmesh those requests look exactly like your app's.
What App Check is
Firebase App Check is a Google service that lets a server ask "did this request come from my real app, on a real device?"
- Your app asks the phone's operating system to vouch for it. Android uses Play Integrity, iOS uses App Attest. They confirm the app is your unmodified build, signed by you, running on a genuine device.
- Firebase turns that into an App Check token: a short-lived signed JWT (valid for 1 hour by default) that names your Firebase project and the app it was issued to.
- Your app sends the token with each request in the
X-Firebase-AppCheckheader. - Bitmesh checks the token's signature against Firebase's public keys, then checks it was issued for the project and one of the apps saved on your API key.
A script holding your stolen secret can still sign a request, but it can't get a token, so the request is refused.
When to use it
| Where the key lives | Key type | App Check |
|---|---|---|
| Inside an iOS or Android app you ship to users | Mobile app | Required on every request |
| On your server, called by your app or website | Server | Not used |
| In a browser, or a desktop app | — | Don't do this. Call Bitmesh from your server instead. |
You choose the type under Where the key lives when you create a key in API keys. A server key ignores the X-Firebase-AppCheck header entirely.
What it changes, and what it doesn't
- App Check is added on top of request signing. You still send
AuthorizationandX-Payload-Signatureexactly as before. - The
X-Firebase-AppCheckheader is not part of either signature. Sign the request as usual, then add the header. - The signature is checked first. A request with a bad signature fails with the signature error, whatever its token.
- Limits, balance, models and responses work the same as on a server key.
- App Check raises the cost of abuse a lot, but no client-side check is absolute. Keep sensible token and request limits on mobile keys too.
Set it up
1. Add your apps to Firebase. In the Firebase console, create a project, or use the one your app already has, and add each Android and iOS app under Project settings → General → Your apps. Your app also needs the Firebase SDK set up: run flutterfire configure in a Flutter project, or add google-services.json (Android) and GoogleService-Info.plist (iOS) by hand.
2. Register the attestation providers. In Security → App Check → Apps:
- Android, Play Integrity: first link the Firebase project in Google Play Console under Release → App integrity → Play Integrity API → Link Cloud project. Then copy the SHA-256 of the App signing key certificate from that page into Play Integrity in Firebase. Add the upload key's SHA-256 too if you test release builds installed outside Play.
- iOS, App Attest: enter your Apple Team ID (developer.apple.com → Membership details). In Xcode, add the App Attest capability to the app target; its environment must be
productionfor TestFlight and App Store builds.
Leave the APIs tab alone. Its Enforce buttons are for Firebase's own products such as Firestore. Bitmesh isn't listed there; it checks tokens itself.
3. Copy two values from Project settings → General:
- Project number: digits only, such as
123456789012. This is not the Project ID (my-app-1a2b3). - App ID of each app, such as
1:123456789012:android:abc123def456. The number after the first colon is the project number; if it doesn't match, the app is in a different project.
4. Create a mobile key. In API keys, create a key, choose Mobile app, paste the project number and add each App ID under Allowed apps. One key can allow up to 20 apps, so a single key can serve both your Android and iOS builds. The form refuses an App ID from a different project.
5. Send the token from your app. Turn App Check on at startup, before the first request. Then fetch a token right before each request and put it in X-Firebase-AppCheck. That means every endpoint, including GET /balance and the GET calls that poll a video or transcription. The Firebase SDK caches and refreshes tokens, so this costs almost nothing. Don't store tokens yourself.
The Dart SDK does this for you from version 0.2.0: set appCheckTokenProvider, as in the sample. A failed token fetch throws BitmeshAppCheckTokenException before anything is sent, and a refusal from the API throws BitmeshAppCheckRejectedException. The Swift SDK can't add headers yet: until it can, build requests yourself as described in Authentication and set the header on each one. Native Android apps use Firebase's firebase-appcheck-playintegrity library and send the same header.
6. Test before you release.
- Run a debug build with its debug token registered (see below). Requests should succeed.
- Send one request without the header. It should fail with
401andMissing X-Firebase-AppCheck header. This proves the key really is a mobile key. - Install a release build on a real device, from Play internal testing or TestFlight. Requests should succeed.
- Check request logs. Every refused request is listed under Denied with the reason.
Emulators, simulators and CI
Play Integrity and App Attest don't work on emulators or simulators, so use Firebase's debug provider there. On its first run it prints a debug token to the log: on Android look in logcat for Enter this debug secret into the allow list, on iOS in the Xcode console for Firebase App Check Debug Token. Add that token in the Firebase console under App Check → Apps → your app → Manage debug tokens. Tokens issued this way are real and pass Bitmesh's checks. There is no test bypass on the server, and there won't be one.
Never ship a release build with the debug provider on. Anyone holding a registered debug token can then mint valid tokens, and nothing will warn you. Pick the provider by build mode, as the sample does. If a debug token leaks, delete it in the console.
Moving an app that already uses a server key
Don't switch the live key to Mobile app. As soon as you save, every installed copy that doesn't send a token is refused. Instead:
- Create a new mobile key.
- Ship an app version that uses the new key and sends App Check tokens.
- Leave the old key active for users who haven't updated yet. Its secret is in old app builds, so treat it as public and lower its limits over time.
- Deactivate the old key once request logs show its traffic is near zero.
Token and request limits apply per key, so while both keys are live your app has two separate budgets. Credits come from one account balance either way.
Errors
Every App Check failure is a 401 with the reason in error. Your request logs show the same reason, prefixed with App Check verification failed:.
error |
What to fix |
|---|---|
Missing X-Firebase-AppCheck header |
No token was sent. Check that getToken() returned one and that the header is set on every request. |
App Check token is malformed |
The value isn't a token from the Firebase SDK, or it was cut short. |
App Check token must use RS256, App Check token must have typ JWT |
Same cause: the value isn't a Firebase App Check token. |
App Check token signature is invalid |
The token wasn't issued by Firebase, or it was altered. |
App Check token has expired |
Fetch a new token right before each request. Don't reuse old ones. |
App Check token issuer is not recognised |
The token comes from a different Firebase project than the key's project number. |
App Check token audience does not match this project |
The key has the wrong project number. A common cause is entering the Project ID instead. |
App Check token is for an unregistered app |
This app's App ID isn't in the key's Allowed apps. Add it. |
API key is not configured for App Check |
The key is a mobile key without a project number or apps. Edit the key and add them. |
App Check verification is temporarily unavailable |
Our side couldn't load Firebase's public keys. Retry after a pause, and contact contact@bitmesh.ai if it lasts. |
If the Firebase SDK in your app fails to get a token at all, the problem is in step 2: the Play Console link, the SHA-256 fingerprint, the Team ID or the App Attest capability.
A Warning in your request logs means the request went through but failed a check that isn't being enforced yet. Fix it the same way, because once it is enforced the same request is refused.
Checklist
- Android: Play Integrity linked to the Firebase project; app signing SHA-256 added in App Check.
- iOS: Team ID added in App Check; App Attest capability in Xcode,
productionenvironment. - Debug tokens registered for every emulator, simulator and CI runner.
- A Mobile app key with the project number and every App ID.
- Debug provider used only in debug builds.
- A fresh token in
X-Firebase-AppCheckon every request. - Tested with a release build on a real device.
- Any old server key kept active until its traffic is gone, then deactivated.
Request header
- X-Firebase-AppCheck header Required
- On mobile keys only. A current App Check token from the Firebase SDK, fetched just before the request. Not part of the OAuth or payload signature.
Mobile key settings
- Project number digits Required
-
The Firebase project number, such as
123456789012. Tokens must be issued for this project. Not the Project ID. - Allowed apps list Required
-
Firebase App IDs whose tokens are accepted, such as
1:123456789012:ios:0f1e2d3c4b5a6978. Between 1 and 20, all from the same project.
// pubspec.yaml: firebase_core, firebase_app_check import 'package:firebase_app_check/firebase_app_check.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(); // Debug provider in debug builds ONLY. Never in a release. await FirebaseAppCheck.instance.activate( providerAndroid: kDebugMode ? AndroidDebugProvider() : AndroidPlayIntegrityProvider(), providerApple: kDebugMode ? AppleDebugProvider() : AppleAppAttestProvider(), ); runApp(const MyApp()); }
import FirebaseAppCheck
import FirebaseCore
class AppAttestFactory: NSObject, AppCheckProviderFactory {
func createProvider(with app: FirebaseApp) -> AppCheckProvider? {
AppAttestProvider(app: app)
}
}
// Set the factory BEFORE FirebaseApp.configure().
// Debug provider in debug builds ONLY. Never in a release.
#if DEBUG
AppCheck.setAppCheckProviderFactory(AppCheckDebugProviderFactory())
#else
AppCheck.setAppCheckProviderFactory(AppAttestFactory())
#endif
FirebaseApp.configure()
import 'package:bitmesh_flutter_sdk/bitmesh_flutter_sdk.dart'; import 'package:firebase_app_check/firebase_app_check.dart'; // Called before every request, retries and polling included. // Firebase caches the token and refreshes it before it expires. final client = BitmeshClient.fromKeys( consumerKey, consumerSecret, options: BitmeshOptions( appCheckTokenProvider: () => FirebaseAppCheck.instance.getToken(), ), );
// The Swift SDK can't add headers yet. Sign the request // yourself (see Authentication), then: let token = try await AppCheck.appCheck() .token(forcingRefresh: false).token request.setValue(token, forHTTPHeaderField: "X-Firebase-AppCheck")
POST /chat Authorization: OAuth oauth_consumer_key="YOUR_MOBILE_KEY", ... X-Payload-Signature: 09898af05784a4eb7d3a6832... X-Firebase-AppCheck: eyJraWQiOiJ2Yy1sQkEiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9... Content-Type: application/json
{
"error": "Missing X-Firebase-AppCheck header"
}
SDKs
An SDK builds the OAuth signature and the payload signature on every call, so your code only deals with models and messages. Pick a language with the tabs above any code sample and the whole reference switches to it.
| Language | Package | Runs on |
|---|---|---|
| PHP | bitmeshai/bitmesh-php-sdk | PHP, via Composer |
| Python | bitmesh-python-sdk | Python 3.10 and later |
| Swift | bitmesh-ios-sdk | iOS 15, macOS 12 and later |
| Dart | bitmesh_flutter_sdk | Flutter, including web, and plain Dart |
All four are open source and expose the same calls, one per endpoint in this reference. Names follow each language's conventions: getVideo in PHP and Swift is get_video in Python.
| Method | Endpoint |
|---|---|
chat |
POST /chat |
image |
POST /image |
video |
POST /video |
getVideo |
GET /video/{id} |
transcribeFile |
POST /transcribe-recorded |
getTranscribeRecorded |
GET /transcribe-recorded/{id} |
toolsGeneralBackgroundRemoval |
POST /tools/general/background-removal |
toolsPortraitTryOnClothes |
POST /tools/portrait/try-on-clothes |
toolsQueryAsyncTaskResult |
POST /tools/query-async-task-result |
imageAnimate |
POST /tools/video/image-animate |
getImageAnimate |
GET /tools/video/image-animate/{task_id} |
Each takes the request body described here and returns the decoded JSON response; the tool upload methods take the text fields and the files as two arguments. The Python SDK has no imageAnimate or getImageAnimate yet. The Dart SDK adds typed requests and responses, retries, and waitFor helpers that poll a video, transcription or tool job until it finishes; the Dart samples use those.
Requests time out after 30 seconds by default. Raise the timeout when you create the client for video and long audio.
No SDK for your stack? The signature is a few lines of code. Authentication walks through it.
composer config repositories.bitmesh vcs https://github.com/bitmeshai/bitmesh-php-sdk composer require bitmeshai/bitmesh-php-sdk:^2.1
pip install "git+https://github.com/bitmeshai/bitmesh-python-sdk.git"
// Package.swift, or File > Add Package Dependencies in Xcode .package(url: "https://github.com/bitmeshai/bitmesh-ios-sdk.git", from: "1.0.0")
# pubspec.yaml
dependencies:
bitmesh_flutter_sdk:
git:
url: https://github.com/bitmeshai/bitmesh-flutter-sdk.git
ref: v0.3.0
use BitmeshAI\BitmeshClient; // Optional third argument: timeout in seconds (default 30) $client = new BitmeshClient('YOUR_CONSUMER_KEY', 'YOUR_CONSUMER_SECRET');
import os from bitmesh_ai import BitmeshClient # Optional third argument: timeout_seconds (default 30) client = BitmeshClient( os.environ["BITMESH_CONSUMER_KEY"], os.environ["BITMESH_CONSUMER_SECRET"], )
import Bitmesh let client = BitmeshClient(key: consumerKey, secret: consumerSecret)
import 'package:bitmesh_flutter_sdk/bitmesh_flutter_sdk.dart'; // Create one client and reuse it final client = BitmeshClient.fromKeys(consumerKey, consumerSecret);
Errors
Bitmesh uses conventional HTTP status codes. 2xx means the request worked, 4xx means something about the request has to change before it can, and 5xx means the fault is on our side or the model provider's.
An error response is a JSON object with an error string. Some errors add detail alongside it: message with a longer explanation, errors with one entry per invalid field, or figures such as balance and retry_after.
| Status | Meaning |
|---|---|
200 |
The request succeeded. |
401 |
The signature is missing, wrong or stale, the API key is unknown, inactive or expired, or a mobile key's App Check token is missing or invalid. |
402 |
The account has no credits left. Add credits to continue. |
404 |
No such path or job. |
413 |
The uploaded file is larger than the server accepts. |
422 |
Validation failed. errors names each field and what is wrong with it. |
429 |
A rate limit or a key's token or request budget was reached. |
500 |
Something failed on our side. Safe to retry after a pause. |
503 |
The endpoint is switched off for now, as can happen to a tool. Try again later. |
When a model provider rejects a request, its status code and error are passed through, so error can be an object in that case rather than a string.
Every request, including the ones that were refused, is recorded with the reason in your request logs. That is the fastest place to find out why a call failed.
Error fields
{
"error": "Invalid signature"
}
{
"error": "Validation failed",
"errors": {
"messages": ["The messages field is required."]
}
}
{
"error": "Insufficient balance",
"message": "Your have ran out of tokens. Please add tokens to continue.",
"balance": 0
}
Rate limits
Limits apply in layers, and a request has to fit inside all of them.
| Scope | Limit |
|---|---|
| All endpoints, per IP address | 60 requests a minute |
| All endpoints, per IP address | 1,000 requests an hour |
| All endpoints, per API key | 100 requests a minute |
POST /chat, per IP address |
20 requests a minute |
POST /image, /video, /transcribe-recorded, /tools/*, per IP address |
10 requests a minute |
Go over one and the API answers 429 with a Retry-After header and the same number of seconds in retry_after. Wait that long before trying again, and back off further if it keeps happening.
These are separate from the token limit and request limit you can put on an individual API key. Those are lifetime budgets rather than rates: once a key has used its budget it gets a 429 until you raise the limit in API keys.
Because the per-IP limits count the caller's address, requests sent straight from your users' devices are counted per user, while requests relayed through your own server share that server's allowance.
{
"error": "Too many chat requests",
"message": "You have exceeded the chat rate limit. Please wait before sending more requests.",
"retry_after": 42
}
{
"error": "Request limit exceeded",
"message": "You have exceeded your request limit for this Api Key. Please contact support or upgrade your plan.",
"request_usage": 5000,
"request_limit": 5000
}
Test mode
Add "test": true to a chat or transcription request and Bitmesh answers with canned data instead of calling a model. No credits are charged.
Tools take the flag too. Uploads to Remove a background and Try on clothes send it as the form field test with the value "1". Animate an image takes "test": true in its JSON, and you poll the made-up job with ?test=1.
The request still goes through signature checks, validation and rate limits, and still counts toward the key's request limit. The account also needs credits left: at zero, a test request gets the same 402 as a live one. That makes test mode the quickest way to prove that your signing code and your payload are right before real traffic depends on them.
Test responses have the same shape as live ones, but their content is fixed. Don't use them to judge a model. /image and /video requests have no test mode; they always run.
{
"model": "qwen/qwen3.5-9b",
"messages": [
{"role": "user", "content": "Hello"}
],
"test": true
}
Create a chat completion
POST
/chat
Sends a list of messages to a chat model and returns the model's reply. The same request works across every chat model in the catalogue; changing provider is a matter of changing model.
The request must be signed. See Authentication.
Body parameters
- messages array of objects Required
-
The conversation so far, oldest first. At least one message.
Child attributes
- role string Required
-
Who wrote the message:
system,user,assistantortool. - content string or array Required
- The text of the message, or an array of text and image parts for vision models.
- model string Conditional
-
ID of the model to run, as listed in the model catalogue. Required unless the API key is locked to a model. If it is, the key's model is always used and sending
modelis rejected with a422. - max_tokens integer
- The most tokens the model may generate in its reply. At least 1.
- temperature number
- Randomness of the reply, from 0 to 2. Lower is more focused and repeatable, higher is more varied.
- frequency_penalty number
- From -2 to 2. Positive values make the model less likely to repeat tokens it has already used often.
- presence_penalty number
- From -2 to 2. Positive values nudge the model toward tokens it has not used yet, and so toward new topics.
- repetition_penalty number
- Zero or more. Higher values discourage repeated sequences. Not every model supports it.
Returns
A chat completion object. The reply is in choices[0].message.content, and usage reports the tokens the request was billed for. The body is passed through from the model's provider, so some models name fields in camelCase (finishReason, promptTokens) where others use snake_case.
$response = $client->chat([
'model' => 'qwen/qwen3.5-9b',
'messages' => [
['role' => 'system', 'content' => 'You are a helpful assistant.'],
['role' => 'user', 'content' => 'What are some fun things to do with AI?'],
],
'max_tokens' => 1000,
]);
$text = $response['choices'][0]['message']['content'] ?? null;
response = client.chat({
"model": "qwen/qwen3.5-9b",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What are some fun things to do with AI?"},
],
"max_tokens": 1000,
})
text = response["choices"][0]["message"]["content"]
let response = try await client.chat(payload: [
"model": "qwen/qwen3.5-9b",
"messages": [
["role": "system", "content": "You are a helpful assistant."],
["role": "user", "content": "What are some fun things to do with AI?"],
],
"max_tokens": 1000,
])
let choices = response["choices"] as? [[String: Any]]
let message = choices?.first?["message"] as? [String: Any]
let text = message?["content"] as? String
final completion = await client.chat.create(const ChatRequest( model: 'qwen/qwen3.5-9b', messages: [ ChatMessage.system('You are a helpful assistant.'), ChatMessage.user('What are some fun things to do with AI?'), ], maxTokens: 1000, )); final text = completion.text;
{
"model": "qwen/qwen3.5-9b",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What are some fun things to do with AI?"}
],
"max_tokens": 1000,
"temperature": 0.8
}
{
"id": "oR7yAhG-57nCBj-9b576165af92311c",
"object": "chat.completion",
"created": 1766991277,
"model": "qwen/qwen3.5-9b",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "There are plenty. You could build a chatbot, generate images from a description, or have a model draft a story with you...",
"tool_calls": []
}
}
],
"usage": {
"prompt_tokens": 51,
"completion_tokens": 390,
"total_tokens": 441,
"cached_tokens": 0
}
}
Multi-turn conversations
Each request stands alone: the model remembers nothing from the last one. To hold a conversation, send the history back in messages every time, with the user's earlier turns as user messages and the model's replies as assistant messages.
In the example the model can answer "where is it?" only because the previous exchange is included. How you store the history, and how much of it you send, is up to you. Longer histories cost more input tokens, so trim or summarise old turns once they stop mattering.
{
"model": "qwen/qwen3.5-9b",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What are some fun things to do in New York?"},
{"role": "assistant", "content": "You could visit the Empire State Building, walk through Central Park, or explore the Metropolitan Museum of Art!"},
{"role": "user", "content": "That sounds fun! Where is the Empire State Building located?"}
]
}
Image input
Models that accept images take content as an array of parts instead of a plain string. Mix text parts with image_url parts in the order you want the model to read them.
The example passes the image as a URL the model provider can fetch. Check a model's page in the catalogue to see whether it accepts images.
Content parts
$response = $client->chat([
'model' => 'qwen/qwen3.5-9b',
'messages' => [
[
'role' => 'user',
'content' => [
['type' => 'text', 'text' => 'Describe this image'],
[
'type' => 'image_url',
'image_url' => ['url' => 'https://placecats.com/800/600'],
],
],
],
],
'max_tokens' => 1000,
]);
response = client.chat({
"model": "qwen/qwen3.5-9b",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this image"},
{"type": "image_url", "image_url": {"url": "https://placecats.com/800/600"}},
],
}
],
"max_tokens": 1000,
})
let content: [[String: Any]] = [
["type": "text", "text": "Describe this image"],
["type": "image_url", "image_url": ["url": "https://placecats.com/800/600"]],
]
let message: [String: Any] = ["role": "user", "content": content]
let response = try await client.chat(payload: [
"model": "qwen/qwen3.5-9b",
"messages": [message],
"max_tokens": 1000,
])
{
"model": "qwen/qwen3.5-9b",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this image"},
{"type": "image_url", "image_url": {"url": "https://placecats.com/800/600"}}
]
}
],
"max_tokens": 1000
}
Generate an image
POST
/image
Generates images from a text prompt and returns a link to each one. The request waits for the image, so the response already contains the finished result.
Models differ in the parameters they accept and the sizes they can draw. A parameter the model doesn't support, such as steps on some models, is refused with a 400 that names it, and some models only draw a fixed list of sizes such as 1024×1024. Check the model's page in the catalogue.
The request must be signed. See Authentication.
Body parameters
- prompt string Required
- A description of the image you want.
- model string Conditional
-
ID of the model to run, as listed in the model catalogue. Required unless the API key is locked to a model. If it is, the key's model is always used and sending
modelis rejected with a422. - width integer default 1024
- Width in pixels. At least 1; the sizes a model accepts vary.
- height integer default 1024
- Height in pixels. At least 1; the sizes a model accepts vary.
- steps integer
- Number of inference steps. More steps usually means more detail and a slower response.
- seed integer
- Fixes the randomness, so the same seed and inputs reproduce the same result.
- n integer
- How many images to generate. At least 1.
- reference_images array of strings
- URLs of source images to edit or build on. See Reference images.
Returns
A list object whose data holds one entry per image. Each url is a result link to the image.
$response = $client->image([
'prompt' => 'Flying car',
'model' => 'google/gemini-3-pro-image',
'width' => 1024,
'height' => 1024,
]);
$url = $response['data'][0]['url'] ?? null;
response = client.image({
"prompt": "Flying car",
"model": "google/gemini-3-pro-image",
"width": 1024,
"height": 1024,
})
url = response["data"][0]["url"]
let response = try await client.image(payload: [
"prompt": "Flying car",
"model": "google/gemini-3-pro-image",
"width": 1024,
"height": 1024,
])
final result = await client.images.generate(const ImageRequest( prompt: 'Flying car', model: 'google/gemini-3-pro-image', width: 1024, height: 1024, )); final url = result.first?.resolveUrl(client.options.baseUrl);
{
"prompt": "Flying car",
"model": "google/gemini-3-pro-image",
"width": 1024,
"height": 1024,
"seed": 22
}
{
"id": "b26aec3d-aws_as1",
"model": "google/gemini-3-pro-image",
"object": "list",
"data": [
{
"index": 0,
"url": "https://api.together.ai/imgproxy/eZnJQhev9e83egwFF4tDKsJTQTcw5SACdmI7z5C9NEY/format:jpeg/aHR0cHM6Ly9pbS5ydW53YXJlLmFpL2ltYWdlL29zL3cwNGRsaW0zL3dzLzMvaWkvY2ZlZDYxZWMtZjNiNy00ZmRmLWI5MDgtNmJkMzNmZjU5NWVkLmpwZw",
"timings": {
"inference": 16617
}
}
]
}
Reference images
For image-to-image work, pass the source images in reference_images and describe the change you want in prompt. Each entry is the public URL of an image.
Only some models accept reference images, and they differ in how many they take. Check the model's page in the catalogue before relying on it.
$response = $client->image([
'prompt' => 'Make this cat smile',
'model' => 'wan-ai/wan2.6-image',
'reference_images' => [
'https://placecats.com/800/600',
],
]);
$url = $response['data'][0]['url'] ?? null;
response = client.image({
"prompt": "Make this cat smile",
"model": "wan-ai/wan2.6-image",
"reference_images": [
"https://placecats.com/800/600",
],
})
let response = try await client.image(payload: [
"prompt": "Make this cat smile",
"model": "wan-ai/wan2.6-image",
"reference_images": [
"https://placecats.com/800/600",
],
])
{
"prompt": "Make this cat smile",
"model": "wan-ai/wan2.6-image",
"reference_images": [
"https://placecats.com/800/600"
]
}
Guide: FLUX.2
Black Forest Labs' FLUX.2 models all take the same /image payload. Start with a prompt, choose a variant with model, and set width and height.
| Model | Best for |
|---|---|
black-forest-labs/FLUX.2-max |
The highest quality |
black-forest-labs/FLUX.2-pro |
Production quality and speed |
black-forest-labs/FLUX.2-dev |
Development and iteration |
black-forest-labs/FLUX.2-flex |
Fine control, such as typography |
Text to image. The smallest useful payload is a prompt, a model and a size.
Tuning steps. FLUX.2 [dev] lets you set steps. More steps typically add detail and latency.
Editing an image. Pass the source in reference_images and describe the change. The third example recolours the mug in the image produced by the second.
{
"prompt": "A mountain landscape at sunset with golden light reflecting on a calm lake",
"model": "black-forest-labs/FLUX.2-pro",
"width": 1024,
"height": 768
}
{
"prompt": "A modern workspace with a laptop, coffee cup, and plants, natural lighting",
"model": "black-forest-labs/FLUX.2-dev",
"width": 1024,
"height": 768,
"steps": 20
}
{
"prompt": "Replace the color of the mug to green",
"model": "black-forest-labs/FLUX.2-pro",
"width": 1024,
"height": 768,
"reference_images": [
"https://www.bitmesh.ai/images/doc-examples/laptop_flux2-dev.jpeg"
]
}
View an image
GET
/imgrslt/{object_id}
Each url returned by Generate an image is a direct link to the image file. Use it exactly as returned: depending on the model, it points at the model provider's image service or at /imgrslt/ on the API.
Result links need no signature, so they work directly in a browser, an <img> or a <video> tag. Treat the link itself as the secret.
Returns
The image file.
GET https://api.bitmesh.ai/imgrslt/Il4vHBVFuu8tiaXP
<img src="https://api.together.ai/imgproxy/eZnJQhev9e83egwFF4tDKsJTQTcw5SACdmI7z5C9NEY/format:jpeg/aHR0cHM6Ly9pbS5ydW53YXJlLmFpL2ltYWdlL29zL3cwNGRsaW0zL3dzLzMvaWkvY2ZlZDYxZWMtZjNiNy00ZmRmLWI5MDgtNmJkMzNmZjU5NWVkLmpwZw" alt="Flying car">
Generate a video
POST
/video
Starts a video generation job. Video takes a while, so this call returns straight away with a job whose status is in_progress. Poll Retrieve a video with the job's id until it is completed.
The request must be signed. See Authentication.
Body parameters
- prompt string Required
- A description of the video you want. Up to 32,000 characters.
- model string Conditional
-
ID of the model to run, as listed in the model catalogue. Required unless the API key is locked to a model. If it is, the key's model is always used and sending
modelis rejected with a422. - width integer
- Width in pixels. At least 1; the sizes a model accepts vary.
- height integer
- Height in pixels. At least 1; the sizes a model accepts vary.
- seconds string
-
Length of the video in seconds, as a string such as
"5". The lengths a model accepts vary. - fps integer
- Frames per second. At least 1.
- steps integer
- Number of inference steps, from 10 to 50.
- seed integer
- Fixes the randomness, so the same seed and inputs reproduce the same result.
- guidance_scale number
- How closely the video follows the prompt. Zero or more; higher sticks closer.
- negative_prompt string
- What you don't want to see in the video.
- output_format string
-
MP4orWEBM. - output_quality integer
- Encoding quality. At least 1.
- frame_images array of objects
-
Images pinned to points on the timeline, like keyframes. See Frame images.
Child attributes
- input_image string Required
- URL of the image.
- frame integer or string Required
-
Where it sits: a frame number such as
0or48, or"first"or"last".
- reference_images array of strings
- URLs of images that set the look of the whole video. See Reference images.
Returns
A video job object with status set to in_progress.
$response = $client->video([
'prompt' => 'Happy happy happy cat',
'model' => 'vidu/vidu-q3',
]);
$id = $response['id'] ?? null;
response = client.video({
"prompt": "Happy happy happy cat",
"model": "vidu/vidu-q3",
})
video_id = response["id"]
let response = try await client.video(payload: [
"prompt": "Happy happy happy cat",
"model": "vidu/vidu-q3",
])
final job = await client.video.create(const VideoRequest( prompt: 'Happy happy happy cat', model: 'vidu/vidu-q3', )); final id = job.id;
{
"prompt": "Happy happy happy cat",
"model": "vidu/vidu-q3"
}
{
"object": "video",
"id": "01a19801-4eae-7358-a183-e3b6a388a95c",
"model": "vidu/vidu-q3",
"status": "in_progress",
"created_at": 1793551584,
"seconds": "5",
"size": "1280x720"
}
Frame images
frame_images pins an image to a position in the video, so the model animates toward or away from it. Each entry needs an input_image and a frame.
Frame 0 is the start of the video. Use a number for an exact position, or "first" and "last" for the two ends. Give a first and a last frame and the model fills in the motion between them.
$response = $client->video([
'prompt' => 'Happy happy happy cat',
'model' => 'vidu/vidu-q3',
'frame_images' => [
[
'input_image' => 'https://example.com/frame-1.jpg',
'frame' => 0,
],
],
]);
response = client.video({
"prompt": "Happy happy happy cat",
"model": "vidu/vidu-q3",
"frame_images": [
{"input_image": "https://example.com/frame-1.jpg", "frame": 0},
],
})
let frame: [String: Any] = ["input_image": "https://example.com/frame-1.jpg", "frame": 0] let response = try await client.video(payload: [ "prompt": "Happy happy happy cat", "model": "vidu/vidu-q3", "frame_images": [frame], ])
{
"prompt": "Happy happy happy cat",
"model": "vidu/vidu-q3",
"frame_images": [
{"input_image": "https://example.com/frame-1.jpg", "frame": 0}
]
}
Reference images
reference_images guides how the video looks from start to finish: a character, a product, a visual style. Unlike frame images, a reference isn't tied to any moment on the timeline.
Pass one or more image URLs. You can combine them with frame_images on models that support both.
Not every model takes reference images. One that doesn't fails the job straight away, with the reason in error.message. The example uses vidu/vidu-q1, which does.
{
"prompt": "Happy happy happy cat",
"model": "vidu/vidu-q1",
"reference_images": [
"https://example.com/ref-1.jpg",
"https://example.com/ref-2.jpg"
]
}
Retrieve a video
GET
/video/{id}
Returns the current state of a video job. Call it every few seconds until status is completed; the response then carries outputs.video_url, a result link to the file. A short clip typically takes a couple of minutes.
A job can also end as failed, with the reason in error.message. A model that can't do what the request asks, such as one without reference image support, fails the job straight away, so the create call can already answer with status: "failed".
The request must be signed. See Authentication. There is no body, so the payload signature is computed over an empty string.
Path parameters
- id string Required
-
The
idreturned when the job was created.
Returns
The video job object. Once completed it includes completed_at and outputs.video_url.
$video = $client->getVideo('01a19801-4eae-7358-a183-e3b6a388a95c'); if (($video['status'] ?? null) === 'completed') { $url = $video['outputs']['video_url']; }
video = client.get_video("01a19801-4eae-7358-a183-e3b6a388a95c") if video.get("status") == "completed": url = video["outputs"]["video_url"]
let video = try await client.getVideo(id: "01a19801-4eae-7358-a183-e3b6a388a95c") if video["status"] as? String == "completed" { let outputs = video["outputs"] as? [String: Any] let url = outputs?["video_url"] as? String }
final job = await client.video.get('01a19801-4eae-7358-a183-e3b6a388a95c'); if (job.isCompleted) { print(job.videoUrl); } // Or let the SDK poll until the job finishes final finished = await client.video.waitFor(job.id);
GET https://api.bitmesh.ai/video/01a19801-4eae-7358-a183-e3b6a388a95c
{
"object": "video",
"id": "01a19801-4eae-7358-a183-e3b6a388a95c",
"model": "vidu/vidu-q3",
"status": "completed",
"created_at": 1793551584,
"completed_at": 1793551697,
"seconds": "5",
"size": "1280x720",
"outputs": {
"video_url": "https://api.bitmesh.ai/imgrslt/LMwdUAk0OYxYZRcR"
}
}
View a video
GET
/imgrslt/{object_id}
The outputs.video_url returned by Retrieve a video once a job has completed is a direct link to the video file. Use it exactly as returned.
Result links need no signature, so they work directly in a browser, an <img> or a <video> tag. Treat the link itself as the secret.
Returns
The video file.
GET https://api.bitmesh.ai/imgrslt/LMwdUAk0OYxYZRcR
<video src="https://api.bitmesh.ai/imgrslt/LMwdUAk0OYxYZRcR" controls></video>
Create a transcription
POST
/transcribe-recorded
Submits a recording for transcription and returns a job. Transcription runs in the background: poll Retrieve a transcription with the job's id, or pass a webhook_url and we'll call you when it is done.
Send the audio in one of two ways:
- Upload a file as
multipart/form-data, with the recording in a part namedaudioand any options as ordinary form fields. Files up to 500 MB in MP3, WAV, M4A, MP4, FLAC, OGG or WebM. - Point at a URL with a JSON body containing
audio_url.
Signing an upload
The request must be signed. See Authentication. The OAuth signature is built the same way as for any other request. The payload signature differs, because a multipart body can't be hashed byte for byte: leave the file out, sort the remaining form fields by name, JSON-encode them without escaping slashes or Unicode, and use that string as the body. With no fields besides the file, the body is [].
A JSON request with audio_url is signed like every other JSON request.
Body parameters
- audio file Conditional
- The recording, as a multipart file part. Required for uploads.
- audio_url string Conditional
- URL of the recording. Required when you send JSON instead of a file.
- speech_models array of strings
-
Speech models to use, in order of preference:
universal-3-pro,universal-2. - language_code string
-
Language of the audio, such as
enores. - language_detection boolean
- Detect the language instead of naming it.
- punctuate boolean
- Add punctuation to the transcript.
- format_text boolean
- Format the text: casing, numbers, dates.
- dual_channel boolean
- Transcribe each channel of a two-channel recording separately.
- webhook_url string
-
A URL we send a
POSTto when the transcript is ready.
Returns
A transcription job. Keep its id; the text arrives once status is completed.
// Allow time for the upload: third argument is the timeout in seconds $client = new BitmeshClient('YOUR_CONSUMER_KEY', 'YOUR_CONSUMER_SECRET', 600); $response = $client->transcribeFile('/path/to/recording.mp3', [ 'speech_models' => ['universal-2'], ]); $transcriptId = $response['id'] ?? null;
# Allow time for the upload client = BitmeshClient(key, secret, timeout_seconds=600) response = client.transcribe_file("/path/to/recording.mp3", { "speech_models": ["universal-2"], }) transcript_id = response["id"]
let response = try await client.transcribeFile(
audioFileURL: URL(fileURLWithPath: "/path/to/recording.mp3"),
fields: ["speech_models": ["universal-2"]]
)
final job = await client.transcription.createFromPath( '/path/to/recording.mp3', speechModels: ['universal-2'], ); final transcriptId = job.id;
{
"audio_url": "https://example.com/recording.mp3",
"speech_models": ["universal-2"]
}
// Every form field except the file $fields = ['speech_models' => ['universal-2']]; ksort($fields); $body = json_encode($fields, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $payloadSignature = hash('sha256', $body.$consumerKey.$oauthSignature);
{
"id": "019c905a-aaaa-bbbb-cccc-111122223333",
"status": "queued",
"text": null
}
Retrieve a transcription
GET
/transcribe-recorded/{id}
Returns a transcription job. status moves from queued to processing and ends at completed or error. When it is completed, text holds the transcript; on error, the error field says why.
Transcription is billed by the length of the audio, once the job has completed.
The request must be signed. See Authentication. There is no body, so the payload signature is computed over an empty string. For a job created in test mode, add ?test=1 and include test in the signed parameters.
Path parameters
- id string Required
-
The
idreturned when the job was created.
Returns
The transcription job. Fields beyond those shown, such as word timings, depend on the options you asked for.
$job = $client->getTranscribeRecorded('019c905a-aaaa-bbbb-cccc-111122223333'); if (($job['status'] ?? null) === 'completed') { echo $job['text']; }
job = client.get_transcribe_recorded("019c905a-aaaa-bbbb-cccc-111122223333") if job.get("status") == "completed": print(job["text"])
let job = try await client.getTranscribeRecorded(id: "019c905a-aaaa-bbbb-cccc-111122223333") if job["status"] as? String == "completed" { let text = job["text"] as? String }
final job = await client.transcription.get('019c905a-aaaa-bbbb-cccc-111122223333'); if (job.isCompleted) { print(job.text); } // Or let the SDK poll until the job finishes final finished = await client.transcription.waitFor(job.id);
GET https://api.bitmesh.ai/transcribe-recorded/019c905a-aaaa-bbbb-cccc-111122223333
{
"id": "019c905a-aaaa-bbbb-cccc-111122223333",
"status": "completed",
"text": "Thanks for calling. How can I help you today?",
"language_code": "en",
"audio_duration": 12
}
How tools work
Tools do one job each on a picture you send, such as cutting out the subject, dressing a person in new clothes or bringing a still image to life. The path chooses the tool, and a few fields tune it.
| Tool | Endpoint | Answers with |
|---|---|---|
| Remove a background | POST /tools/general/background-removal |
The result link, straight away |
| Try on clothes | POST /tools/portrait/try-on-clothes |
A task_id to poll |
| Animate an image | POST /tools/video/image-animate |
A task_id to poll |
Background removal and try-on take a multipart upload. Animate an image takes JSON, with the image base64-encoded inside it.
Slow tools start a task and answer with its task_id. Poll it until it finishes: try-on with Retrieve a tool task, animations with Retrieve an animation. Then download the result.
Signing a tool upload
The request must be signed. See Authentication. The OAuth signature is built the same way as for any other request, and the form fields are not part of it. The payload signature differs, because a multipart body can't be hashed byte for byte:
- Take every form field that is not a file.
- Sort them by name.
- JSON-encode them without escaping slashes or Unicode, for example
{"resolution":"1024","restore_face":"1"}. - Use that string as the body:
X-Payload-Signatureis the SHA-256 hash, in hex, of the JSON, your consumer key and theoauth_signature.
Two details cause most Invalid payload signature errors when you sign by hand:
- Every value is a string. Multipart sends text, so the server hashes
"1", never1ortrue. Write booleans as"1"or"0"and numbers as strings such as"1024", and send exactly the values you hashed."true"is not a boolean here: background removal rejects it with a422. - No text fields means
[]. With no text fields the server hashes[], while most JSON encoders turn an empty map into{}.
The current SDKs handle both. The samples here pass strings anyway, so they also work with older SDK versions.
Animate an image is not an upload, so it is signed like any other JSON request.
Result links
Finished images and videos come back as plain links to the tool provider's storage. They need no signature, so they work in a browser or an <img> or <video> tag, but they expire: images after about 24 hours, videos after about 8. Download the file and keep your own copy.
Billing
Background removal and try-on cost a flat price per request, charged when the request is accepted: for try-on that is when you get the task_id, not when the picture is ready. Animations are charged for the credits the job used, once it succeeds. Failed requests, polling and test mode are free.
1. POST /tools/portrait/try-on-clothes
→ task_id
2. POST /tools/query-async-task-result
{"task_id": "…"}
→ status "queued" or "processing"
wait 10 seconds and ask again
3. → status "success"
→ output.image_url
4. GET output.image_url
→ the finished image (download it within 24 hours)
// Every form field except the files, as strings $fields = ['restore_face' => '1', 'resolution' => '1024']; ksort($fields); $body = $fields === [] ? '[]' : json_encode($fields, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); // {"resolution":"1024","restore_face":"1"} $payloadSignature = hash('sha256', $body.$consumerKey.$oauthSignature);
import hashlib, json # Every form field except the files, as strings fields = {"restore_face": "1", "resolution": "1024"} body = ( json.dumps(dict(sorted(fields.items())), separators=(",", ":"), ensure_ascii=False) if fields else "[]" ) # {"resolution":"1024","restore_face":"1"} payload_signature = hashlib.sha256( (body + consumer_key + oauth_signature).encode() ).hexdigest()
Remove a background
POST
/tools/general/background-removal
Removes the background from a photo of a person, product or anything else. The request waits for the result, so the response already holds the link to the finished image in data.image_url.
By default you get a PNG with a transparent background, the same size as the photo. return_form asks for a mask, a white background or a tight crop instead.
Send it as multipart/form-data: images in file parts, every other option as a plain text field. The payload signature is computed over the text fields, not the upload; see How tools work.
The link points at the tool provider's storage. It needs no signature and is deleted after about 24 hours, so download the image rather than keeping the link.
Form fields
- image file Required
- The photo, as a file part. JPEG, PNG, BMP or WebP, up to 3 MB, larger than 32×32 and smaller than 2000×2000 pixels.
- return_form string
-
maskfor a black-and-white mask of the subject,whiteBKfor the subject on a white background, orcropfor a transparent PNG trimmed to the subject. Leave it out for a transparent PNG at the original size.
Returns
An object with status set to success and the link to the finished image in data.image_url.
$result = $client->toolsGeneralBackgroundRemoval(
['return_form' => 'crop'],
'/path/to/photo.jpg'
);
$url = $result['data']['image_url'] ?? null;
// The link expires: keep your own copy
file_put_contents('/path/to/cutout.png', file_get_contents($url));
import urllib.request
result = client.tools_general_background_removal(
{"return_form": "crop"},
"/path/to/photo.jpg",
)
url = result["data"]["image_url"]
# The link expires: keep your own copy
urllib.request.urlretrieve(url, "/path/to/cutout.png")
let result = try await client.toolsGeneralBackgroundRemoval(
fields: ["return_form": "crop"],
imageFileURL: URL(fileURLWithPath: "/path/to/photo.jpg")
)
let data = result["data"] as? [String: Any]
if let link = data?["image_url"] as? String, let url = URL(string: link) {
// The link expires: keep your own copy
let (png, _) = try await URLSession.shared.data(from: url)
}
final result = await client.tools.backgroundRemoval( BitmeshFile.fromPath('/path/to/photo.jpg'), fields: {'return_form': 'crop'}, ); // The link expires: keep your own copy final png = await client.tools.fetchResultBytes(result.outputUrl!);
POST https://api.bitmesh.ai/tools/general/background-removal Content-Type: multipart/form-data image: (file) photo.jpg return_form: crop
{
"data": {
"image_url": "https://ailab-outputs.oss-accelerate.aliyuncs.com/cutout/commonImage/2026-11-02-01/0fc01abc-48de-8316-229c-d1e74619dfc7_1793552716.png"
},
"error_code": 0,
"request_id": "01A19812-91F5-54D1-B1B9-004FBCB51A65",
"detail": {
"status_code": 200,
"code": "",
"code_message": "",
"message": ""
},
"status": "success"
}
Try on clothes
POST
/tools/portrait/try-on-clothes
Starts a virtual try-on: upload a photo of a person and pictures of the clothes, and get back a picture of that person wearing them. The work takes a while, typically under a minute, so this call answers straight away with a task_id. Poll Retrieve a tool task with it until status is success.
Send it as multipart/form-data: images in file parts, every other option as a plain text field. The payload signature is computed over the text fields, not the upload; see How tools work.
Try-on is charged when the task is accepted, that is when you get a task_id back, not when the picture is ready.
Form fields
- person_image file Required
- Photo of the person, as a file part. Up to 5 MB.
- top_garment file Required
- Picture of the upper-body garment, such as a shirt, top or jacket. Up to 5 MB.
- bottom_garment file
- Picture of a lower-body garment, such as trousers or a skirt. Up to 5 MB.
- resolution string
-
Size of the result:
"-1"keeps the resolution of the person photo, or"1024"or"1280". - restore_face boolean default true
-
"1"keeps the person's own face;"0"lets the tool redraw it.
Returns
An object with the task_id to poll and status set to success, meaning the task was accepted. The picture comes from Retrieve a tool task.
// Allow time for the upload: third argument is the timeout in seconds $client = new BitmeshClient('YOUR_CONSUMER_KEY', 'YOUR_CONSUMER_SECRET', 120); $job = $client->toolsPortraitTryOnClothes( ['restore_face' => '1', 'resolution' => '1024'], [ 'person_image' => '/path/to/person.jpg', 'top_garment' => '/path/to/shirt.jpg', ] ); $taskId = $job['task_id'] ?? null;
# Allow time for the upload client = BitmeshClient(key, secret, timeout_seconds=120) job = client.tools_portrait_try_on_clothes( {"restore_face": "1", "resolution": "1024"}, { "person_image": "/path/to/person.jpg", "top_garment": "/path/to/shirt.jpg", }, ) task_id = job["task_id"]
let job = try await client.toolsPortraitTryOnClothes(
fields: ["restore_face": "1", "resolution": "1024"],
files: [
"person_image": URL(fileURLWithPath: "/path/to/person.jpg"),
"top_garment": URL(fileURLWithPath: "/path/to/shirt.jpg"),
]
)
let taskId = job["task_id"] as? String
final job = await client.tools.tryOnClothes(
{
'person_image': BitmeshFile.fromPath('/path/to/person.jpg'),
'top_garment': BitmeshFile.fromPath('/path/to/shirt.jpg'),
},
fields: {'restore_face': '1', 'resolution': '1024'},
);
final taskId = job.taskId;
POST https://api.bitmesh.ai/tools/portrait/try-on-clothes Content-Type: multipart/form-data person_image: (file) person.jpg top_garment: (file) shirt.jpg restore_face: 1 resolution: 1024
{
"error_code": 0,
"request_id": "85200403",
"task_id": "1793549228989.e65e0fb6-7ff6-dffd-5272-d1b8bf9510a2",
"task_type": "async",
"detail": {
"status_code": 200,
"code": "",
"code_message": "",
"message": ""
},
"status": "success"
}
Retrieve a tool task
POST
/tools/query-async-task-result
Returns the state of a task started by Try on clothes. Ask every 10 seconds until status is success; output.image_url then holds the link to the finished picture.
status |
Meaning | What to do |
|---|---|---|
queued |
Waiting to start. | Ask again. |
processing |
Running. | Ask again. |
success |
Done. output.image_url is filled in. |
Download the picture. |
fail |
The task failed. detail says why. |
Stop. |
When the task can't be looked up at all, for example because the task_id is wrong, the answer is an error such as a 400 with the reason in error.
The link points at the tool provider's storage. It needs no signature and is deleted after about 24 hours, so download the image rather than keeping the link.
Unlike the other status endpoints, this one is a POST with a JSON body. The request must be signed. See Authentication. It is signed like any other JSON request, not like a tool upload.
Polling is free, but each call counts toward the per-IP limit of 10 media requests a minute, which tool uploads share. Asking more often than every 10 seconds gets you 429s, not a faster result.
Body parameters
- task_id string Required
-
The
task_idreturned when the task was started.
Returns
The task: status as in the table above, task_status with the same state as a number (0 queued, 1 processing, 2 done), and on success output.image_url.
do {
sleep(10);
$task = $client->toolsQueryAsyncTaskResult($taskId);
} while (in_array($task['status'] ?? null, ['queued', 'processing'], true));
if ($task['status'] === 'success') {
$url = $task['output']['image_url'];
file_put_contents('/path/to/result.png', file_get_contents($url));
}
import time, urllib.request
while True:
time.sleep(10)
task = client.tools_query_async_task_result(task_id)
if task.get("status") not in ("queued", "processing"):
break
if task["status"] == "success":
url = task["output"]["image_url"]
urllib.request.urlretrieve(url, "/path/to/result.png")
var task: [String: Any]
repeat {
try await Task.sleep(nanoseconds: 10_000_000_000)
task = try await client.toolsQueryAsyncTaskResult(taskId: taskId)
} while ["queued", "processing"].contains(task["status"] as? String)
if task["status"] as? String == "success" {
let output = task["output"] as? [String: Any]
let url = output?["image_url"] as? String
}
// Let the SDK poll until the task finishes
final finished = await client.tools.waitFor(
taskId,
options: const PollingOptions(interval: Duration(seconds: 10)),
);
final picture = await client.tools.fetchResultBytes(finished.outputUrl!);
{
"task_id": "1793549228989.e65e0fb6-7ff6-dffd-5272-d1b8bf9510a2"
}
{
"error_code": 0,
"output": {
"image_url": "https://ailab-outputs.oss-accelerate.aliyuncs.com/aiScheduler/image2/edit/2026-11-02-00/956c9756-189b-a6ec-9de3-59c9d162183a_1793549243.png"
},
"task_status": 2,
"detail": {
"status_code": 200,
"code": "",
"code_message": "",
"message": ""
},
"status": "success"
}
{
"error_code": 0,
"task_status": 1,
"detail": {
"status_code": 200,
"code": "",
"code_message": "",
"message": ""
},
"status": "processing"
}
{
"error": "task_id is invalid."
}
Animate an image
POST
/tools/video/image-animate
Starts an image-to-video job: send a picture and, if you like, a prompt describing the motion, and get back a video up to 10 seconds long. The job takes a while, typically under a minute for a short clip, so this call answers straight away with data.task_id. Poll Retrieve an animation with it until data.status is SUCCESS, or pass a callback_url to be told.
Unlike the other tools this one takes a JSON body, with the image base64-encoded inside it. The request must be signed. See Authentication. It is signed like any other JSON request.
Nothing is charged when the job starts. Once it succeeds, the account is charged for the credits the job used, shown in data.credits on the finished task. Failed and cancelled jobs are free.
Callbacks
With a callback_url, we forward each status notification about the job to that address as a JSON POST, with the same fields as data in Retrieve an animation. The same status can arrive more than once. Notifications are not signed, so treat one as a hint and confirm it with Retrieve an animation before you rely on it. Polling still works alongside a callback.
Body parameters
- model string Required
-
animate-2.4-faster, oranimate-2.4-advancedfor higher quality. Other DomoAI image-to-video models are passed through as given. - image object Required
-
The picture to animate. JPEG, PNG, WebP or AVIF, up to 10 MB.
Child attributes
- bytes_base64_encoded string Required
-
The image file, base64-encoded, without a
data:prefix.
- seconds integer Required
- Length of the video, from 1 to 10. A number, not a string.
- prompt string
-
The motion you want, such as
the cat slowly blinks. Up to 2,000 characters. - aspect_ratio string
-
16:9,9:16,1:1,4:3or3:4. Leave it out to use the ratio closest to the image. - callback_url string
- A URL that receives each status notification for the job. See Callbacks above.
Returns
An object whose data.task_id identifies the job. Keep it to poll the job.
$job = $client->imageAnimate([
'model' => 'animate-2.4-faster',
'image' => [
'bytes_base64_encoded' => base64_encode(file_get_contents('/path/to/cat.png')),
],
'seconds' => 5,
'prompt' => 'The cat slowly blinks',
'aspect_ratio' => '9:16',
]);
$taskId = $job['data']['task_id'];
let image = try Data(contentsOf: URL(fileURLWithPath: "/path/to/cat.png")) let job = try await client.imageAnimate(payload: [ "model": "animate-2.4-faster", "image": ["bytes_base64_encoded": image.base64EncodedString()], "seconds": 5, "prompt": "The cat slowly blinks", "aspect_ratio": "9:16", ]) let taskId = (job["data"] as? [String: Any])?["task_id"] as? String
// The SDK base64-encodes the image for you final job = await client.tools.imageAnimate( model: 'animate-2.4-faster', image: BitmeshFile.fromPath('/path/to/cat.png'), seconds: 5, prompt: 'The cat slowly blinks', aspectRatio: '9:16', ); final taskId = job.taskId;
{
"model": "animate-2.4-faster",
"image": {
"bytes_base64_encoded": "iVBORw0KGgoAAAANSUhEUgAA…"
},
"seconds": 5,
"prompt": "The cat slowly blinks",
"aspect_ratio": "9:16"
}
{
"code": 0,
"data": {
"task_id": "06ae763e-0ce0-7475-8000-258b9a39351c"
}
}
Retrieve an animation
GET
/tools/video/image-animate/{task_id}
Returns the state of a job started by Animate an image. Ask every 5 seconds until data.status is SUCCESS, FAILED or CANCELED. On SUCCESS, data.output_videos[0].url is the link to the video.
data.status |
Meaning | What to do |
|---|---|---|
PENDING, QUEUING |
Waiting to start. | Ask again. |
PROCESSING |
Running. | Ask again. |
SUCCESS |
Done. output_videos is filled in. |
Download the video. |
FAILED, CANCELED |
The job did not finish. Nothing is charged. | Stop. |
The video link is signed by the provider and stops working after about 8 hours, so download the file rather than keeping the link. data.callback_url is the address we registered to receive the provider's notifications, not yours.
The request must be signed. See Authentication. There is no body, so the payload signature is computed over an empty string. For a job created in test mode, add ?test=1 and include test in the signed parameters; the answer is a finished job with a placeholder link.
Polling is free and only counts toward the general rate limits.
Path parameters
- task_id string Required
-
The
data.task_idreturned when the job was started.
Returns
The job under data: its status, the seconds asked for, the credits used, and on success output_videos, each with a url, width and height.
do {
sleep(5);
$job = $client->getImageAnimate($taskId);
$status = $job['data']['status'] ?? null;
} while (! in_array($status, ['SUCCESS', 'FAILED', 'CANCELED'], true));
if ($status === 'SUCCESS') {
$url = $job['data']['output_videos'][0]['url'];
file_put_contents('/path/to/cat.mp4', file_get_contents($url));
}
var data: [String: Any] = [:]
repeat {
try await Task.sleep(nanoseconds: 5_000_000_000)
let job = try await client.getImageAnimate(taskId: taskId)
data = job["data"] as? [String: Any] ?? [:]
} while !["SUCCESS", "FAILED", "CANCELED"].contains(data["status"] as? String)
if data["status"] as? String == "SUCCESS" {
let videos = data["output_videos"] as? [[String: Any]]
let url = videos?.first?["url"] as? String
}
// Let the SDK poll until the video is ready
final finished = await client.tools.waitForImageAnimate(taskId);
final video = await client.tools.fetchResultBytes(finished.outputUrl!);
GET https://api.bitmesh.ai/tools/video/image-animate/06ae763e-0ce0-7475-8000-258b9a39351c
{
"code": 0,
"data": {
"task_id": "06ae763e-0ce0-7475-8000-258b9a39351c",
"id": "06ae763e-0ce0-7475-8000-258b9a39351c",
"status": "SUCCESS",
"category": "IMAGE_TO_VIDEO",
"callback_url": "https://api.bitmesh.ai/tools/video/image-animate-callback/17935492801122",
"seconds": 3,
"output_videos": [
{
"url": "https://assets-cdn.domoai.com/output/video/d0a0c368-264b-4645-8b4e-1801174b994b.mp4?q-sign-algorithm=sha1&…",
"width": 720,
"height": 1280
}
],
"credits": 6,
"inputs": {
"model": "animate-2.4-faster",
"prompt": "The cat slowly blinks",
"seconds": 3,
"image_url": "https://assets-cdn.domoai.com/input/image/e05f11d7-3b59-48d2-86c2-d24393bcb215.png?…",
"aspect_ratio": "9:16"
},
"created_at": "2026-11-01T16:08:00.800782"
}
}
Retrieve your balance
GET
/balance
Returns the credits remaining on the account the API key belongs to. Useful for warning yourself before the balance reaches zero, at which point requests are refused with a 402.
The request must be signed. See Authentication. There is no body, so the payload signature is computed over an empty string. Because the balance check runs before this endpoint does, an account that is already at zero gets the 402 here too.
Returns
An object with balance, the number of credits remaining.
GET https://api.bitmesh.ai/balance
{
"balance": 42.5
}
Docs