Virtual Try-On API
Send a photo of a person and a photo of a garment — get back a photorealistic image of that person wearing that garment. Submit a job, then poll for the result or let us call you back.
Breaking change
This API used to be fully synchronous — one request, one blocking response carrying the finished image. It is now asynchronous.POST /v1/tryon (and /upload) returns a job id immediately with 202; you get the result by polling GET /v1/tryon/jobs/{id} and/or by supplying a callback_url. If you integrated against an older version of these docs, update your integration — the old single-blocking-call flow no longer works.Using an AI coding agent?
Download a condensed markdown spec covering every endpoint, field, job status, and error code — or copy it straight into your chat. English only.
Create an API key in your dashboard
Open the API keys tab in your BenitoAI dashboard and create a key in a couple of clicks. A key is shown only once, at creation time — copy it straight into your server's secret store. You can also give an individual key its own credit limit there.
Key concepts
Read these first — they shape how you integrate.
- Asynchronous job model. Submitting a request returns a job id immediately — it does not wait for generation to finish. You then either poll
GET /v1/tryon/jobs/{id}untilstatusiscompleted(orfailed), or supply acallback_urland we'llPOSTthe finished job to it. Both mechanisms are always available — polling never stops working just because you also gave acallback_url. - Generation takes ~10–60 seconds once a job starts processing, plus any queue wait. Design your integration around checking back later (polling every 1–3s is reasonable), not around a single long-held connection.
- Two ways to provide images.
POST /v1/tryontakes JSON with image URLs that are publicly reachable overhttp/https— the server downloads them itself.POST /v1/tryon/uploadtakesmultipart/form-dataso you can upload the raw file instead, which saves you hosting an image publicly just so this API can download it again. - Output is a hosted PNG URL. The API never returns base64 — you get back a URL to the generated
.png.
Authentication
Every request requires a Bearer API key in the Authorization header:
Authorization: Bearer bnto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx- Keys are prefixed
bnto_live_followed by a random string. - Keys are shown once, at creation time — store yours securely.
- A missing, malformed, revoked, or unknown key returns
401. - Treat the key as a secret: never embed it in client-side/browser code or commit it to source control. Call the API from your backend.
Credits & quota
Every accepted try-on costs one credit, charged when you submit — not when the job finishes. A job that ends failed is refunded automatically, so you are only ever charged for a generation you actually got. Two counters pay for it, in this order:
- Plan quota — the try-ons included in your monthly plan. Resets each billing cycle; unused quota does not roll over, and it stops being spendable the moment the cycle ends.
- Purchased credits — top-ups you bought separately. These never expire and survive a lapsed subscription. They cover overage once quota runs out.
Credits belong to your account, not to a key. All your API keys draw from the same pool — three keys running two try-ons each spends six credits in total. You can optionally give an individual key its own credit limit, which caps how much of the shared pool that key may spend. That is a ceiling, not a separate allowance: it never adds spending power.
If neither counter can cover a call you get 402 insufficient_credits, and nothing is queued. If the key is capped out while the account still has credits you get 402 key_credit_limit_reached instead — same status, but the fix is different, so check the code.
Submitting a job
Send a JSON body to the endpoint. The response is immediate and does not contain the image.
Body schema:
{
"model_name": "tryon-v1.6",
"inputs": {
"model_image": "https://example.com/person.jpg",
"garment_image": "https://example.com/garment.jpg"
},
"callback_url": "https://your-server.example/webhooks/tryon"
}| Field | Type | Required | Description |
|---|---|---|---|
model_name | string | No | Logical model identifier. Defaults to tryon-v1.6. Echoed back in the response. |
inputs.model_image | URL | Yes | Public URL of the person/model photo. This image defines the output's identity, pose, background, and aspect ratio. |
inputs.garment_image | URL | Conditional | Exactly one of garment_image / garment_id. Public URL of the garment photo. Used as a reference for fabric, cut, and colour only — its background is ignored. |
inputs.garment_id | string | Conditional | Exactly one of garment_image / garment_id. Id of a garment preset you registered from the dashboard, e.g. grm_V1StGXR8Z5jdHi6BmyT. See Garment presets below. |
callback_url | URL | No | If set, we POST the finished job's envelope here once it completes or fails. See Callbacks. Polling stays available either way. |
A successful submission returns 202 with a job id — see Submit response below, then Polling for the result.
model_image is the person; garment_image is the clothing. The person's face, body, pose, and background are preserved; the garment is transferred onto them.Garment presets
Most integrations send the same garment images over and over while the person photo changes on every call. For those, register each garment once in the dashboard instead of re-sending its URL: we clean the photo up ahead of time — the garment extracted front-facing onto a solid background — store it, and give you back a stable garment_id.
- Better results — the model conditions on a clean garment reference rather than a photo of someone else wearing it on a busy background.
- Faster start — we skip fetching and normalizing the garment image on every request, because it is already stored and ready.
The same request, using a preset instead of a URL:
{
"model_name": "tryon-v1.6",
"inputs": {
"model_image": "https://example.com/person.jpg",
"garment_id": "grm_V1StGXR8Z5jdHi6BmyT"
}
}Presets are managed from the garments tab of your dashboard, not through this API. A garment_id that doesn't exist, belongs to another account, or has been deleted returns 404 garment_not_found; one that is still being prepared returns 409 garment_not_ready — retry the identical request once it's ready.
File upload
Use POST /v1/tryon/upload instead of the JSON endpoint when you already have the raw image bytes — a file your own user just uploaded, say — and don't want to host them somewhere public first just to hand this API a URL. Same async job model: it also returns 202 with a job id.
Headers:
POST /v1/tryon/upload
Content-Type: multipart/form-data
Authorization: Bearer <your-api-key>| Field | Type | Required | Description |
|---|---|---|---|
model_name | text | No | Same as the JSON endpoint. Defaults to tryon-v1.6. |
model_image | file | Conditional | Exactly one of model_image / model_image_url. Person/model photo, uploaded directly. |
model_image_url | text | Conditional | Exactly one of model_image / model_image_url. URL of the person/model photo. |
garment_image | file | Conditional | Exactly one of garment_image / garment_image_url / garment_id. Garment reference photo, uploaded directly. |
garment_image_url | text | Conditional | Exactly one of garment_image / garment_image_url / garment_id. URL of the garment reference photo. |
garment_id | text | Conditional | Exactly one of garment_image / garment_image_url / garment_id. Id of a garment preset registered from the dashboard. |
callback_url | text | No | Same as the JSON endpoint. |
The model image and the garment input are resolved independently — you can upload one as a file and pass the other as a URL in the same request, for example uploading a fresh model photo while reusing a previously-hosted garment image or a garment_id. Providing more than one form of the same input, or none, returns 422 validation_error.
Image requirements
Both the model image and the garment image must satisfy all of the following. Because URL-based inputs are validated after the job is accepted, a failure surfaces as status: "failed" on the job rather than as an HTTP error at submit time. A file uploaded to /v1/tryon/upload is the exception: it is already in hand and cheap to check, so it is rejected synchronously with 400 invalid_image_upload.
| Requirement | Limit / Rule |
|---|---|
| Scheme | http or https only |
| Format | PNG, JPEG, WebP, or GIF |
| Content-Type | The server must receive an image/* content type |
| Max size | 10 MB per image |
| Reachability | URL must return HTTP 200 within 10 seconds |
| Redirects | At most 2 redirects are followed |
| Host | Must resolve to a public IP address |
The Reachability, Redirects, and Host rows apply only to the URL-based flow — URLs submitted directly, or via model_image_url/garment_image_url on the upload endpoint. A file uploaded through model_image/garment_image skips the network fetch entirely, but is still checked against Format, Content-Type, and Max size.
169.254.169.254), 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1, and fc00::/7. Every redirect hop is re-validated. Use public image hosting (your own CDN, object storage with public read, etc.). The same rule applies to your callback_url.Output aspect ratio
The output's aspect ratio matches the model_image, snapped to the nearest supported ratio (e.g. 1:1, 16:9, 9:16, 4:3, 3:4, 4:5). For predictable framing, crop your person photo to the aspect ratio you want before submitting.
Tips for best results
- Use a clear, well-lit, front-facing photo of the person.
- Use a clean garment reference (flat-lay or on a model) where the item is fully visible. The garment's background is discarded, so it doesn't need to be plain.
- If the garment image includes accessories (shoes, hats, belts, glasses, bags, jewelry), they may also be applied.
- A full-body garment (dress/jumpsuit) replaces both top and bottom; a "top" is layered appropriately.
Submit response
Accepted → 202 Accepted
{
"id": "tryon_V1StGXR8Z5jdHi6BmyT8x",
"status": "queued",
"model_name": "tryon-v1.6",
"created_at": "2026-08-09T12:00:00Z",
"updated_at": "2026-08-09T12:00:00Z",
"output": null,
"error": null
}| Field | Description |
|---|---|
id | Unique job id (tryon_…). Use it to poll GET /v1/tryon/jobs/{id}. It is also the output filename once the job completes. |
status | One of queued, processing, completed, or failed. |
model_name | The model identifier you sent, or the default. |
created_at / updated_at | UTC timestamps (ISO 8601). |
output | null until the job is completed, then { "image_url": "…" }. |
error | null unless status is failed, then { "code", "message" }. |
202 means the job was accepted for processing, not that it will succeed — check status/error via polling or your callback for the actual outcome.
X-Request-ID header — include it when reporting issues. You can also set your own X-Request-ID on the request and it will be echoed back.Polling for the result
This returns exactly the same envelope shape as the submit response, updated to reflect the job's current state.
A completed job:
{
"id": "tryon_V1StGXR8Z5jdHi6BmyT8x",
"status": "completed",
"model_name": "tryon-v1.6",
"created_at": "2026-08-09T12:00:00Z",
"updated_at": "2026-08-09T12:00:15Z",
"output": {
"image_url": "https://pub-xxxx.r2.dev/outputs/tryon_V1StGXR8Z5jdHi6BmyT8x.png"
},
"error": null
}| Status | What it means |
|---|---|
queued | Accepted, waiting for a worker. Poll again shortly. |
processing | Generation is running. Poll again shortly. |
completed | Done — output.image_url is set. |
failed | error.code / error.message describe why. See the job-level table under Errors. |
- Polling every 1–3 seconds is reasonable. This endpoint is not subject to the submission rate limit.
- A job id you don't own, or that doesn't exist, returns
404 job_not_foundeither way — we don't reveal whether a job id belongs to someone else.
Callbacks
Optional. If you set callback_url at submit time, we POST the same envelope shown above to that URL once the job reaches a terminal state (completed or failed).
POST <your callback_url>
Content-Type: application/json
{ "id": "tryon_...", "status": "completed", "output": {...}, "error": null, ... }- Unsigned. This is a convenience notification, not a source of truth. Anyone who learns your
callback_urlcould POST a fake payload to it. - Retried with backoff on timeout, connection error,
5xx, or429, up to a bounded number of attempts. A4xxresponse from your endpoint (other than429) is treated as permanent — we won't keep retrying a URL that's actively rejecting the payload. - Delivery is not guaranteed. If your endpoint is down for the entire retry window, the callback is simply dropped. Polling remains the authoritative fallback, which is why it is never disabled just because you supplied a
callback_url. - Respond
2xxpromptly. A slow endpoint risks its own delivery attempt being treated as a timeout and retried.
GET /v1/tryon/jobs/{id} — which is authenticated by your API key — rather than trusting the callback body alone.Errors
All errors share one envelope:
{ "error": { "code": "invalid_api_key", "message": "Invalid API key." } }Validation errors (422) on POST /v1/tryon additionally include a details array describing which fields failed. On POST /v1/tryon/upload, the file-vs-URL exactly-one-of check also reports validation_error but without a details array — the message describes which field was missing or duplicated.
HTTP-level errors
Returned synchronously, before a job is even created.
| HTTP | code | Meaning / cause |
|---|---|---|
| 401 | missing_api_key | No or malformed Authorization header. |
| 401 | invalid_api_key | Unknown or revoked key. |
| 402 | insufficient_credits | Your account has no quota and no credits left. Top up or renew from the dashboard. |
| 402 | key_credit_limit_reached | This key has hit its own credit limit even though the account still has credits. Raise the limit on the key, or call with a different one. |
| 404 | job_not_found | Unknown job id, or one belonging to another account. |
| 404 | garment_not_found | Unknown garment_id, or one belonging to another account, or since deleted. |
| 409 | garment_not_ready | The garment preset exists but is still being prepared, or its last preparation failed. Retry the identical request once it's ready. |
| 422 | validation_error | Request body failed schema validation, or — on the upload endpoint — both or neither of an image file and its URL counterpart were provided. |
| 422 | invalid_callback_url | callback_url isn't a usable public http/https URL. |
| 400 | invalid_image_upload | An uploaded file (upload endpoint only) was invalid — wrong type, too large, corrupt, or unparseable. Checked synchronously since the bytes are already in hand. |
| 429 | rate_limited | Per-key rate limit exceeded on submission. Includes a Retry-After header. |
| 503 | queue_unavailable | Transient — the job queue was unreachable. Includes a Retry-After header; safe to retry the submission. |
| 500 | internal_error | Unexpected server error. |
Job-level errors
These are not HTTP statuses. They arrive as status: "failed" on the job, via polling or your callback, with the code in error.code.
| error.code | Meaning / cause |
|---|---|
image_fetch_failed | An input image URL couldn't be fetched or validated — bad URL, too big, wrong type, blocked host, timeout, or non-200. |
image_processing_failed | An image was fetched but couldn't be decoded or normalized. |
generation_failed | The model could not produce an image. |
content_blocked | Generation blocked by safety filters. |
upstream_unavailable | The upstream generation provider stayed unreachable through the full retry window. |
internal_error | Unexpected server error during processing. |
Handling guidance
429— read theRetry-Afterresponse header (seconds) and back off before retrying submission.503 queue_unavailable— transient; retry the submission with backoff.generation_failed/content_blocked(on the job) — usually input-related. Try a clearer person photo or a different garment image; retrying the identical request rarely helps forcontent_blocked.image_fetch_failed(on the job) — verify the URL is public, returns an image, is under 10 MB, and resolves to a public host.invalid_image_upload(upload endpoint only) — verify the uploaded file is a PNG/JPEG/WebP/GIF under 10 MB and not corrupted.internal_error/upstream_unavailable— transient; safe to resubmit with backoff.
Rate limits
- Applies to job submission only (
POST /v1/tryonandPOST /v1/tryon/upload) — pollingGET /v1/tryon/jobs/{id}is not rate-limited. - Default: 60 requests per minute per API key (some keys may have a custom limit).
- Enforced with a rolling 60-second window.
- Exceeding it returns
429with aRetry-Afterheader telling you how many seconds to wait.
Examples
Every example below submits a job and then collects the result. Nothing holds a connection open waiting for generation.
Submit, then poll
The baseline integration: POST the job, then poll the job endpoint until it leaves queued/processing.
# 1. Submit
resp=$(curl -s -X POST https://api.benitoai.com/v1/tryon \
-H "Authorization: Bearer bnto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"model_name": "tryon-v1.6",
"inputs": {
"model_image": "https://example.com/person.jpg",
"garment_image": "https://example.com/garment.jpg"
}
}')
job_id=$(echo "$resp" | jq -r .id)
# 2. Poll until done
until [ "$(curl -s https://api.benitoai.com/v1/tryon/jobs/$job_id \
-H "Authorization: Bearer bnto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" | jq -r .status)" != "queued" ]; do
sleep 2
done
curl -s https://api.benitoai.com/v1/tryon/jobs/$job_id \
-H "Authorization: Bearer bnto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" | jqReceiving a callback instead of polling
Supply a callback_url at submit time and we'll POST the finished job to it. Confirm with a GET before trusting the payload — the callback is unsigned.
app.post("/webhooks/tryon", express.json(), (req, res) => {
const job = req.body;
// Always confirm via GET /v1/tryon/jobs/{id} before trusting this —
// this endpoint is unsigned.
if (job.status === "completed") {
console.log("Result:", job.output.image_url);
} else if (job.status === "failed") {
console.error("Failed:", job.error);
}
res.sendStatus(200);
});Uploading a file directly
Same auth rules; only the body shape changes. These upload the model photo and reuse a hosted garment image by URL, to show the mixed case — either input can be a file or a URL independently.
curl -X POST https://api.benitoai.com/v1/tryon/upload \
-H "Authorization: Bearer bnto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-F "model_name=tryon-v1.6" \
-F "model_image=@person.jpg" \
-F "garment_image_url=https://example.com/garment.jpg"Other endpoints
| Endpoint | Purpose |
|---|---|
GET /health | Liveness check. Returns 200 with {"status":"ok","mongo":true,"rabbitmq":true} when healthy, 503 otherwise. No auth required. |
GET /docs | Interactive Swagger/OpenAPI UI for exploring and testing the API in the browser. |
Quick checklist before you call
- ☐ Bearer API key set in the
Authorizationheader. - ☐ Image URLs are public
http/httpsand return a PNG/JPEG/WebP/GIF — or you're uploading the files directly. - ☐ Each image is under 10 MB and loads within 10s.
- ☐ Person photo cropped to your desired output aspect ratio.
- ☐ Your integration polls
GET /v1/tryon/jobs/{id}and/or handles acallback_urldelivery, rather than expecting a blocking response. - ☐ Retry/backoff logic for
429and503on submission, honoringRetry-After.
Ready to start?
Create an API key in your dashboard and make your first call.