# Bijify API (v1)

**What is Bijify?** Bijify (https://bijify.com) is an AI jewelry-imagery service. You give it a photo of a piece of jewelry and it generates polished product imagery from it: either **On-Body** shots (the jewelry worn by an AI-generated model) or **Still-Life** studio shots, as images or short videos. This document describes the public HTTP API (v1) you call with an API key. It is written to be complete: everything you need to build a full storefront integration is here, including the parts that are easy to get wrong.

This is also the canonical generation API we created for bijify.com itself. The web app authenticates with its dashboard session instead of an API key and supplements V1 with private account, history and video-prompt helpers, but upload, discovery, generation and job polling use the same endpoints and response shapes documented here.

**Try it / reference implementation:** a browser POC that exercises every endpoint below lives at
**https://bijify.com/api-tester/index.html** - paste a valid `ak_...` key into the box and watch the network calls. Its source (`index.html` + `js/app.js`) is the end-to-end example.

- **Base URL:** `https://api.bijify.com/api/v1` (always include the path segment after it; a bare `/api/v1` is not routed)
- **Auth:** every endpoint requires `Authorization: Bearer ak_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX` (`ak_` + 32 hex chars)
- **Format:** JSON in / JSON out, except `POST /upload` (multipart). Send `Content-Type: application/json` on `POST /generate` and `POST /video-generate`.
- **CORS:** `Access-Control-Allow-Origin: *` on every response. Preflight answers 204 with `Allow-Methods: GET, POST, OPTIONS`, `Allow-Headers: Content-Type, Authorization`, `Max-Age: 86400`.

There are eight endpoints; the quick reference at the end of this document lists them all.

Example responses below are real captures (signed-URL tokens shortened). Credit prices and workflow lists are live data - read them at runtime, do not hardcode.

**Getting a key.** API access is part of the **Max** plan. On a Max subscription, create your key yourself at **Dashboard -> API Keys** (https://bijify.com/dashboard/api-keys), where you also register its allowed browser domain. One key per account; you can disable, delete and recreate it at any time, and the same screen shows its usage. The key is displayed once at creation - store it somewhere safe. Questions: info@bijify.com.

---

## Authentication

Keys have an allowed browser domain. **The domain check runs only when the request carries an `Origin` header.** Cross-origin browser `fetch()` calls to `api.bijify.com` include this browser-controlled header, so JavaScript running on another website cannot pretend to be your registered domain. Server-side HTTP clients and CLIs normally omit `Origin`, and requests without it are accepted without a domain check.

When `Origin` is present, matching is exact, `www.`-insensitive, and accepts **any subdomain** of the registered domain: a key for `example.com` accepts `shop.example.com` and `www.example.com`. It is not symmetric - a key registered for `www.example.com` does **not** accept `shop.example.com`.

An unknown key, a deactivated key, an expired key, and a domain mismatch are all rejected identically with `401 {"error":"Invalid or expired token"}` on **every** endpoint - authentication runs before dispatch, so the endpoint makes no difference, and the API deliberately does not tell you which of the four it was. A missing or non-`Bearer` header returns `401 {"error":"Authentication required"}` plus a `message` field. `{"error":"Authentication failed"}` only appears for a 401/403 raised *after* auth succeeded - in practice, asking for a job that is not yours. Switch on the status, not the string.

Every successful request is logged against the key with your IP, User-Agent and country.

**Keep the key on your server.** It is a bearer credential with spend authority. The allowed browser domain prevents direct reuse by JavaScript on another website, but it is not stolen-key protection: a server or CLI can omit `Origin`, or set it to any value. If you embed the key in browser JavaScript, anyone can recover it from the bundle or Network panel and then spend your credits from a server-side client. Route calls through your backend, authenticate your own customers there, and enforce any per-user or per-order limits yourself; Bijify attributes the traffic to your account and has no per-end-user quota.

## Errors

Always JSON. **Switch on the status code, never on the message.**

`/generate` and `/video-generate` shape-check the body first, and those failures return a real message - e.g. `400 {"error":"storage_key is required (use /upload first)"}`; an unparseable body returns `500 {"error":"Generation failed"}` or `{"error":"Video generation failed"}` respectively. **`/upload` has no such pass**, so every one of its failures - oversized file, bad type, quota - is sanitized. Once a request reaches the generation pipeline, every error out of it (and out of `/jobs/{id}` and `/upload`) is sanitized to one of these fixed strings:

| Status | Body | Meaning |
|---|---|---|
| 400 / 429 / 4xx | `{"error":"Request failed"}` | bad request, quota, or rate limit |
| 402 | `{"error":"Request failed"}` | **insufficient credits** |
| 401 / 403 | `{"error":"Authentication failed"}` | a job that is not yours (a bad key is rejected earlier, see Authentication) |
| 404 | `{"error":"Not found"}` | unknown job, or unknown `workflow_id` |
| 5xx | `{"error":"Service temporarily unavailable"}` | server error - *also what an invalid `mode` or a malformed `category_states` produces* |

`/modes`, `/schema`, `/workflows` and `/inspiration` are not sanitized and return real messages, e.g. `400 {"error":"Invalid mode: xyz"}`.

Infrastructure failures (502/504/52x from the edge) may return **HTML, not JSON**. Always guard your parse.

A 500 is not always our fault: an unknown `mode`, a `category_states` value of `null`, or `custom: true` without a `custom_value` string all throw internally and surface as `500 Service temporarily unavailable`. Validate client-side.

## Limits and money

**Rate limit.** Uploads and job starts share a single sliding window of **500 requests per rolling hour**, and API-key traffic currently shares that counter globally rather than per-key. Treat the effective budget as well below 500/h and back off on any 429. There is no `Retry-After` header.

**Upload limits.** 50 MiB per file (`400` if exceeded) and 1000 MiB of *new* bytes per account per UTC day (`429` if exceeded; the whole upload is rejected rather than partly counted). Deduplicated re-uploads are free and do not count against the daily quota.

**Credits.** Each run costs the `credits` figure `/workflows` reports for that workflow. Your balance is **checked** when the job is accepted (`402` if you are short) and **charged only when the job succeeds**. A `failed` job - including one killed by the 90-minute watchdog - is never charged, so a failure costs you nothing but time.

Nothing is held between the check and the charge. Two consequences that affect how you design:
- **Concurrent submissions are not serialized.** Ten 25-credit jobs fired at once against a 30-credit balance all pass the check and all return `202`. The final charge is capped at your remaining balance, so you cannot go negative, but you also cannot rely on `402` to throttle a burst. Queue your own submissions if that matters.
- **Count completions, not acceptances.** There is **no way to read your credit balance with an API key**. If you keep your own ledger, sum `credits` per job you observe `completed`; summing accepted jobs over-counts every failure. A job you stop polling has an outcome you never learn, so any self-kept ledger drifts - treat it as an estimate and read the real balance from the Bijify dashboard.

---

## How a job works

1. **Upload** your image bytes with `POST /upload` -> get a `storage_key` (+ file metadata).
2. **Pick a workflow** from `GET /workflows`. For images also pick a **mode** (`GET /modes`) and **presets** (`GET /schema?mode=...`). Video uses a freeform text `prompt` instead.
3. **Start the job**: `POST /generate` (image) or `POST /video-generate` (video) -> get a job `id`.
4. **Poll** `GET /jobs/{id}` until `status` is `completed` (or `failed`); read the output at `result.output.url` **and download it immediately**.

There is no "generate from a URL" shortcut: if your source is a remote URL, download the bytes yourself then `POST /upload`. Doing that fetch from browser JS usually fails - most image hosts send no CORS headers. Fetch server-side.

A `storage_key` can be reused in as many `/generate` and `/video-generate` calls as you like, which is how you offer "try 6 styles on one photo" without re-uploading.

IDs for modes, categories and presets are opaque 12-hex tokens (e.g. `d66bfe287849`), not readable slugs. Always read them from `/modes` and `/schema`; never hardcode or guess them, and **never key on labels** - labels are display text, they get renamed, and two categories in On-Body Women's share the label "Pose".

### The one exception: finding the Subject category

The most common storefront need - open the picker with Subject preset to Ring because the shopper is on a ring product page - needs one id you cannot discover generically. These are stable; you may hardcode them:

| Mode | Subject category | Ring | Earrings | Necklace | Bracelet |
|---|---|---|---|---|---|
| On-Body Women's `d66bfe287849` | `ef7618d5524d` | `981531a8cea2` | `4c8d3f9d5b1a` | `6087c804fac1` | `3df16635e183` |
| Still-Life `d3766436ba3f` | `a965ce25e795` | `cee2214426e4` | `ce7420ba83a2` | `83a685efaeb9` | `6eed58edee7d` |

These are also the labels the `/inspiration?jewelry=` filter matches on. Everything else should be read at runtime.

---

## Endpoints

### POST /upload
Multipart. The file field MUST be named `image`, and the part must declare a content type of `image/jpeg`, `image/jpg`, `image/png` or `image/webp` - a `Blob` built by hand with no type is rejected before the bytes are read. The content is then validated by magic signature (JPEG `FF D8 FF`, the 8-byte PNG signature, or `RIFF`...`WEBP`), so `file_type` in the response is the **detected** type, not what you declared.

```
POST /api/v1/upload
Authorization: Bearer ak_...
Content-Type: multipart/form-data
  image            = <image bytes>      (required)
  original_sha256  = <64 hex>           (optional - see below)
  source           = "bijify" | "video" (optional, default "bijify")
```

Real success (200):
```json
{
  "url": "https://api.bijify.com/api/inference/images/users/<uid>/content/<hash>.jpg?token=...",
  "storage_key": "users/<uid>/content/<hash>.jpg",
  "file_name": "bij_input.png",
  "file_size": 171693,
  "file_type": "image/jpeg"
}
```
Pass `storage_key`, `file_name`, `file_size`, `file_type` into the next step. The `url` is a temporary signed preview (2 h). `file_size` echoes what you sent.

**Deduplication.** The storage key is `users/<uid>/content/<first-16-hex-of-sha256>.<ext>`. Upload the same bytes twice and you get the same `storage_key`, the second upload is discarded, and it does not count against your daily quota. The extension comes from your filename, so identical bytes sent as `.jpg` and as `.png` become two separate objects.

**`original_sha256`** overrides the dedup identity with a hash you supply and forces the key extension to `.jpg`. It must be exactly 64 hex chars or it is silently ignored. **It is never verified against the bytes you send.** If the hash collides with one of your own earlier uploads, the server returns that earlier image's `storage_key` and throws away the bytes you just sent - and you will then generate from the wrong picture, with no error anywhere.

It exists for clients that compress before uploading. Canvas JPEG output is not byte-stable across browsers, so hashing the compressed bytes would defeat dedup. The recommended pipeline, which is what bijify.com itself does:

```
sha256(original bytes) -> compress to JPEG (longest side <= 2048, quality 0.85,
                          skipped if already JPEG and < 500 KB)
                       -> upload(compressed, original_sha256: <hash of the ORIGINAL>)
```
If you are not compressing client-side, omit `original_sha256` entirely.

**`source`** is `"bijify"` (default) or `"video"`; any other value is treated as `"bijify"`. `source=video` triggers an internal image-understanding pass whose result **you cannot retrieve** (described under Video prompts below). Do not send it.

### GET /workflows
```json
{
  "workflows": [
    { "id": "bijify-image-lite", "name": "Bijify Image", "description": null, "category": "image", "credits": 25,  "health": { "status": "green", "score": 100, "avgGenerationSeconds": 12.95 } },
    { "id": "bijify-video-pro",  "name": "Bijify Video", "description": null, "category": "video", "credits": 300, "health": { "status": "green", "score": 100, "avgGenerationSeconds": 142.86 } }
  ]
}
```
Six workflows are returned today. The `name` field distinguishes them, but nothing in the API tells you the resolution or speed you are buying, so here is what each one actually is - this is what bijify.com shows its own users:

| id | What it is | Resolution | Typical |
|---|---|---|---|
| `bijify-image-lite` | Standard definition, fast | 1K | ~13 s |
| `bijify-image-pro` | High quality and definition, slower | 2K | ~44 s |
| `bijify-image-pro-2` | Higher quality and definition | 2K | ~31 s |
| `bijify-video-pro` | Current video model | 1080p | ~143 s |
| `bijify-video-legacy-lite` / `-legacy-pro` | Previous-generation video, kept for continuity | - | - |

The Pro image workflows deliberately drop the enhancement controls they make redundant (Size, Skin Realism, Beauty and Quality on On-Body Women's; Size and Quality on Still-Life) via the `__workflow__` rule - though those categories are internal, so you never see them in `/schema` anyway. Prefer the current `bijify-video-pro` over the legacy video workflows for new work.

- `category` decides `/generate` vs `/video-generate`.
- `credits` is the cost per run. Read it at runtime.
- `description` may be `null`; do not depend on it.
- `health` is always present, but **`health.avgGenerationSeconds` is omitted until the workflow has recent runs** - defend against that, since it is what you size your polling deadline from. `score` is the recent success rate out of 100 and `status` has **four** values: `green` (>=90), `yellow` (75-89), `orange` (50-74), `red` (<50). Treat `orange` and `red` as degraded. A workflow with no history reports `green`/`100` - that is a cold-start default, not evidence.
- `name` is a stable server-side display name, unlike mode and preset labels.

`workflow_id` is not validated against this list. Any workflow id that exists server-side is reachable; an unknown one returns 404.

### GET /modes
Trimmed sample (2 of the 6 returned):
```json
{
  "modes": [
    { "id": "d66bfe287849", "label": "On-Body Women's Jewelry", "restricted": false },
    { "id": "d3766436ba3f", "label": "Still-Life Jewelry",      "restricted": false }
  ]
}
```
Every object has `id`, `label` and `restricted`.

**API keys see every mode** - currently six, four flagged `restricted: true`. Those four are hidden from the Bijify web dashboard because they are work in progress; they answer `/schema` and `/generate` normally for you, but they may change or disappear without notice. Build against `restricted: false` modes unless you have agreed otherwise with us.

### GET /schema?mode={modeId}
Categories and presets for a mode. On-Body Women's returns **23 of its 30** categories (7 are internal); Still-Life returns **27 of 31** (4 internal). Trimmed sample:

```json
{
  "mode": "d66bfe287849",
  "mode_label": "On-Body Women's Jewelry",
  "categories": [
    { "id": "ef7618d5524d", "label": "Subject",
      "presets": [ { "id": "981531a8cea2", "label": "Ring" }, { "id": "4c8d3f9d5b1a", "label": "Earrings" },
                   { "id": "6087c804fac1", "label": "Necklace" }, { "id": "3df16635e183", "label": "Bracelet" } ] },
    { "id": "0d3684b8d739", "label": "Camera",
      "presets": [ { "id": "a54085214de1", "label": "Close-Up" }, { "id": "2ace0d27529f", "label": "Portrait" } ] },
    { "id": "7f49cbdd9af5", "label": "Age", "default": "e8a51c25f2da",
      "presets": [ { "id": "187e76db8609", "label": "Young" }, { "id": "e8a51c25f2da", "label": "Young Adult" } ] }
  ],
  "rules": [ ... ]
}
```
- Pick a category's initial value as `category.default ?? category.presets[0].id`. That is exactly what bijify.com does. `default` is present on only some categories and is a **hint for you** - the server never applies it to a category you omit.
- Internal (`visible:false`) categories are not returned here, but they are real: the server auto-fills them, and they do appear inside `_ui_state` template payloads. Pass such ids through unchanged rather than filtering them out.
- Missing `mode` -> `400 {"error":"Missing mode parameter"}`. Unknown mode -> `400 {"error":"Invalid mode: <id>"}`.
- `rules` has its own section below. **Read it** - it is what makes the picker behave.

### POST /generate  (image)
```json
{
  "workflow_id": "bijify-image-lite",
  "storage_key": "users/<uid>/content/<hash>.jpg",
  "file_name":   "bij_input.png",
  "file_size":   171693,
  "file_type":   "image/jpeg",
  "aspect_ratio": "1:1",
  "mode":         "d66bfe287849",
  "category_states": {
    "ef7618d5524d": { "selected_preset": "981531a8cea2", "custom": false, "custom_value": "" },
    "0d3684b8d739": { "selected_preset": "a54085214de1", "custom": false, "custom_value": "" },
    "7f49cbdd9af5": { "selected_preset": "e8a51c25f2da", "custom": false, "custom_value": "" }
  }
}
```
Send all of `workflow_id`, `storage_key`, `file_name`, `file_size` (a number), `file_type`, `mode` and `category_states`. The v1 shape check returns 400 only when one of the first five is absent. Missing `mode` returns 500 as described below. Missing or empty `category_states` is unfortunately accepted: the job runs using only the mode's hidden defaults, and a successful result is charged normally. Treat both fields as required in your client. `aspect_ratio` is optional, default `"1:1"`.

Omitting `mode`, or sending one that is not a real mode id, both return **`500`** - the v1 layer substitutes a placeholder for a missing `mode` and the generator then rejects it. There is no 400 for either case, so validate mode ids client-side against `/modes`.

`storage_key` is neither ownership-checked nor existence-checked. A typo returns `202 accepted`, runs, and fails minutes later with the generic `"Generation failed"`. You are not charged for it, but you have burned the round trip.

Response (HTTP 202) - the job object, same shape as `/jobs/{id}`:
```json
{ "id": "d643477d-1205-4f54-930a-e82276f58d12", "status": "accepted", "progress": 0, "result": null, "error": null, "createdAt": "2026-06-15T13:13:57.202Z", "updatedAt": "2026-06-15T13:13:57.202Z" }
```

#### category_states format
Each value is an object with **all three fields present**:
```json
"<categoryId>": { "selected_preset": "<presetId>", "custom": false, "custom_value": "" }
```
- Normal case: a preset id from `/schema`, `custom: false`, `custom_value: ""`.
- Free-text override: `custom: true` with your text in `custom_value`. The text **replaces** that category's preset wording in the prompt - you lose the preset's built-in phrasing rather than adding to it.
- **`selected_preset` is still what the rules evaluate against, even when `custom` is true.** Setting `custom: true` with an empty `selected_preset` silently flips rule outcomes and excludes the wrong categories. Always keep a valid preset id alongside your custom text.
- **Send a state for every category `/schema` returns.** A visible category you omit is not defaulted - it contributes nothing to the prompt, *and* its absence changes rule evaluation (a rule leaf on a missing category is always false).
- A bare string (`"<categoryId>": "<presetId>"`) does not work: the server reads `.selected_preset`, gets `undefined`, and silently drops the category. No error.
- A `selected_preset` is not checked against that category's current preset list. An unknown or stale id becomes literal prompt text; the job still runs and a success is charged. Before replaying saved state, re-read `/schema` and re-seed each stale visible category to `category.default ?? category.presets[0].id`; neither omit the category nor leave its preset empty, because both change rule evaluation. Templates supplied by `/inspiration` may include internal category ids absent from `/schema`; preserve those unchanged.
- `null`, or `custom: true` without a `custom_value` string, throws and returns **500**.
- This is the exact shape stored inside inspiration templates.

### POST /video-generate  (video)
Uses a freeform `prompt` instead of mode/presets.
```json
{
  "workflow_id": "bijify-video-pro",
  "storage_key": "users/<uid>/content/<hash>.jpg",
  "file_name":   "photo.jpg",
  "file_size":   171693,
  "file_type":   "image/jpeg",
  "prompt":      "slow cinematic orbit around the ring, soft studio light",
  "duration":     5
}
```
Required: `workflow_id`, `prompt` (non-empty after trimming), `storage_key`, `file_name`, `file_size`, `file_type`. Optional: `duration`, default `5`.

**`duration` must be `5` or `10`.** Other values are not rejected at request time - the job is accepted and comes back `failed` (uncharged). Duration does not change the credit cost.

**Do not rely on `aspect_ratio` here.** It defaults to `"1:1"` server-side whether you send it or not. On the primary path, `bijify-video-pro` ignores it and follows your input image; the legacy Seedance workflows and the failover provider do honor it. So the same request can produce different framing depending on which backend served it. **Crop your input image to the shape you want the video to be**, and if you do send the field, send a value the model accepts (`16:9`, `9:16` or `1:1` for `bijify-video-pro`).

Response is the same job object.

### GET /jobs/{id}
```json
{
  "id": "d643477d-1205-4f54-930a-e82276f58d12",
  "status": "completed",
  "progress": 100,
  "result": {
    "output": {
      "url": "https://api.bijify.com/api/inference/images/users/<uid>/outputs/<jobId>/1781529248029-7.jpg?token=...",
      "key": "users/<uid>/outputs/<jobId>/1781529248029-7.jpg",
      "bucket": "output",
      "type": "image"
    },
    "outputName": "output"
  },
  "error": null,
  "createdAt": "2026-06-15T13:13:57.202Z",
  "updatedAt": "2026-06-15T13:14:09.165Z"
}
```
The response never contains any field beyond these seven.

- `status` is exactly one of `accepted`, `processing`, `completed`, `failed`. Jobs cannot be cancelled.
- `progress` is 0-100, allocated across the pipeline's internal steps. It moves in coarse jumps and is **not** a time estimate - drive your ETA from `avgGenerationSeconds` instead.
- `result.output.type` is `"image"` or `"video"` - **use it to decide whether to render an `<img>` or a `<video>`.** `result.outputName` names the producing step; when that name is not literally `"output"`, `result` carries one extra key of that name holding the same object. Always read `result.output` and ignore the alias.
- The response carries no width, height, byte size or MIME type. If you need output dimensions, measure them after downloading.
- On `failed`, `error` is always the string `"Generation failed"`. A content-policy rejection, a bad input image and an upstream outage are indistinguishable. A failed job is never charged.
- `403` if the job is not yours; `404` if it never existed **or has been garbage-collected**.

**Two hard deadlines you must design around:**
1. The signed `url` is minted **once, when the output is stored** (shortly before `completed`), and expires 2 hours later. Re-polling returns the same, already-aging URL. There is no re-sign endpoint. Download as soon as you see `completed`.
2. The job record itself is deleted **24 h after success, 6 h after failure**. After that the id returns 404 forever.

**Fetch output URLs from your server, not the browser.** Media lives at `api.bijify.com/api/inference/images/...`, which is *outside* `/api/v1` and does not share its open CORS policy. Those responses are Origin-checked against a Bijify-only allowlist, so a browser `fetch()` or `<img crossorigin>` from your domain gets `403 Forbidden` as **plain text, not JSON**. Requests that send no `Origin` are fine - which means your backend works normally, and so does a plain `<img src>` or `<video src>` tag. Only scripted cross-origin reads are blocked. The same applies to the preview `url` returned by `/upload`.

**Polling.** Poll every 2 s for the first 30 s, then every 5 s, with a deadline of at least `4 x avgGenerationSeconds` and a floor of 5 minutes for every workflow - `bijify-video-pro` averages ~143 s, so a naive `120 x 1s` loop times out on a job that is about to succeed. Server-side, a stuck job is killed by a 90-minute watchdog and marked `failed` (so it is never charged), which is the real upper bound on job lifetime. A client-side timeout neither cancels the job nor changes what you are charged. Stop the loop immediately on 401/403/404.

### GET /inspiration
Gallery of example results, many of which are reusable **templates**.

Query parameters: `limit` (default 30, max 100), `offset`, `sourceType` (`image`|`video`), `seed`, `q`, `mode`, `jewelry`, `template`, `ids`.

- `seed` - a non-zero integer gives a deterministic shuffle; **omit it or pass 0 for plain newest-first order.** Pass the same seed on every page of a walk.
- `q` - free text matched against the template's mode, category, preset and custom-text **labels**, plus its aspect ratio. Up to 8 space-separated terms, all must match. Supplying `q` disables the shuffle and orders by recency.
- `mode` - exact mode id.
- `jewelry` - matches the Subject preset label: `ring`, `earrings`, `necklace`, `bracelet`.
- `template=true` - returns only entries with reusable UI state, including both curated templates and templates promoted from generations.
- `ids` - comma-separated inspiration UUIDs, at most 100. This performs a direct lookup, preserves the requested order, ignores pagination and returns `hasMore: false`. `sourceType` is ignored on this path; `q`, `mode`, `jewelry` and `template=true` still filter the looked-up set and may remove requested entries. Use it for pinned storefront template sets, normally by itself or with `template=true`.

```json
{
  "images": [
    { "id": "7c29b6b2-...", "url": "https://api.bijify.com/api/inference/images/templates/...png?token=...",
      "filename": "template.png", "width": null, "height": null,
      "metadata": { "tags": ["workflow/d66bfe287849", "_ui_state:<base64>"] },
      "ui_state": { "mode": "d66bfe287849", "category_states": {}, "aspect_ratio": "1:1" },
      "createdAt": "...", "sourceType": "image", "is_favorite": false }
  ],
  "total": 150,
  "hasMore": true
}
```
- Only the **1000 most recent** entries are browsed or searched; `offset` beyond that returns nothing. Direct `ids` lookup is not subject to this window.
- Filtering happens first, then `offset`/`limit` slice the filtered set - so paging is consistent under a filter, and you advance by `limit`.
- `total` is the count **after filtering**, capped at 1000 - not the size of the gallery.
- `width`/`height` are always `null`. `sourceType` is derived from the stored file type and may be `"unknown"` when that is missing; treat `"unknown"` as "sniff the URL extension" rather than assuming an image. URLs are re-signed on every request (unlike job outputs), so this endpoint is safe to page through lazily.
- `is_favorite` reflects an internal curation flag and is not meaningful to API consumers; there is no way to set it.

**Templates.** Reusable state is returned as the decoded `ui_state` object. An item is a template when `ui_state` is non-null. The legacy `_ui_state:` metadata tag remains for backwards compatibility, but new integrations should not decode it. The rest of `tags` varies by origin and is not worth branching on: templates promoted from an image generation also carry `"workflow/<modeId>"` (the prefix reads `workflow/` but the value is a **mode** id), video entries carry the literal `"workflow/video"`, and admin-curated templates carry no such tag at all.

Legacy clients may decode `_ui_state:` as **standard padded base64 of UTF-8 JSON** (never URL-safe - the server encodes with `btoa`):

```js
const json = new TextDecoder().decode(Uint8Array.from(atob(b64), c => c.charCodeAt(0)));
```
Plain `atob(...)` + `JSON.parse` corrupts any accented character in a `custom_value`.

An image template decodes to `{ mode, category_states, aspect_ratio }`. Merge all three into your `/generate` body and add `workflow_id` plus the four `/upload` fields; nothing needs removing. Note `_ui_state` does **not** record which workflow produced it - that choice is yours.

**Video entries also carry a `_ui_state:` tag**, but it holds only `{ aspect_ratio, workflow_id, duration }` - no prompt, no `category_states`. Check for `category_states` before treating an entry as a reusable template, or you will render video rows as broken image templates. (Its `aspect_ratio` is a leftover: do not replay it, since `/video-generate` does not honor the field.)

Admin-curated templates additionally carry `metadata.type === "template"`. Filtering on that field alone matches only the curated subset and **misses every template promoted from a real generation**. Use `template=true` for server-side filtering or test `ui_state` client-side.

---

## Evaluating `rules`

`/schema` returns `rules` verbatim. They are what make the picker feel intelligent - choosing Earrings hides the ring-specific Pose category, and so on. On-Body Women's has 12 rules (6 using operator nodes); Still-Life has 26.

```
Rule  := { comment: string, condition: Node, exclude_categories: string[] }
Node  := Leaf | Op
Leaf  := { category: string, presets: string[], match: "positive" | "negative" }
Op    := { op: "AND" | "OR", left: Node, right: Node }
```

**`Op` is strictly binary** - `left` and `right`, not an array of children. Nesting goes several levels deep (`AND(AND(leaf,leaf),leaf)` occurs in production).

Evaluation:
1. A leaf reads `states[category].selected_preset` - **always the preset, even when `custom` is true.**
2. If that category has no state at all, the leaf is **`false`, for both `match` values.** A `negative` leaf on an omitted category does not fire.
3. `positive` = the selected preset is in `presets`; `negative` = it is not. Multiple `presets` are an OR.
4. Union the `exclude_categories` of every rule whose condition is true. Rules are unordered with no precedence; `exclude_categories` is the only action.
5. **Evaluation is a single pass.** An excluded category keeps its state and still participates in every other rule's condition. Exclusion never cascades, and you must not iterate to a fixpoint.

Two guarantees that let your evaluation match the server's exactly:
- **Rule conditions only ever reference categories that `/schema` returns, plus `__workflow__`.** No condition depends on an internal category you cannot see. (`exclude_categories` *may* name internal ids - just ignore the ones you do not recognize.)
- **Every category `/schema` returns has at least one preset**, so `category.presets[0].id` is always safe. Categories with no presets exist but are internal and never returned.

**The one thing you cannot discover from `/schema`:** before evaluating, the server injects a virtual category with the literal id `__workflow__`, whose `selected_preset` is your `workflow_id`. Every mode ships rules keyed on it:

```json
{ "comment": "HD workflows exclude Size, Skin Realism, Beauty, Quality",
  "condition": { "category": "__workflow__",
                 "presets": ["bijify-image-pro", "bijify-image-pro-2"], "match": "positive" },
  "exclude_categories": ["4a231a80790d", "ed8a66a5283c", "d7b5a4053d19", "7bf37f9e2904"] }
```
Inject it yourself so your evaluation matches the server's, and so `__workflow__` does not look like a dangling reference - no such category is listed in `/schema`. In practice today every category these workflow rules exclude is internal, so omitting the injection will not visibly change your picker; do it anyway, because the rule data is server-controlled and can start naming visible categories at any time.

The whole evaluator:

```js
function rule_fires(node, states) {
  if (node.op) {
    const l = rule_fires(node.left, states), r = rule_fires(node.right, states);
    return node.op === 'AND' ? (l && r) : (l || r);
  }
  const state = states[node.category];
  if (!state) return false;                   // missing => false for BOTH match values
  const hit = node.presets.includes(state.selected_preset);
  return node.match === 'negative' ? !hit : hit;
}

function excluded_categories(rules, states, workflow_id) {
  const s = { ...states, __workflow__: { selected_preset: workflow_id, custom: false, custom_value: '' } };
  const out = new Set();
  for (const rule of rules || []) {
    if (rule_fires(rule.condition, s)) rule.exclude_categories.forEach(c => out.add(c));
  }
  return out;
}
```

**The client/server contract:** the server re-evaluates these same rules when it builds the prompt and drops excluded categories, so *sending* an excluded category is harmless. Rules are therefore presentational - but ignoring them gives your shoppers controls that visibly do nothing, which is exactly what bijify.com avoids. Re-evaluate on every change.

## Preset and mode thumbnails

Every **mode** and every **preset** id maps to a public static image on the Bijify frontend. This is what turns a wall of ~450 text labels into a visual picker.

```
mode tile:    https://bijify.com/presets/{modeId}.webp?v=5
preset tile:  https://bijify.com/presets/{modeId}/{categoryId}/{presetId}.webp?v=5
```

**There are no category tiles** - do not construct `/presets/{modeId}/{categoryId}.webp`, it does not exist.

Tiles are 256x256 WebP, unauthenticated, no CORS restriction, `max-age=14400, must-revalidate`. Coverage is **complete**: every preset id returned by `/schema`, in every mode, has a tile (2315 preset tiles + 6 mode tiles today), so you can build the URL directly from ids with no fallback logic - though an `onerror` placeholder is still wise.

`?v=` is a cache-buster, bumped when artwork is replaced at an existing id. It is `5` today. Omit it and browsers may serve a stale tile indefinitely. There is no endpoint that reports the current value; if you cache tiles aggressively, re-check this document after a Bijify release.

## Video prompts

`/video-generate` takes a freeform `prompt` that **you** author. Describe camera motion and lighting: `"slow cinematic orbit around the ring, soft studio light"`.

Bijify's own web app does not ask its users for a prompt - it derives one automatically from the uploaded image with a vision model, triggered by `source=video` on upload. **That auto-prompt is not retrievable with an API key**: the endpoint serving it requires a dashboard session and is not exposed under `/api/v1`. Sending `source=video` therefore triggers server-side work you can never read. Write your own prompts.

## aspect_ratio

Optional on `/generate`, default `"1:1"`. **Not reliably honored on `/video-generate`** - crop the input image instead (see above).

It is passed straight through to the underlying model and **not validated**, so an unsupported value does not return 400 - the job is accepted and comes back `failed` (uncharged).

Safe values across the image workflows: `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`. These are exactly what bijify.com offers. Some image workflows ignore the field entirely and match the input image's shape.

## What you must build yourself

Not available through v1 today. Plan for these up front:

- **No history / list-my-jobs endpoint.** Persist your own records: `job.id`, `storage_key`, `file_name`/`file_size`/`file_type`, `mode`, `category_states`, `aspect_ratio`, `workflow_id`.
- **No way to re-sign an expired output URL.** Download the bytes within 2 h of completion and re-host them. After 24 h the job record is gone too.
- **No credit balance endpoint.** You discover exhaustion as a 402 at submit time.
- **No webhooks or callbacks.** Every job outcome must be polled; budget for holding those loops.
- **No prompt inspection.** The generated prompt is never returned - that is deliberate, it is the product's IP.
- **No favorites, template creation, or key-usage statistics.**
- **No job cancellation**, and no way to re-preview an expired `/upload` URL other than re-uploading the identical bytes (which dedups for free and returns a fresh signed URL).
- **No idempotency key.** If a `/generate` POST times out at the network layer you cannot tell whether it was charged, and you cannot look it up. Log the request before you send it.

---

## Full image example (server-side)

```js
const API = "https://api.bijify.com/api/v1";
const H   = { "Authorization": "Bearer " + process.env.BIJIFY_KEY };

async function call(path, init) {
  const r = await fetch(API + path, { ...init, headers: { ...H, ...(init?.headers || {}) } });
  const body = await r.text();
  let data; try { data = JSON.parse(body); } catch { data = { error: body.slice(0, 200) }; }
  if (!r.ok) { const e = new Error(data.error || "HTTP " + r.status); e.status = r.status; throw e; }
  return data;
}

// 1. Workflow, mode, schema
const { workflows } = await call("/workflows");
const wf = workflows.find(w => w.category === "image");            // e.g. bijify-image-lite
const { modes } = await call("/modes");
const mode = modes.find(m => !m.restricted).id;
const schema = await call("/schema?mode=" + encodeURIComponent(mode));

// 2. A state for EVERY category, then grey out the ones the rules exclude
const category_states = {};
for (const c of schema.categories) {
  category_states[c.id] = {
    selected_preset: c.default || c.presets[0].id,
    custom: false,
    custom_value: ""
  };
}
const hidden = excluded_categories(schema.rules, category_states, wf.id);  // see Rules section
// `hidden` is what you hide in your UI. Sending those categories anyway is harmless.

// 3. Upload the bytes (from disk, or fetched server-side)
const form = new FormData();
form.append("image", new Blob([bytes], { type: "image/jpeg" }), "ring.jpg");
const up = await call("/upload", { method: "POST", body: form });

// 4. Start the job
const job = await call("/generate", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    workflow_id: wf.id,
    storage_key: up.storage_key,
    file_name:   up.file_name,
    file_size:   up.file_size,
    file_type:   up.file_type,
    aspect_ratio: "1:1",
    mode,
    category_states
  })
});

// 5. Poll to a deadline, then download IMMEDIATELY
const budget   = Math.max(300, (wf.health?.avgGenerationSeconds ?? 60) * 4) * 1000;
const deadline = Date.now() + budget;
const started  = Date.now();
let out = null;
while (Date.now() < deadline) {
  await new Promise(r => setTimeout(r, Date.now() - started < 30000 ? 2000 : 5000));
  let s;
  try {
    s = await call("/jobs/" + job.id);
  } catch (e) {
    if ([401, 403, 404].includes(e.status)) throw e;    // fatal, stop polling
    continue;                                            // transient, retry
  }
  if (s.status === "completed") { out = s.result.output; break; }
  if (s.status === "failed")    throw new Error("generation failed - not charged");
}
if (!out) throw new Error("timed out; the job may still succeed - look it up within 24h");

// out.type is "image" or "video". This URL dies 2h after completion, and it is
// Origin-checked - this fetch works because it runs on your server and sends no
// Origin. The same fetch from browser JS would return 403 plain text.
const bytes_out = Buffer.from(await (await fetch(out.url)).arrayBuffer());
await save_to_my_own_storage(bytes_out, out.type);
```

For video, skip step 2, use a `video` workflow, and POST `/video-generate` with `prompt` and `duration` (5 or 10).

## Quick reference

| Method | Path | Purpose |
|---|---|---|
| POST | `/upload` | upload image (multipart field `image`) -> `storage_key` (+ file_name/size/type) |
| GET  | `/workflows` | list pipelines; `category` picks the endpoint, `credits` is the cost |
| GET  | `/modes` | image style modes (API keys see all, incl. `restricted: true` ones) |
| GET  | `/schema?mode=ID` | categories + presets + rules |
| POST | `/generate` | start image job (`mode` + object `category_states`) -> job `id` |
| POST | `/video-generate` | start video job (freeform `prompt`, `duration` 5 or 10) -> job `id` |
| GET  | `/jobs/ID` | poll status; output at `result.output.url`, expires 2 h after completion |
| GET  | `/inspiration` | example gallery + decoded `ui_state`; supports `ids` and template filtering |

**The five that bite hardest:** send a state for *every* category; inject `__workflow__` when evaluating rules; download outputs **server-side** within 2 h (browser `fetch()` of an output URL is Origin-blocked); expect 402 when credits run out; and do not count on `aspect_ratio` for video.
