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/v1is not routed) - Auth: every endpoint requires
Authorization: Bearer ak_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX(ak_+ 32 hex chars) - Format: JSON in / JSON out, except
POST /upload(multipart). SendContent-Type: application/jsononPOST /generateandPOST /video-generate. - CORS:
Access-Control-Allow-Origin: *on every response. Preflight answers 204 withAllow-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 on402to 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
creditsper job you observecompleted; 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
- Upload your image bytes with
POST /upload-> get astorage_key(+ file metadata). - Pick a workflow from
GET /workflows. For images also pick a mode (GET /modes) and presets (GET /schema?mode=...). Video uses a freeform textpromptinstead. - Start the job:
POST /generate(image) orPOST /video-generate(video) -> get a jobid. - Poll
GET /jobs/{id}untilstatusiscompleted(orfailed); read the output atresult.output.urland 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.
categorydecides/generatevs/video-generate.creditsis the cost per run. Read it at runtime.descriptionmay benull; do not depend on it.healthis always present, buthealth.avgGenerationSecondsis omitted until the workflow has recent runs - defend against that, since it is what you size your polling deadline from.scoreis the recent success rate out of 100 andstatushas four values:green(>=90),yellow(75-89),orange(50-74),red(<50). Treatorangeandredas degraded. A workflow with no history reportsgreen/100- that is a cold-start default, not evidence.nameis 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.defaultis 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_statetemplate 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>"}. ruleshas 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: truewith your text incustom_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_presetis still what the rules evaluate against, even whencustomis true. Settingcustom: truewith an emptyselected_presetsilently flips rule outcomes and excludes the wrong categories. Always keep a valid preset id alongside your custom text.- Send a state for every category
/schemareturns. 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, getsundefined, and silently drops the category. No error. - A
selected_presetis 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/schemaand re-seed each stale visible category tocategory.default ?? category.presets[0].id; neither omit the category nor leave its preset empty, because both change rule evaluation. Templates supplied by/inspirationmay include internal category ids absent from/schema; preserve those unchanged. null, orcustom: truewithout acustom_valuestring, 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.
statusis exactly one ofaccepted,processing,completed,failed. Jobs cannot be cancelled.progressis 0-100, allocated across the pipeline's internal steps. It moves in coarse jumps and is not a time estimate - drive your ETA fromavgGenerationSecondsinstead.result.output.typeis"image"or"video"- use it to decide whether to render an<img>or a<video>.result.outputNamenames the producing step; when that name is not literally"output",resultcarries one extra key of that name holding the same object. Always readresult.outputand 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,erroris 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. 403if the job is not yours;404if it never existed or has been garbage-collected.
Two hard deadlines you must design around:
- The signed
urlis minted once, when the output is stored (shortly beforecompleted), and expires 2 hours later. Re-polling returns the same, already-aging URL. There is no re-sign endpoint. Download as soon as you seecompleted. - 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. Supplyingqdisables 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 returnshasMore: false.sourceTypeis ignored on this path;q,mode,jewelryandtemplate=truestill filter the looked-up set and may remove requested entries. Use it for pinned storefront template sets, normally by itself or withtemplate=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;
offsetbeyond that returns nothing. Directidslookup is not subject to this window. - Filtering happens first, then
offset/limitslice the filtered set - so paging is consistent under a filter, and you advance bylimit. totalis the count after filtering, capped at 1000 - not the size of the gallery.width/heightare alwaysnull.sourceTypeis 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_favoritereflects 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:
- A leaf reads
states[category].selected_preset- always the preset, even whencustomis true. - If that category has no state at all, the leaf is
false, for bothmatchvalues. Anegativeleaf on an omitted category does not fire. positive= the selected preset is inpresets;negative= it is not. Multiplepresetsare an OR.- Union the
exclude_categoriesof every rule whose condition is true. Rules are unordered with no precedence;exclude_categoriesis the only action. - 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
/schemareturns, plus__workflow__. No condition depends on an internal category you cannot see. (exclude_categoriesmay name internal ids - just ignore the ones you do not recognize.) - Every category
/schemareturns has at least one preset, socategory.presets[0].idis 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
/uploadURL other than re-uploading the identical bytes (which dedups for free and returns a fresh signed URL). - No idempotency key. If a
/generatePOST 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.
