Skip to content
Bitmesh home Docs

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.

Quickstart

Three steps take you from nothing to a model's reply.

  1. Create an API key. Open API keys in the dashboard and create one. Copy the consumer key and the consumer secret.
  2. 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.
  3. Send a message. Call /chat with 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.

Install
composer config repositories.bitmesh vcs https://github.com/bitmeshai/bitmesh-php-sdk
composer require bitmeshai/bitmesh-php-sdk:^2.1
Send a message
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'];

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 Authorization header holding the OAuth parameters and their signature, which proves who is calling;
  • an X-Payload-Signature header, which ties the body to that signature so it can't be swapped in transit.

Build the OAuth signature

  1. Normalise the URL to scheme://host/path. Leave the port out unless it is non-standard, and leave the query string out.
  2. Collect the parameters: the five oauth_* values below plus any query string parameters. The JSON body is not part of this signature.
  3. Sort and encode them. Sort by name, URL-encode each name and value, join each pair with = and the pairs with &.
  4. Build the base string: the HTTP method, the normalised URL and the parameter string, each URL-encoded, joined with &.
  5. 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 is oauth_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
OAuth followed by the parameters above as comma-separated name="value" pairs, values URL-encoded.
X-Payload-Signature header Required
Hex SHA-256 of body + consumer key + oauth_signature.
Content-Type header
application/json for JSON bodies, multipart/form-data for uploads.
Accept header
application/json.
Signed request headers
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
Signing a request by hand
$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?"

  1. 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.
  2. 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.
  3. Your app sends the token with each request in the X-Firebase-AppCheck header.
  4. 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 Authorization and X-Payload-Signature exactly as before.
  • The X-Firebase-AppCheck header 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 production for 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.

  1. Run a debug build with its debug token registered (see below). Requests should succeed.
  2. Send one request without the header. It should fail with 401 and Missing X-Firebase-AppCheck header. This proves the key really is a mobile key.
  3. Install a release build on a real device, from Play internal testing or TestFlight. Requests should succeed.
  4. 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:

  1. Create a new mobile key.
  2. Ship an app version that uses the new key and sends App Check tokens.
  3. 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.
  4. 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, production environment.
  • 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-AppCheck on 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.
