Exercise the API from your browser

The interactive tester is a single dependency-free page that exercises every endpoint documented below. With your key in hand it is the fastest way to see real requests and real responses, and it doubles as a debugging tool when your own integration misbehaves.

  1. Paste your ak_... key into the box and press Load. It fetches your workflows, modes and every schema.
  2. Pick Image or Video, choose a mode, and adjust the preset thumbnails. Categories the mode's rules exclude grey out live, exactly as they do on Bijify.
  3. Choose a file, then press Generate. The page uploads, starts the job, polls it, and renders the result.
  4. Read the Log panel. Every request and every raw response body is printed with its status code, which is the real reference material.
  5. Use the Error probes to see what a malformed request actually returns, without guessing.

The tester keeps your key in the browser for convenience. That is fine for a demo, but never ship a browser-side key in production: it is a bearer credential that can spend your credits.

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: [email protected].


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):

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

{
  "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):

{
  "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:

{
  "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)

{
  "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}:

{ "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:

"<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.

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

{
  "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.
{
  "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):

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:

{ "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:

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)

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.

This page is generated from bijify-api-v1.md. Questions about API access? Write to [email protected].