<!--
  Hand-maintained. This is the condensed, machine-readable spec served from
  /tryonai-api-llms.md and offered as "Instructions for LLMs" on the docs page.

  The long-form human spec is src/app/[locale]/products/tryonai-api/docs.md.
  When you change one, change the other: `scripts/check-llms-doc.mjs` runs on
  `npm run build` and fails if this file stops covering every endpoint, field,
  job status and error code that docs.md defines.
-->

# BenitoAI Virtual Try-On API — LLM integration guide

You are integrating the BenitoAI Virtual Try-On API. This document is the complete and
authoritative specification. **Do not infer endpoints, fields, parameters, or behaviour
that is not written here.** If something you need is not in this document, it does not
exist in this API.

The API takes a photo of a person and a photo of a garment, and returns a photorealistic
image of that person wearing that garment.

---

## Critical: this API is asynchronous

Older versions of this API were synchronous. They are gone. If you have prior knowledge
of a blocking try-on endpoint, discard it.

- `POST /v1/tryon` returns **`202` with a job id immediately**. It never returns the image.
- You get the result by **polling** `GET /v1/tryon/jobs/{id}` until `status` is
  `completed` or `failed`, and/or by supplying a `callback_url` we POST to.
- Both mechanisms are always available. Supplying a `callback_url` does not disable polling.
- Generation takes **~10–60 seconds** once a job starts processing, plus any queue wait.
  Poll every 1–3 seconds. Do not hold a connection open.

---

## Base URL and authentication

Base URL: `https://api.benitoai.com`

Every request requires a Bearer API key:

```http
Authorization: Bearer bnto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

- Keys start with `bnto_live_` followed by a random string.
- Keys are created in the BenitoAI dashboard and shown only once, at creation time.
- Missing, malformed, revoked, or unknown key → `401`.
- The key is a secret. Call the API from a backend; never from browser code.

Every response carries an `X-Request-ID` header. You may also set your own `X-Request-ID`
on a request and it will be echoed back.

---

## Endpoints

| Method | Path | Purpose |
| ------ | ---- | ------- |
| POST | `/v1/tryon` | Submit a job with image URLs (JSON) |
| POST | `/v1/tryon/upload` | Submit a job with uploaded files (multipart) |
| GET | `/v1/tryon/jobs/{id}` | Fetch a job's current state |
| GET | `/health` | Liveness. Returns `200` `{"status":"ok","mongo":true,"rabbitmq":true}` when healthy, `503` otherwise. No auth |
| GET | `/docs` | Interactive Swagger/OpenAPI UI |

---

## 1. `POST /v1/tryon` — submit with URLs

`Content-Type: application/json`

```json
{
  "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 | Notes |
| ----- | ---- | -------- | ----- |
| `model_name` | string | No | Defaults to `tryon-v1.6`. Echoed back in the response |
| `inputs.model_image` | URL | Yes | The **person**. Defines the output's identity, pose, background, and aspect ratio |
| `inputs.garment_image` | URL | Conditional | Exactly one of `garment_image` / `garment_id`. The **garment**. Reference for fabric, cut, and colour only — its background is ignored |
| `inputs.garment_id` | string | Conditional | Exactly one of `garment_image` / `garment_id`. A garment preset id, e.g. `grm_V1StGXR8Z5jdHi6BmyT` (see §4) |
| `callback_url` | URL | No | We POST the finished job envelope here on completion or failure (see §6) |

`model_image` is the person and `garment_image` is the clothing — the order is not
interchangeable. The person's face, body, pose, and background are preserved; the garment
is transferred onto them.

---

## 2. `POST /v1/tryon/upload` — submit with files

`Content-Type: multipart/form-data`. Same async job model; also returns `202` with a job id.
Use this when you already hold the raw bytes and don't want to host them publicly first.

| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| `model_name` | text | No | Defaults to `tryon-v1.6` |
| `model_image` | file | Conditional | Exactly one of `model_image` / `model_image_url` |
| `model_image_url` | text | Conditional | Exactly one of `model_image` / `model_image_url` |
| `garment_image` | file | Conditional | Exactly one of `garment_image` / `garment_image_url` / `garment_id` |
| `garment_image_url` | text | Conditional | Exactly one of `garment_image` / `garment_image_url` / `garment_id` |
| `garment_id` | text | Conditional | Exactly one of `garment_image` / `garment_image_url` / `garment_id` |
| `callback_url` | text | No | As above |

The model input and the garment input resolve **independently**: you may upload one as a
file and pass the other as a URL in the same request. Supplying more than one form of the
same input, or none, returns `422 validation_error`.

---

## 3. Job envelope

Returned by both submit endpoints (as `202`) and by `GET /v1/tryon/jobs/{id}`. Identical
shape in all cases.

```json
{
  "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 | Notes |
| ----- | ----- |
| `id` | Job id, prefixed `tryon_`. Also the output filename once completed |
| `status` | `queued`, `processing`, `completed`, or `failed` |
| `model_name` | The model identifier you sent, or the default |
| `created_at` / `updated_at` | UTC, ISO 8601 |
| `output` | `null` until `completed`, then `{ "image_url": "..." }` |
| `error` | `null` unless `failed`, then `{ "code", "message" }` |

`202` means accepted for processing, **not** that it will succeed.

Completed job:

```json
{
  "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
}
```

Output is always a hosted PNG URL. The API never returns base64.

---

## 4. Garment presets

Register a garment once in the dashboard and reuse its `garment_id` instead of re-sending
the same image URL on every call. We normalize the photo ahead of time (garment extracted
front-facing onto a solid background) and store it.

- Produces better results — the model conditions on a clean reference rather than a photo
  of someone else wearing the item on a busy background.
- Starts faster — no per-request fetch and normalization.

Presets are managed in the dashboard, not through this API.
Unknown, foreign, or deleted id → `404 garment_not_found`. Still being prepared (or its
last preparation failed) → `409 garment_not_ready`; retry the identical request later.

```json
{
  "model_name": "tryon-v1.6",
  "inputs": {
    "model_image": "https://example.com/person.jpg",
    "garment_id": "grm_V1StGXR8Z5jdHi6BmyT"
  }
}
```

---

## 5. Polling

```http
GET /v1/tryon/jobs/{id}
Authorization: Bearer <your-api-key>
```

- `queued` / `processing` → not done; poll again. Every 1–3 seconds is reasonable.
- `completed` → `output.image_url` is set.
- `failed` → read `error.code` and `error.message` (see §9).
- This endpoint is **not** rate-limited.
- A job id you don't own, or that doesn't exist, returns `404 job_not_found` in both
  cases — ownership is never disclosed.

---

## 6. Callbacks

If `callback_url` was set at submit time, we POST the same envelope to it once the job
reaches `completed` or `failed`.

```http
POST <your callback_url>
Content-Type: application/json

{ "id": "tryon_...", "status": "completed", "output": {...}, "error": null, ... }
```

- **Unsigned.** Anyone who learns the URL can post a fake payload. Always confirm by
  calling `GET /v1/tryon/jobs/{id}`, which is authenticated, before trusting the contents.
- Retried with backoff on timeout, connection error, `5xx`, or `429`, up to a bounded
  number of attempts. Any other `4xx` from your endpoint is treated as permanent.
- **Delivery is not guaranteed.** If your endpoint is down for the whole retry window the
  callback is dropped. Polling is the authoritative fallback.
- Respond `2xx` promptly; a slow endpoint risks its attempt being treated as a timeout.

---

## 7. Image requirements

Applies to both input images.

| Requirement | Rule |
| ----------- | ---- |
| Scheme | `http` or `https` only |
| Format | PNG, JPEG, WebP, or GIF |
| Content-Type | 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 followed, each hop re-validated |
| Host | Must resolve to a public IP address |

Reachability, Redirects, and Host apply only to URL inputs (direct URLs, or
`model_image_url` / `garment_image_url`). An uploaded file skips the network fetch but is
still checked for Format, Content-Type, and Max size.

**Where failures surface:** a URL input is validated *after* the job is accepted, so a bad
URL fails the **job** (`status: "failed"`, `image_fetch_failed`), not the submit call. An
uploaded file is checked synchronously and fails the submit call with
`400 invalid_image_upload`.

**SSRF protection:** URLs resolving to private, loopback, link-local, reserved, multicast,
or unspecified addresses are rejected — including `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`. The same rule
applies to `callback_url`.

**Output aspect ratio** is taken from `model_image`, snapped to the nearest supported ratio
(`1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `4:5`). Crop the person photo beforehand for
predictable framing.

---

## 8. Credits and rate limits

One credit per accepted try-on, **charged at submit**, not on completion. A job that ends
`failed` is refunded automatically.

Two counters are spent in order:

1. **Plan quota** — included in the monthly plan. Resets each billing cycle, does not roll
   over, and stops being spendable when the cycle ends.
2. **Purchased credits** — top-ups. Never expire, survive a lapsed subscription, cover
   overage once quota is gone.

They are separate numbers and must not be summed when displaying remaining balance.
Credits belong to the **account**, not to a key — all keys draw from one pool. A per-key
credit limit is a ceiling on that shared pool, never an additional allowance.

- No quota and no credits → `402 insufficient_credits`; nothing is queued.
- Key's own limit reached while the account still has credits → `402 key_credit_limit_reached`.

Rate limits apply to **submission only** (`POST /v1/tryon`, `POST /v1/tryon/upload`) —
polling is exempt. Default 60 requests per minute per key, rolling 60-second window.
Exceeding it returns `429` with a `Retry-After` header.