Turn on App Check at startup
// 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());
}
Send the token on every request
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(),
  ),
);
Token missing 401
{
  "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.

Install
composer config repositories.bitmesh vcs https://github.com/bitmeshai/bitmesh-php-sdk
composer require bitmeshai/bitmesh-php-sdk:^2.1
Create a client
use BitmeshAI\BitmeshClient;

// Optional third argument: timeout in seconds (default 30)
$client = new BitmeshClient('YOUR_CONSUMER_KEY', 'YOUR_CONSUMER_SECRET');

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 string
What went wrong, in a short phrase such as Invalid signature.
message string
A longer explanation, when there is one.
errors object
On a 422: field names mapped to a list of the rules they broke.
Not authenticated 401
{
  "error": "Invalid signature"
}
Validation failed 422
{
  "error": "Validation failed",
  "errors": {
    "messages": ["The messages field is required."]
  }
}
Out of credits 402
{
  "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.

Rate limited 429
{
  "error": "Too many chat requests",
  "message": "You have exceeded the chat rate limit. Please wait before sending more requests.",
  "retry_after": 42
}
Key budget used up 429
{
  "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.

A test request
{
  "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, assistant or tool.
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 model is rejected with a 422.
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.
test boolean default false
Return canned data without calling the model or spending credits. See Test mode.

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.

Request
$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 200
{
  "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.

Request body
{
  "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

type string Required
text or image_url.
text string
The text, when type is text.
image_url object
When type is image_url: an object with a url pointing at the image.
Request
$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,
]);

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 model is rejected with a 422.
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.

Request
$response = $client->image([
    'prompt' => 'Flying car',
    'model' => 'google/gemini-3-pro-image',
    'width' => 1024,
    'height' => 1024,
]);

$url = $response['data'][0]['url'] ?? null;
Response 200
{
  "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.

Request
$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;

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.

FLUX.2 [pro]: the mountain prompt
FLUX.2 [pro]: the mountain prompt
FLUX.2 [dev]: 20 steps
FLUX.2 [dev]: 20 steps
FLUX.2 [pro]: the mug recoloured
FLUX.2 [pro]: the mug recoloured
Text to image with FLUX.2 [pro]
{
  "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
}
Tuning steps with FLUX.2 [dev]
{
  "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
}
Editing an image with FLUX.2 [pro]
{
  "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.

Request
GET https://api.bitmesh.ai/imgrslt/Il4vHBVFuu8tiaXP
In a page
<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 model is rejected with a 422.
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
MP4 or WEBM.
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 0 or 48, 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.

Request
$response = $client->video([
    'prompt' => 'Happy happy happy cat',
    'model' => 'vidu/vidu-q3',
]);

$id = $response['id'] ?? null;
Response 200
{
  "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.

Request
$response = $client->video([
    '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.

Request body
{
  "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 id returned when the job was created.

Returns

The video job object. Once completed it includes completed_at and outputs.video_url.

Request
$video = $client->getVideo('01a19801-4eae-7358-a183-e3b6a388a95c');

if (($video['status'] ?? null) === 'completed') {
    $url = $video['outputs']['video_url'];
}
Response 200
{
  "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.

Request
GET https://api.bitmesh.ai/imgrslt/LMwdUAk0OYxYZRcR
In a page
<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 named audio and 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 en or es.
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 POST to when the transcript is ready.
test boolean default false
Return canned data without calling the model or spending credits. See Test mode.

Returns

A transcription job. Keep its id; the text arrives once status is completed.

Request
// 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;
Payload signature for an upload
// 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);
Response 200
{
  "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 id returned when the job was created.

Returns

The transcription job. Fields beyond those shown, such as word timings, depend on the options you asked for.

Request
$job = $client->getTranscribeRecorded('019c905a-aaaa-bbbb-cccc-111122223333');

if (($job['status'] ?? null) === 'completed') {
    echo $job['text'];
}
Response 200
{
  "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:

  1. Take every form field that is not a file.
  2. Sort them by name.
  3. JSON-encode them without escaping slashes or Unicode, for example {"resolution":"1024","restore_face":"1"}.
  4. Use that string as the body: X-Payload-Signature is the SHA-256 hash, in hex, of the JSON, your consumer key and the oauth_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", never 1 or true. 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 a 422.
  • 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.

Flow for a task
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)
Payload signature for a tool upload
// 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);

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
mask for a black-and-white mask of the subject, whiteBK for the subject on a white background, or crop for a transparent PNG trimmed to the subject. Leave it out for a transparent PNG at the original size.
test boolean default false
Send "1" to get a placeholder response without running the tool or spending credits. The placeholder link does not lead to an image. See Test mode.

Returns

An object with status set to success and the link to the finished image in data.image_url.

Request
$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));
Response 200
{
  "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.
test boolean default false
Send "1" to get a made-up task_id back without running the tool or spending credits. It is not a real task, so polling it returns an error. See Test mode.

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.

Request
// 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;
Response 200
{
  "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_id returned 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.

Request
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));
}
Response 200
{
  "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"
}
Still running 200
{
  "error_code": 0,
  "task_status": 1,
  "detail": {
    "status_code": 200,
    "code": "",
    "code_message": "",
    "message": ""
  },
  "status": "processing"
}
Unknown task 400
{
  "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, or animate-2.4-advanced for 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:3 or 3: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.
test boolean default false
Return a made-up task_id without running the tool or spending credits. Poll it with ?test=1. See Test mode.

Returns

An object whose data.task_id identifies the job. Keep it to poll the job.

Request
$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'];
Response 200
{
  "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_id returned 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.

Request
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));
}
Response 200
{
  "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.

Request
GET https://api.bitmesh.ai/balance
Response 200
{
  "balance": 42.5
}
esc