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. Upload, discovery, generation and job polling use the same endpoints and response shapes documented here.

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

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

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

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

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


Authentication

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

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

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

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

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

Errors

Always JSON, 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 strings
  • 400 This mode expects N image(s), got M
  • 400 Prompt references @imgN but M image(s) were supplied
  • 400 Image N of this mode does not accept a template reference / Image N: invalid template reference / Image N: template not found
  • 400 Missing required fields: ... (either endpoint), 400 Expected application/json, 400 Prompt cannot be empty, 400 Video generation does not accept template references
  • 400 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 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) with storage_keys: [storage_key] -> 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.

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.

  • 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" },
    { "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" } ]
}
  • images is 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 many storage_keys, in that order. A mode without images takes exactly one.
  • source says how a slot is filled. "upload": a storage_key from /upload. "template": the image of an inspiration template, sent as "inspiration:<template id>" (the id from /inspiration) - no upload needed for that slot. A template reference anywhere else, including on modes without images, is a 400.
  • 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_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: 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_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.

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

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. Supplying q disables 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 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.
  • 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:

  1. Copy every saved internal category (an id absent from the current visible schema) unchanged; those states may still be meaningful to the server.
  2. For each visible schema category, keep the saved state when it is an object and its selected_preset still exists in that category; preserve its custom and custom_value fields.
  3. 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: "" }.
  4. Use the template's current mode and aspect_ratio, then add your chosen workflow_id plus 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:

  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=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 /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.

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.

This page is generated from bijify-api-v1.md. Questions about API access? Write to info@bijify.com.