---

## 9. Errors

All errors share one envelope:

```json
{ "error": { "code": "invalid_api_key", "message": "Invalid API key." } }
```

`422` on `POST /v1/tryon` also includes a `details` array naming the failed fields. On
`POST /v1/tryon/upload` the exactly-one-of check reports `validation_error` without
`details`; the `message` names the field.

### HTTP-level — returned synchronously, before a job exists

| HTTP | code | Cause |
| ---- | ---- | ----- |
| 401 | `missing_api_key` | No or malformed `Authorization` header |
| 401 | `invalid_api_key` | Unknown or revoked key |
| 402 | `insufficient_credits` | No quota and no credits left |
| 402 | `key_credit_limit_reached` | This key hit its own limit; account still has credits |
| 404 | `job_not_found` | Unknown job id, or one belonging to another account |
| 404 | `garment_not_found` | Unknown `garment_id`, foreign, or deleted |
| 409 | `garment_not_ready` | Preset exists but is still being prepared |
| 422 | `validation_error` | Schema validation failed, or the upload exactly-one-of rule was broken |
| 422 | `invalid_callback_url` | `callback_url` is not a usable public `http`/`https` URL |
| 400 | `invalid_image_upload` | Uploaded file wrong type, too large, corrupt, or unparseable |
| 429 | `rate_limited` | Submission rate limit exceeded. Includes `Retry-After` |
| 503 | `queue_unavailable` | Transient; queue unreachable. Includes `Retry-After`; safe to retry |
| 500 | `internal_error` | Unexpected server error |

### Job-level — arrive as `status: "failed"`, in `error.code`

These are **not** HTTP statuses. They are only visible via polling or a callback.

| code | Cause |
| ---- | ----- |
| `image_fetch_failed` | An input image URL couldn't be fetched or validated |
| `image_processing_failed` | Image fetched but couldn't be decoded or normalized |
| `generation_failed` | The model could not produce an image |
| `content_blocked` | Blocked by safety filters |
| `upstream_unavailable` | Upstream generation provider unreachable through the full retry window |
| `internal_error` | Unexpected server error during processing |

### Retry guidance

- `429`, `503 queue_unavailable` → honour `Retry-After`, back off, retry submission.
- `internal_error`, `upstream_unavailable` → transient, safe to resubmit with backoff.
- `image_fetch_failed` → fix the URL (public, an image, under 10 MB, public host).
- `invalid_image_upload` → fix the file (PNG/JPEG/WebP/GIF, under 10 MB, not corrupt).
- `generation_failed`, `content_blocked` → input-related. Retrying an identical
  `content_blocked` request rarely helps; change the inputs.

---

## 10. Worked example — submit and poll (Python)

```python
import time
import requests

BASE = "https://api.benitoai.com"
HEADERS = {"Authorization": "Bearer bnto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}

submit = requests.post(
    f"{BASE}/v1/tryon",
    headers={**HEADERS, "Content-Type": "application/json"},
    json={
        "model_name": "tryon-v1.6",
        "inputs": {
            "model_image": "https://example.com/person.jpg",
            "garment_image": "https://example.com/garment.jpg",
        },
    },
)
submit.raise_for_status()          # 202 on success
job_id = submit.json()["id"]       # the image is NOT in this response

while True:
    job = requests.get(f"{BASE}/v1/tryon/jobs/{job_id}", headers=HEADERS).json()
    if job["status"] in ("completed", "failed"):
        break
    time.sleep(2)

if job["status"] == "completed":
    print(job["output"]["image_url"])
else:
    print(job["error"]["code"], job["error"]["message"])
```

---

## 11. Common mistakes

Read this section before writing any client code.

- **Do not expect the image in the submit response.** `POST /v1/tryon` returns `202` with
  `output: null`. Code that reads `response.output.image_url` straight from the submit
  call is wrong and will throw.
- **Do not hold a connection open waiting for the result.** There is no synchronous mode
  and no long-polling. Setting a long client read timeout does not help.
- **Do not treat `202` as success.** It means accepted. The job can still end `failed`.
- **Do not trust a callback body.** It is unsigned. Confirm with `GET /v1/tryon/jobs/{id}`.
- **Do not disable polling because you set a `callback_url`.** Callback delivery is not
  guaranteed; polling is the authoritative fallback.
- **Do not send both `garment_image` and `garment_id`** (or both a file and its `_url`
  counterpart). Exactly one, or `422 validation_error`.
- **Do not add plan quota and purchased credits together** when showing a balance. They
  are spent under different rules.
- **Do not expect base64.** Output is always a hosted PNG URL.
- **Do not rate-limit your own polling against the 60/min budget.** That budget is for
  submissions; polling is exempt.
- **Do not assume a `404` on a job means it never existed.** It also covers jobs owned by
  another account.
