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. 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: 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, always {"error": "<string>"}. Switch on the status code, never on the message.
Every error the API returns is one of two kinds. The first is a validation message: a 4xx whose text describes something in your own request, written to be shown as-is. These are the only messages that vary, and the complete list is:
400 storage_keys must be a non-empty array (use /upload first)/storage_keys accepts at most 14 images/storage_keys must contain only non-empty strings400 This mode expects N image(s), got M400 Prompt references @imgN but M image(s) were supplied400 Image N of this mode does not accept a template reference/Image N: invalid template reference/Image N: template not found400 Missing required fields: ...(either endpoint),400 Expected application/json,400 Prompt cannot be empty,400 Video generation does not accept template references400 Missing mode parameter,400 Invalid mode: <id>(/schema),400 ids must contain at most 100 valid UUIDs(/inspiration)/upload:400 No image file provided,400 Invalid file type. Allowed: JPEG, PNG, WebP,400 Invalid image file. ...,400 File too large. Maximum size is NMB.,429 Daily upload quota exceeded
The second kind is a fixed string by status, used for everything else - including every failure that happens once a request is inside the generation pipeline:
| Status | Body | Meaning |
|---|---|---|
| 400 / other 4xx | {"error":"Request failed"} |
a bad request the validation list does not name (an invalid mode, a malformed category_states value) |
| 401 / 403 | {"error":"Authentication failed"} |
bad key, or a job that is not yours |
| 402 | {"error":"Insufficient credits"} |
top up before retrying |
| 404 | {"error":"Not found"} |
unknown job, or unknown workflow_id |
| 429 | {"error":"Rate limit exceeded"} |
slow down and retry after a pause |
| 5xx | {"error":"Service temporarily unavailable"} |
server error |
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.
Retry rules
| Request | Safe automatic retry? | Rule |
|---|---|---|
Discovery GETs (/workflows, /modes, /schema, /inspiration) |
Yes | Retry transient 429/5xx/network failures with exponential backoff and jitter. Do not retry 400/401/403. |
Job polling (/jobs/{id}) |
Yes, while within your deadline | Retry transient 429/5xx/network failures. Stop immediately on 401/403/404 or a terminal completed/failed status. |
Upload (/upload) |
Only when you still own the exact source bytes | Re-uploading identical bytes deduplicates, but a lost response still costs a rate-limit slot; back off before retrying 429/5xx/network failures. |
Job creation (/generate, /video-generate) |
No blind retry | There is no idempotency key. If the connection fails after submission, the job may already exist and may still complete; persist the attempt before sending and resolve ambiguous outcomes operationally rather than immediately submitting a duplicate. |
Never retry authentication failures or deterministic validation errors without changing the request. The API currently sends no Retry-After header, so choose your own bounded exponential-backoff policy.
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) withstorage_keys: [storage_key]-> 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.
Multiple input images. storage_keys is an ordered array. How many a job takes is decided by the mode: /schema returns an images list of labeled slots for multi-image modes, and a mode without one takes exactly one image. Sending a different count is a 400 before anything is charged. Each slot is either an upload (storage_key from /upload) or, when the slot's source is "template", an inspiration template's own image written as "inspiration:<template id>". The first entry is the primary image. Inside prompt text, @img1, @img2, ... name the slots in order; the server expands them to the positional wording the model understands, so a custom_value of "the ring from @img1" works. A reference to a slot you did not send is a 400. Video workflows take uploads only and use the first entry.
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 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 d66bfe287849 |
ef7618d5524d |
981531a8cea2 |
4c8d3f9d5b1a |
6087c804fac1 |
3df16635e183 |
On-Body Men c198e0562b17 |
848d55bb932d |
ee542d80d9ea |
- | - | - |
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)
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.
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; 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" },
{ "id": "d3766436ba3f", "label": "Still-Life" }
]
}
Use the public modes listed in the Subject table above and read their current labels from this endpoint.
GET /schema?mode={modeId}
Categories and presets for a mode. On-Body Women 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",
"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": [ ... ],
"images": [ { "label": "Jewelry render", "source": "upload" },
{ "label": "Template photo", "source": "template" } ]
}
imagesis present only on modes that take more than one input image: one entry per slot, in order, with a label for your UI. Send exactly that manystorage_keys, in that order. A mode withoutimagestakes exactly one.sourcesays how a slot is filled."upload": astorage_keyfrom/upload."template": the image of an inspiration template, sent as"inspiration:<template id>"(theidfrom/inspiration) - no upload needed for that slot. A template reference anywhere else, including on modes withoutimages, is a 400.- 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_keys": ["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_keys (a non-empty array whose length matches the mode's images count, or 1), 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. file_* describe the primary (first) image. The singular storage_key string is still accepted on this endpoint as a one-image alias for integrations written before storage_keys existed; new code should send storage_keys. 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.
Entries of storage_keys are neither ownership-checked nor existence-checked. Treat each as a private, opaque identifier: store it on your server, never expose it as an end-user-editable field, never accept one supplied by an untrusted client, and pass forward only the exact value your own /upload call returned. 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_keys": ["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_keys (a non-empty array; only the first entry is used), file_name, file_size, file_type. Optional: duration, default 5. This endpoint does not accept the singular storage_key alias.
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. bijify-video-pro follows the shape of your input image and may ignore the field; the legacy video workflows honor it. Crop your input image to the shape you want the video to be, and if you do send the field, send 16:9, 9:16 or 1:1.
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.
Output URLs are readable from the browser. Media lives at api.bijify.com/api/inference/images/..., which is outside /api/v1 but is authorized the same way it is addressed: by the signature in the URL, not by Origin. It answers Access-Control-Allow-Origin: *, so a scripted fetch() from your own domain can read the bytes and save them, and so can your backend, and so can a plain <img src> or <video src> tag. Send the fetch uncredentialed (credentials: "omit"): a wildcard origin and credentials do not combine. 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. Comma-separated ids match any of them (at most 16); omit the parameter for no mode filter.jewelry- matches the Subject preset label:ring,earrings,necklace,bracelet. Comma-separated labels match any of them (at most 16); omit the parameter for no jewelry filter.template=true- returns only entries with reusable UI state.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.- Ignore
is_favorite; it is not part of the reusable template contract.
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 or branch on the contents of metadata.tags.
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 }. Before replaying it, fetch the current /schema for its mode and reconcile the saved state:
- Copy every saved internal category (an id absent from the current visible schema) unchanged; those states may still be meaningful to the server.
- For each visible schema category, keep the saved state when it is an object and its
selected_presetstill exists in that category; preserve itscustomandcustom_valuefields. - If a visible category or preset is missing or stale, replace it with
{ selected_preset: category.default ?? category.presets[0].id, custom: false, custom_value: "" }. - Use the template's current
modeandaspect_ratio, then add your chosenworkflow_idplus the four fields returned by/upload.
This preserves server-authored internal state without allowing stale visible choices to change rule evaluation. _ui_state does not record which workflow produced an image template - 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.)
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 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=7
preset tile: https://bijify.com/presets/{modeId}/{categoryId}/{presetId}.webp?v=7
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 7 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".
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_keys,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.
Production integration checklist
- Keep the Bijify API key exclusively on your server and authenticate your own users before accepting work.
- Persist your local request record before submitting a generation, including the uploaded
storage_keys, selected workflow/settings and submission time. - Persist the returned job id immediately so polling can resume after a process restart or deployment.
- Run polling in recoverable background work rather than tying it to a shopper's open browser tab.
- Download every completed output immediately, verify the HTTP status and media type, and store it in storage you control.
- Serve the re-hosted asset from your own application; do not save the temporary Bijify signed URL as the permanent product image.
- Record terminal failures and ambiguous submissions so operators can reconcile them without blindly creating another paid job.
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 publicModeIds = new Set(["d66bfe287849", "c198e0562b17", "d3766436ba3f"]);
const mode = modes.find(m => publicModeIds.has(m.id)).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_keys: [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. The
// signature in the URL is the authorization, so the same fetch works from
// browser JS too, as long as it is sent with credentials: "omit".
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 storage_keys, 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 |
discover labels for the supported public image modes |
| GET | /schema?mode=ID |
categories + presets + rules |
| POST | /generate |
start image job (storage_keys[], mode + object category_states) -> job id |
| POST | /video-generate |
start video job (storage_keys[], 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 within 2 h (the signed URL is not re-issued); expect 402 when credits run out; and do not count on aspect_ratio for video.
