Pro API: balance, caps and trained characters
Two read routes, always allowed whatever the key's permissions, plus the activity log kept in the app.
Balance and caps
GET /credits
{ "balance": 1830, "licenseId": "lic_...", "key": "k_1a2b3c4d",
"limits": { "concurrent": 2, "perMinute": 60, "perDay": 0 }, "running": 1,
"buy_url": "https://tendre.ai/credits?license=lic_..." }| Field | Meaning |
|---|---|
balance | Credits left. null when the balance is unknown (test environment). |
licenseId | The licence this key belongs to. |
key | The key's public identifier, safe to log. Never the secret. |
limits.concurrent | Renders running at once, 2 by default. |
limits.perMinute | Submissions per minute, 60 by default. |
limits.perDay | Daily credit cap you set on the key. 0 means no cap. |
running | Renders from this key still waiting to be collected. |
buy_url | The credit purchase page of your licence, by card or crypto. |
This is the cheapest call in the API and the right one to check a key works.
Buy credits on your saved card
POST /credits/purchase
{ "pack": "m" }pack is a pack id (s, m, l, xl, xxl or xxxl), or "c25000" for a custom amount beyond the largest pack. The payment goes on the card saved in the automatic top-up of your account page, only if you allowed your agent to buy there, within the monthly limit you set there (automatic top-ups included). An email confirms each purchase. One purchase at a time.
The answer is 200 { credits, eur, balance }. Every refusal carries buy_url, the ordinary purchase link, to use instead: 402 confirmation_required (your bank asks for your confirmation) or payment_declined; 409 agent_not_allowed, no_saved_card, monthly_cap_reached, topup_in_progress or purchase_in_progress; 400 unknown_pack. On 503 payment_unavailable we do not know whether the charge went through: do not retry right away, a charge that went through is credited.
Trained characters
These are the characters trained as a LoRA, for images and ordinary videos. The characters of Multi-Ref Video, seven views made from one photo, are another thing: see Characters.
GET /loras
{ "image": [ { "name": "megan_krea2", "trigger": "megan", "base": "krea2", "size": 0 } ],
"video": [ { "name": "megan_ltx", "trigger": "megan", "size": 0 } ] }Pass name as lora, taking it from the image list for images and the video list for
videos, and put its trigger in the prompt, otherwise the character will not appear. A
character that does not belong to the account answers 403 unknown_lora.
Upload your own LoRA
POST /loras/upload
A .safetensors file you already have, trained elsewhere or sitting on your own server, goes
into the account's space and becomes usable through lora, exactly like a character trained in
the app. There are two ways in: one shot up to 90 MB, or in parts beyond that. A LoRA
usually weighs 200 to 500 MB, so the second one is the ordinary case. The ceiling is 800 MB
either way.
One shot: a raw body (application/octet-stream, Content-Length required), parameters in the
URL.
Past 100 MB the request never reaches the API. The infrastructure in front turns it away, so
what comes back is a bare 413 with no JSON body: a client that parses every error as JSON
will choke on it rather than report it. That size belongs in parts.
| Parameter | Role |
|---|---|
name | File name, .safetensors, letters, digits, . _ -, 80 characters at most. A name already taken is replaced. |
kind | image (the default) or video. Needs the images or the videos permission accordingly. |
trigger | The trigger word to put back in the prompt, 60 characters at most. |
curl -s -X POST "$B/loras/upload?name=my_character.safetensors&kind=image&trigger=ohwx" -H "Authorization: Bearer $KEY" -H "Content-Type: application/octet-stream" --data-binary @my_character.safetensorsThe answer is { "ok": true, "name": "my_character.safetensors", "kind": "image", "size": 314572800, "trigger": "ohwx", "architecture": "krea2", "storage": { ... } }.
Compatibility is checked on upload. Images are rendered by Krea 2, which has an architecture of its own: only a Krea 2 LoRA applies, whether trained in tendre.AI or for Krea 2 elsewhere. Note that a FLUX.1 LoRA is refused, which is not everyone's first guess. Videos are rendered by LTX 2.5, so a LTX 2.5 or 2.3 LoRA.
The API reads the file header before storing anything. A LoRA recognised as SDXL, SD 1.5, SD3,
FLUX.1, Wan or HunyuanVideo is turned away with 415 incompatible_lora, which names the
architecture it found, and a file that is not a safetensors with 415 invalid_safetensors.
Nothing is stored and no quota is spent in either case.
One case still gets through: an architecture the header does not identify is accepted, with
architecture: "inconnue", and is only judged at render time. That is the one mistake that costs
you a render to discover.
Send a large LoRA in parts
Three calls, plus one to give up.
1. Open the session. POST /loras/upload/start with { "name", "kind", "size", "trigger" },
where size is the whole file in bytes. It checks the name, the size and the quota, then answers:
{ "uploadId": "...", "part_size": 20971520, "max_part_size": 104857600, "parts_expected": 15 }2. Send each part. POST /loras/upload/part?uploadId=...&part=N, raw body, parts numbered
from 1, part_size bytes each except the last, never more than max_part_size. Each answer
carries an etag, and you need all of them for the next step, so keep them as you go.
Part 1 is special: it carries the file header, so that is where the architecture is judged. An
incompatible LoRA is caught there with 415 and the session is cancelled. You find out after
20 MB rather than after 400.
3. Close it. POST /loras/upload/finish with { "uploadId", "parts": [ { "part": 1, "etag": "..." }, ... ] }.
It assembles the file and registers it, answering exactly like the one-shot upload.
To give up, POST /loras/upload/abort with { "uploadId" }. A session left without finish is
dropped after six hours. Nothing shows in GET /loras and nothing counts against the quota
until finish succeeds, so an interrupted upload costs you no storage.
import os, requests
B, H = "https://api.tendre.ai/pro/v1", {"Authorization": "Bearer " + KEY}
path = name = "my_character.safetensors"
s = requests.post(f"{B}/loras/upload/start", headers=H,
json={"name": name, "kind": "image", "size": os.path.getsize(path), "trigger": "ohwx"}).json()
parts = []
with open(path, "rb") as f:
n = 1
while chunk := f.read(s["part_size"]):
r = requests.post(f"{B}/loras/upload/part",
headers={**H, "Content-Type": "application/octet-stream"},
params={"uploadId": s["uploadId"], "part": n}, data=chunk)
r.raise_for_status()
parts.append({"part": n, "etag": r.json()["etag"]})
n += 1
done = requests.post(f"{B}/loras/upload/finish", headers=H,
json={"uploadId": s["uploadId"], "parts": parts}).json()
print(done["name"], done["architecture"], done["storage"])A ready-made PowerShell script ships with the app, at scripts/api-pro-lora.ps1.
Delete a LoRA
DELETE /loras/{name}?kind=image|video removes the file and frees the quota. It answers
{ "ok": true, "name": ..., "storage": { ... } }, or 404 unknown_lora when the name does not
exist on the account.
Storage quota
Each licence gets 20 files and 10 GB at most, images and videos together, and the characters
trained in the app count towards it. GET /loras reports the state in storage:
{ "count": 3, "max_count": 20, "bytes": 943718400, "max_bytes": 10737418240 }Past either limit, an upload answers 413 lora_quota along with what is left. Delete a file to
make room.
Training a character is still not exposed by the API. It happens in the app, and the result shows up in this list.
Activity
The app keeps the log of every render your keys have made (date, key, type, cost, status) and the consumption per key, under Settings > API > Activity.
That is also where you revoke a key that has been leaking, without touching the others.