Integrating nyris? It takes two steps: import your product data into the nyris index (Product Data & Images), then integrate nyris into your website or app (Integration (for humans)). No data in nyris yet? Ask support@nyris.io for an API key to a demo index and start with the frontend.
For AI coding agents. Use the Agent Guide section, or download it as Markdown. That file is the spec for endpoints, image handling, and request formats. Do not invent endpoints from the rest of this page.
Search UI assets. Icons for camera, drop zone, upload, crop, search, and the controls around results are free to reuse. They are one color (currentColor) on a 24 × 24 canvas. Download the asset pack, or use a single file from search-ui/.
Welcome to nyris' API Documentation. nyris is a high performance visual product search, object detection and visual recommendations engine for retail and industry. This documentation covers our visual product search engine and the visual recommendations engine.
The implementation in your website or app comprises three steps.
1. Submit your product & image data
In order to recognize your products, we need to know your products. The product feed section covers all details how to submit your product data to us and the specifications for your product or object images.
2. Implement nyris API in your app, website or messenger bot
The nyris engine can be integrated in your app, website, messenger bots, BI solutions - wherever you want to make your customers happy or optimize your data management process. We provide you with code examples on how to query our Find API and Regions API and have ready to go SDKs for iOS and Android.
3. Implement automated feedback in your app, website or messenger bot
By tying user interactions to automated feedback in our Feedback API, you allow for automatic statistic generation and continuous improvement of the
search results.
To get started, email us at mailto:support@nyris.io. Our team will provision API keys to access our API. Always feel free to contact us should you have questions or suggestions for our service or this documentation. In addition, you can always find our open source code github.com/nyris.
Try it with Postman — You can explore and test the nyris API directly using our public Postman workspace. It includes ready-to-use collections for Find, Recommend, Regions, and Images APIs.
Thank You & happy coding!
Markus (CTO / Founder)
Download this guide as Markdown. The chapters below are the same guide, formatted for this site.
You are implementing visual search against the nyris Find API. Follow this file. Do not invent endpoints. Do not call the Data Management API (/manage/v1) unless the user explicitly asked to edit the catalog.
Scope: adding nyris takes two steps, and this file covers only the second.
The steps are independent. Do not stop if the user's data is not in nyris yet. Build against a demo index instead: the user can ask support@nyris.io for an API key to a demo index with sample products. Keep the key in configuration so it can be swapped for the production key later. Tell the user that step 1 is still open.
Reference implementation: the nyris example web app. Where this file says "the example web app" or "the example client", it means that app.
The human version of this guide is Integration (for humans). This file is enough on its own.
Ask before coding if any of these are missing:
| Input | Required | Default |
|---|---|---|
| Find API key | yes | environment variable NYRIS_API_KEY |
| Base URL | no | https://api.nyris.io |
| Target | yes | web, iOS, Android, React Native, Flutter, or backend |
| Text search | no | off |
| Filters | no | none |
| Existing text search engine and results page (Algolia, Elasticsearch, ...) | no | none. If there is one, follow Existing text search instead of building a separate results page. |
Never hard-code the key. Never print it. Never commit it.
The key is sent from the client as X-Api-Key. It must be a Find key. Refuse to embed a Data Management key in a client app.
| Name | Request |
|---|---|
| Find | POST {base}/find/v1.1 |
| Regions | POST {base}/find/v2/regions |
| Feedback | POST {base}/feedback/v1 |
| Filters | GET {base}/find/v1/filters?limit=100 |
| Recommend | GET {base}/recommend/v1/{sku} |
| Product images | GET {base}/find/v1.1/images/{sku} |
Filters: use /find/v1/filters. The response is a JSON array of { "key": "color", "values": ["Red", "Blue"] } (empty if the index has no filters). To search the values of one key, call GET {base}/find/v1/filters/{key}/{value}?limit=100, where {value} is the search term; it returns string[].
CAD search (POST {base}/cad/find/v0.2, multipart field file) is not part of the public API reference. Use it only if the user confirms that nyris enabled CAD search for their account.
Find and Regions accept the image in three formats. The results are the same.
| Format | Content-Type |
Body | Use when |
|---|---|---|---|
| Multipart (default) | multipart/form-data |
part image, optional text, optional filter fields |
You need text or filters. Browsers and mobile HTTP clients support it natively. |
| Binary | image/jpeg or image/png |
the raw image bytes | Image only, no text or filters. Smallest request. |
| JSON (base64) | application/json |
{"image": "<base64>", "text": "...", "filters": [...]} |
The client or platform only speaks JSON (low-code tools, serverless functions, tool-calling backends, some SDK wrappers). |
JSON body schema:
| Field | Type | Required | Notes |
|---|---|---|---|
image |
string | yes, unless the search is text only | Plain base64 (/9j/4AAQ...) or a data URI (data:image/jpeg;base64,/9j/...). |
text |
string | no | Max 512 characters. |
filters |
array | no | [{"key": "MachineSerial", "values": ["SN-40821"]}]. Same AND/OR rules as multipart. |
Regions JSON takes only image.
Base64 makes the request about 33% larger. The 2 MB limit applies to the decoded image, not the encoded body. Still resize before encoding exactly as in step 4. Default to multipart. Use JSON when the user asks for it or the platform cannot send multipart or binary bodies.
Do the base64 encoding in code. If you are an agent that can call HTTP tools, never put image base64 into your own output or tool arguments by hand. It wastes tokens and gets corrupted.
Implement in this order.
rgb(255,255,255)). Keep this normalized original at full resolution in memory until the user removes the image. Show a screen-sized preview derived from it. Never crop the preview or the step 4 JPEG; crops come from this original only.createImageBitmap, or heic-to / libheif, then continue as a bitmap..stp, .step, .stl, .obj, .glb, .gltf: do not send them to /find/v1.1. If CAD search is enabled for the account, POST the original file to /cad/find/v0.2 as field file. Otherwise treat it as unsupported.1024 * 1024 (the example client always does this, including small sources). Encode JPEG at quality 0.9. Target size is about 100 kB; keep it at most 1 MB for latency. If the blob is still above ~200 kB, re-encode at 0.8. The API rejects images over 2 MB (2,097,152 bytes) with 413.POST /find/v2/regions with Content-Type: image/jpeg and the raw JPEG as the body. Multipart (part image) and JSON base64 also work; see Request body formats. Map pixel boxes to normalized 0..1 coordinates. If the list is empty, the crop is the full frame. If Regions fails, continue with the full frame; never block Find on Regions. As soon as Regions returns, auto-select the best region, mark its marker active, and run step 6 with that crop without waiting for a click. Draw the overlay as specified in Region overlay.POST /find/v1.1 as multipart/form-data (or binary / JSON base64, see Request body formats). Field image is the JPEG. If there is a selected region that is not the full frame, crop the normalized original from step 2 (not the step 4 JPEG), then resize per step 4, then send that JPEG. Header Accept: application/items.complete+json for product fields; without it you get the minimal { sku, score } format. Header X-Api-Key.text (or JSON field text). Keep it under 512 characters. Do not put text in the query string; a query-string text returns results: []. Exception: with an existing text search engine that handles the keyword, do not send text to nyris (see Existing text search).results[]. Identity is sku, else key, else objectID. Show a skeleton list while the request is in flight. On a new search, clear the previous list. HTTP 200 with an empty list is an empty state, not an error. Keep the order of results[]; it is the visual similarity order. Do not show score (or a percentage) on result cards. It is an internal ranking signal and misleads users. If the site already has a text search results page, render there; see Existing text search.session, send it as header X-Session (or query ?session=) on follow-up searches, for example a crop search after a full-frame search. Only send ids the API returned; an invented value returns HTTP 400.POST /feedback/v1 with Content-Type: application/json. Required: request_id (Find id), timestamp (ISO 8601 UTC), event (click, conversion, feedback, or region). Optional: session_id (Find session) and an event-specific data object. See Feedback event. The ids are also in the response headers X-Matching-Request and X-Session, but browsers may not expose those headers, so read the body.Without an existing text search engine, you may start Find (full frame) and Regions in parallel after step 4. When the user changes the box, run step 6 again with the crop. Cancel the in-flight Find first.
Feedback event
{
"request_id": "<id from find>",
"session_id": "<session from find>",
"timestamp": "2026-09-29T12:00:00.000Z",
"event": "click",
"data": { "positions": [0], "product_ids": ["ABC-100"] }
}
data by event:
event |
data |
|---|---|
click |
positions (zero-based) and/or product_ids |
conversion |
optional: subtype (add_to_cart, purchase, support_request), product_ids, positions, quantity, revenue { amount, currency, order_id } |
feedback |
success (boolean), optional comment |
region |
rect { x, y, w, h }, each 0..1 |
A valid event returns HTTP 2xx.
aspect = width / height
targetArea = 1024 * 1024
outWidth = sqrt(targetArea * aspect)
outHeight = outWidth / aspect
Round to integers. Crop in normalized coordinates before this resize:
sx = originalWidth * x1
sy = originalHeight * y1
sw = originalWidth * (x2 - x1)
sh = originalHeight * (y2 - y1)
x1,y1 is top-left, x2,y2 is bottom-right, each in 0..1.
Regions come back in pixels of the JPEG you posted:
x1 = left / sentWidth
x2 = right / sentWidth
y1 = top / sentHeight
y2 = bottom / sentHeight
Do not draw every detected rectangle at once. Overlapping boxes make the photo unreadable. A row of "Object 1 / Object 2" buttons under the image is useless: those labels have no spatial link to the photo.
Required UI:
((x1+x2)/2, (y1+y2)/2).If the site already has a search results page backed by a text search engine (Algolia, Elasticsearch, OpenSearch, Typesense, ...), do not build a second results page. Full guide: Combine with your text search. Rules:
X-Options: limit=100. Do not run a parallel full-frame Find. Treat nyris plus the engine query as one search for stale-response handling.AND them, never replace them). Fetch all of them in one page with facet counts. Then sort the records by nyris position in your own code. Engine order is never trusted. Paginate in the client.text to nyris), or send it to nyris as text. Either way keep the nyris order. Do not merge a separate full-catalog text search.results list, not the filtered page position. Always include product_ids.For a demo or test site, match the layout and density of test.nyris.app: white header, light gray page (#fafafa), Source Sans 3, compact drop zone. Palette is white, black, and one or two neutrals (borders, muted text). Buttons may be black-on-white or the inverse. Do not use decorative colored page backgrounds.
POST /find/v1.1 HTTP/1.1
Host: api.nyris.io
X-Api-Key: $NYRIS_API_KEY
Accept: application/items.complete+json
Content-Type: multipart/form-data; boundary=----nyris
------nyris
Content-Disposition: form-data; name="image"; filename="query.jpg"
Content-Type: image/jpeg
<jpeg bytes>
------nyris
Content-Disposition: form-data; name="text"
optional query
------nyris--
Filters, when used (AND across keys, OR across values):
filters[0].filterType = category
filters[0].filterValues[0] = pumps
Do not set Content-Type: multipart/form-data manually if the HTTP client generates the boundary (browser FormData, OkHttp MultipartBody). Set it only when you build the body yourself (curl, raw sockets).
Same request as JSON (base64)
POST /find/v1.1 HTTP/1.1
Host: api.nyris.io
X-Api-Key: $NYRIS_API_KEY
Accept: application/items.complete+json
Content-Type: application/json
{
"image": "/9j/4AAQSkZJRg...",
"text": "optional query",
"filters": [{ "key": "category", "values": ["pumps"] }]
}
Encode the resized JPEG bytes, not the original file. Omit text and filters when unused.
Same request as binary (image only)
POST /find/v1.1 HTTP/1.1
Host: api.nyris.io
X-Api-Key: $NYRIS_API_KEY
Accept: application/items.complete+json
Content-Type: image/jpeg
<jpeg bytes>
Example success body (items.complete)
{
"results": [
{
"sku": "ABC-100",
"score": 0.91,
"title": "Example",
"images": ["https://img.nyris.io/..."]
}
],
"id": "request-id",
"session": "session-id"
}
Product fields besides sku and score depend on the index. Do not require a field the index does not have. For a thumbnail, use image if present, else images[0]. session may be missing; treat it as optional.
POST /find/v2/regions HTTP/1.1
X-Api-Key: $NYRIS_API_KEY
Content-Type: image/jpeg
<jpeg bytes>
JSON alternative: Content-Type: application/json with body {"image": "<base64>"}.
{
"regions": [
{
"confidence": 0.9,
"region": { "left": 10, "top": 20, "right": 200, "bottom": 300 }
}
]
}
Coordinates are floats. Ignore fields not shown here.
| Status | Action |
|---|---|
200, empty results |
Empty state. Do not retry. |
| 400 | Malformed request (bad JSON or base64, invented X-Session). Do not retry; fix the request. |
| 401 | Key missing or wrong type. Stop. |
| 413 | Image over 2 MB. Lower quality or size, retry once. |
| 415 | Unsupported format reached the API (for example HEIC or TIFF). Fix step 3 so everything is converted to JPEG. Do not retry. |
| 429, 5xx, network | Retry at most twice with backoff, unless a newer search replaced this one. |
If the key is missing locally, do not call the API. Show a configuration error.
TypeScript (browser)
const body = new FormData();
body.append("image", jpegBlob, "query.jpg");
if (text) body.append("text", text);
const response = await fetch(`${baseUrl}/find/v1.1`, {
method: "POST",
headers: {
"X-Api-Key": apiKey,
Accept: "application/items.complete+json",
},
body,
signal,
});
JSON variant, Node.js only (Buffer does not exist in browsers or most edge runtimes):
const image = Buffer.from(jpegBytes).toString("base64");
const response = await fetch(`${baseUrl}/find/v1.1`, {
method: "POST",
headers: {
"X-Api-Key": apiKey,
Accept: "application/items.complete+json",
"Content-Type": "application/json",
},
body: JSON.stringify(text ? { image, text } : { image }),
});
In the browser, FileReader.readAsDataURL(jpegBlob) gives a data URI that you can send as image unchanged.
Python
Build the multipart body yourself or use requests / httpx files={"image": ("query.jpg", jpeg_bytes, "image/jpeg")} and data={"text": text}. Header X-Api-Key. Do not pass text as a URL query parameter.
JSON variant:
payload = {"image": base64.b64encode(jpeg_bytes).decode("ascii")}
if text:
payload["text"] = text
response = httpx.post(f"{base_url}/find/v1.1", json=payload, headers=headers)
Swift
Draw UIImage into an opaque renderer filled white, jpegData(compressionQuality: 0.9), upload with URLSession multipart part name image.
Kotlin
BitmapFactory with inSampleSize, EXIF rotate, draw onto a white Canvas, compress(JPEG, 90), OkHttp part name image.
| Case | Required behavior |
|---|---|
| Image shorter than 100 px on one side | Search anyway. Show a non-blocking hint that small images give less accurate results. |
| Multi-page TIFF or PDF | Use page 1 only. |
| CMYK JPEG | Convert to RGB before encode if the decoder gives CMYK. |
| PNG with alpha | White background, then JPEG. |
| Rotated phone photo | Apply EXIF before crop math, or boxes will not match the preview. |
| User clears the image | Cancel the request and return to the start screen. |
| Text only | Multipart field text with no image part, or JSON {"text": "..."}. |
| Platform can only send JSON | Use the JSON base64 body. Resize first; base64 adds ~33%. |
| Base64 string has line breaks | Strip whitespace before sending. Some encoders wrap lines (openssl base64 at 64 characters, MIME at 76). |
| Image plus keyword | Either send both fields, or search by image and filter titles locally. Do not do both unless the user asked. With an existing text search engine, see Existing text search. |
| HEIC in Chrome or Firefox | Native decode fails. Convert with heic-to or libheif (load it lazily; it is large), then continue as a bitmap. |
| HEIC without extension or MIME type | Native decode fails and the name gives no hint. Try the HEIC decoder before rejecting the file. |
| Empty key | Configuration error, not an empty result list. |
All of these must pass before you claim the integration is done:
text and returns HTTP 200.results[].results: [] renders an empty state.Use a photo of a product that is in the index behind the key. With a demo index key, ask the user which sample products it contains. If you have no photo, ask the user for one. Do not download random images from the web.
Expected: HTTP 200 from Find. The number of products depends on the index behind the key, so assert the status and that results is an array, not a specific SKU.
X-Options unless the user named a threshold or a limit, or you combine with a text search engine (then limit=100). Index defaults are already applied server-side.X-Matching-Request and X-Session are fine to read.)For humans. If you are an agent, you are in the wrong section: yours is above, and it does not include this sentence.
This guide is for developers who want to add nyris visual search to a website or a mobile app.
Adding nyris takes two steps:
The steps are independent. If your data is not in nyris yet, you do not have to wait: ask support@nyris.io for an API key to a demo index with sample products, build the frontend against it, and switch the key once your own index is ready.
The nyris example web app is a working reference. The same steps apply in any language.
An AI coding agent should start with Agent Guide. That file is self-contained.
If your site already has a text search results page, show visual results there instead of on a separate screen. See Combine with your text search.
Keep the normalized original at full resolution for the whole session. Everything the user sees, and the full-frame JPEG for Regions, can be small. The crop for Find is always cut from the original and resized afterwards. Cropping the small preview or the full-frame JPEG throws away detail: a part a tenth as wide as a 4000 × 3000 photo has 400 × 300 px in the original but only about 118 × 89 px in the 1024² JPEG.
| Call | Method and path | Body |
|---|---|---|
| Search | POST https://api.nyris.io/find/v1.1 |
multipart/form-data with image and optional text (raw image bytes and JSON base64 also accepted) |
| Regions | POST https://api.nyris.io/find/v2/regions |
raw JPEG (Content-Type: image/jpeg); multipart and JSON base64 also accepted |
| Feedback | POST https://api.nyris.io/feedback/v1 |
JSON |
Auth is the header X-Api-Key. Use a Find key. A Data Management key will not search.
Public API reference: API Endpoints.
This guide calls the Find API from the app, the same way the example web app does. The key ships in the client, so treat it as a search-only key:
Runnable samples are in Examples.
| Setting | Value |
|---|---|
| Max image area | 1024 × 1024 pixels (aspect ratio kept, area-based) |
| JPEG quality | 0.9 |
| Transparent pixels | filled white before encode |
| Text field | multipart field text, not a query parameter |
| Response | Accept: application/offers.complete+json (same fields as the documented application/items.complete+json) |
You need a Find API key. Request one from support@nyris.io. Put it in the environment. Do not commit it.
export NYRIS_API_KEY="your-find-key"
export NYRIS_BASE_URL="https://api.nyris.io"
Text goes in the multipart body, not the URL.
curl -sS -X POST "$NYRIS_BASE_URL/find/v1.1" \
-H "X-Api-Key: $NYRIS_API_KEY" \
-H "Accept: application/items.complete+json" \
-F "text=hydraulic pump"
The JSON body has results (products), id (this request), and session.
Resize first when you can. A phone photo of several megabytes is slower and can be rejected. The samples below send a file as-is so you can see the contract; production code should follow Image pipeline.
curl -sS -X POST "$NYRIS_BASE_URL/find/v1.1" \
-H "X-Api-Key: $NYRIS_API_KEY" \
-H "Accept: application/items.complete+json" \
-F "image=@query.jpg;type=image/jpeg"
async function findByText(apiKey, text) {
const body = new FormData();
body.append("text", text);
const response = await fetch("https://api.nyris.io/find/v1.1", {
method: "POST",
headers: {
"X-Api-Key": apiKey,
Accept: "application/items.complete+json",
},
body,
});
if (!response.ok) {
throw new Error(`Find failed: ${response.status}`);
}
return response.json();
}
Do not set Content-Type yourself when using FormData. The runtime must add the multipart boundary.
import os
import urllib.request
boundary = "----nyrisboundary"
text = "hydraulic pump"
payload = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="text"\r\n\r\n'
f"{text}\r\n"
f"--{boundary}--\r\n"
).encode()
request = urllib.request.Request(
os.environ.get("NYRIS_BASE_URL", "https://api.nyris.io") + "/find/v1.1",
data=payload,
method="POST",
headers={
"X-Api-Key": os.environ["NYRIS_API_KEY"],
"Accept": "application/items.complete+json",
"Content-Type": f"multipart/form-data; boundary={boundary}",
},
)
print(urllib.request.urlopen(request, timeout=60).read().decode()[:500])
Full scripts: examples/curl/find.sh, examples/python/find.py, examples/node/find.mjs.
A useful product flow is: normalize and resize the image, call regions, let the user adjust the box, then search again with the crop. That is Object detection plus Find API.
The Find API accepts JPEG, PNG, and WebP. Your app should turn every user file into one JPEG before upload. The example web app does this in @nyris/nyris-api (prepareImage) and in useImageSearch.
file → decode + EXIF rotate → optional format conversion → crop → resize → JPEG
Phone cameras store rotation in EXIF. If you ignore it, the search image is sideways and results get worse.
blueimp-load-image with orientation: true in @nyris/nyris-api), or draw through createImageBitmap and respect the orientation.UIImage already applies orientation when you draw it.ExifInterface.TAG_ORIENTATION and rotate the bitmap before you scale it.Fill transparent pixels with white before you encode JPEG. PNG and WebP transparency otherwise becomes black, and black backgrounds hurt matching. The example client does this:
fill the destination canvas with rgb(255, 255, 255)
draw the source image on top
encode JPEG
| Input | What to do |
|---|---|
| JPEG | Decode, apply EXIF, resize, re-encode. |
| PNG, WebP, GIF (first frame) | Decode, flatten on white, resize, JPEG. |
| HEIC / HEIF | Convert to a bitmap first. On the web, try createImageBitmap; if that fails, convert with heic-to (or libheif). Platform photo APIs already yield a bitmap. |
| TIFF | Decode the first page. If pixels are RGB (3 channels), add an opaque alpha, then JPEG. Most browsers cannot decode TIFF natively, so the web needs a TIFF decoder; without one, reject TIFF clearly and leave it out of the file picker. |
| AVIF, BMP | Decode natively where the platform supports it, then JPEG. |
| Rasterize page 1 (the example uses PDF.js at scale 2), then JPEG. | |
CAD (.stp, .step, .stl, .obj, .glb, .gltf) |
Do not rasterize for /find/v1.1. If nyris enabled CAD search for your account, POST the file to POST /cad/find/v0.2 as multipart field file. This endpoint is not in the public API reference; ask support@nyris.io. |
Try the native decoder first. Do not reject a file by its extension unless it is CAD; browsers increasingly decode HEIC and AVIF themselves. Skip or reject anything that still fails to decode, with a clear message.
The API accepts only JPEG, PNG, and WebP. Other formats come back as HTTP 415 (binary or multipart) or 400 "Invalid image format" (JSON base64). Base64 is only the transport; the image inside must still be one of those formats. Converting everything to JPEG on the client avoids all of this and gives the smallest upload.
Default used by the example web app:
maxWidth = 1024maxHeight = 1024jpegQuality = 0.9The scaler keeps the aspect ratio and targets a pixel area of maxWidth * maxHeight, not “longest edge ≤ 1024”. Extreme panoramas stay sharper this way.
targetArea = maxWidth * maxHeight
width = sqrt(targetArea * aspectRatio)
height = width / aspectRatio
aspectRatio = originalWidth / originalHeight
Examples with 1024 × 1024:
| Source aspect (w/h) | Output size |
|---|---|
| 1.0 (square) | 1024 × 1024 |
| 1.5 (3:2) | about 1254 × 836 |
| 0.75 (3:4 portrait) | about 886 × 1182 |
Round to integers. If you prefer a hard box, fit the longest edge inside 1024 instead (getThumbSizeLongestEdge in @nyris/nyris-api). Area-based sizing is what the example app sends.
The example client always scales to that area, so a very small image is enlarged. Enlarging does not add detail. If the source (or the crop) is under 100 px on its shortest edge, the search still runs, but results get worse. Show the user a hint such as "This image is small; results may be less accurate."
After decode, EXIF rotation, and flattening, keep that bitmap at full resolution for as long as the user works with this image. Derive everything else from it:
| Derived image | Size | Used for |
|---|---|---|
| Preview | small, sized for the screen | What the user sees, overlay markers |
| Full-frame JPEG | area resize to 1024 × 1024 | Regions, and Find when nothing is selected |
| Crop JPEG | crop of the original, then area resize | Find for the selected object |
Never crop the preview or the full-frame JPEG. A 4000 × 3000 photo resized to about 1182 × 887 keeps only about 9% of its pixels. An object 400 × 300 px in the original (a tenth of the width and height) would become a crop of roughly 118 × 89 px. nyris then sees an enlarged, blurry part instead of real detail.
Release the original when the user removes the image or picks a new one.
Regions and the user box use normalized coordinates in 0..1. Because they are relative, the same box applies to the preview, the full-frame JPEG, and the original:
x1, y1 = top-left
x2, y2 = bottom-right
Pixel crop on the original image:
sx = originalWidth * x1
sy = originalHeight * y1
sw = originalWidth * (x2 - x1)
sh = originalHeight * (y2 - y1)
Resize after the crop, using the crop’s aspect ratio, so the object fills the JPEG you upload.
| Recommended | Accepted | Rejected | |
|---|---|---|---|
| Shortest edge | 100 px or more | Smaller, but results deteriorate | Never rejected for small size |
| File size | 1 MB or less (lower latency) | Up to 2 MB (2,097,152 bytes) | Larger than 2 MB: HTTP 413 |
With a JSON base64 body, the 2 MB limit applies to the decoded image, not the encoded request.
Aim for about 100 kB after JPEG encode. Quality 0.9 at the sizes above usually lands there. If the blob is still large, lower quality toward 0.8 before you shrink the pixel size further.
original = flattenOnWhite(decode(file)) # apply EXIF; keep this for the session
preview = resizeForScreen(original) # display only
def searchJpeg(normalizedRect = None):
bitmap = crop(original, normalizedRect) if normalizedRect else original
bitmap = resizeArea(bitmap, 1024, 1024) # always the target area
return encodeJpeg(bitmap, quality=0.9)
Regions tell you which objects are in the photo so the user can search one of them instead of the whole frame. The example web app calls this before the product search when the user uploads an image.
Send the same resized JPEG you will use for search (or the full-frame JPEG before a user crop).
POST /find/v2/regions
X-Api-Key: <find-key>
Content-Type: image/jpeg
<raw jpeg bytes>
The example sends the raw image as the body. Two alternatives return the same result:
Content-Type: multipart/form-data with the image in a part named image.Content-Type: application/json with body {"image": "<base64 or data URI>"}.{
"regions": [
{
"confidence": 0.92,
"region": { "left": 120, "top": 40, "right": 640, "bottom": 700 }
}
]
}
left, top, right, bottom are floating-point pixels of the image you sent, not of the original camera file. The response can contain more fields; ignore them.
Store boxes in 0..1 so they survive another resize and map onto the preview:
x1 = left / sentWidth
x2 = right / sentWidth
y1 = top / sentHeight
y2 = bottom / sentHeight
Clamp each value to 0..1. Drop boxes whose width or height is below a few percent of the frame; they are rarely useful.
x1=0, y1=0, x2=1, y2=1).confidence, or the box closest to the center if several are close.Two valid orders:
| Order | Behavior |
|---|---|
| Regions, then Find with the chosen crop | One product search. Slightly slower to first products. |
| Find on the full image and regions in parallel, then Find again if the user changes the box | First products appear sooner. You may replace them when the crop search returns. |
The example web app runs regions first, then Find. Parallel calls are a speed option; see Speed. If results render on an existing text search page, use regions first: see Combine with your text search.
Show an EXIF-correct preview immediately, scaled down for the screen. Do not wait for the network to show the photo. The preview is for display only; crops always come from the original.
Do not stroke every region. That crowds the image. For each detected box:
dotX = ((x1 + x2) / 2) * frameW
dotY = ((y1 + y2) / 2) * frameH
Draw a small marker at (dotX, dotY). Draw a rectangle only for the selected region:
boxLeft = x1 * frameW
boxTop = y1 * frameH
boxWidth = (x2 - x1) * frameW
boxHeight = (y2 - y1) * frameH
Dim everything outside that rectangle (semi-transparent black). The auto-selected object's marker must look active as soon as Regions returns. Clicking a marker selects that object. Do not add object buttons below the image; they are disconnected from the photo. For layout and a black/white test-site palette, follow test.nyris.app. See also Agent Guide.
POST /find/v1.1
X-Api-Key: <find-key>
Accept: application/items.complete+json
Content-Type: multipart/form-data; boundary=...
application/items.complete+json returns product fields (title, image, sku, filters, and so on). Without Accept (or with application/minimal+json) you get a minimal { sku, score } list. The example web app sends application/offers.complete+json, an older name that returns the same fields.
The API accepts the image three ways:
| Format | Content-Type |
When |
|---|---|---|
| Multipart | multipart/form-data |
Default in this guide. Carries image, text, and filters. |
| Binary | image/jpeg or image/png |
Image only. Body is the raw bytes. |
| JSON | application/json |
Image as base64. For JSON-only clients. See JSON body (base64). |
image = <jpeg file>
Field name is image. Filename can be query.jpg.
text = hydraulic pump
With multipart/form-data, text must be a form field. A query string ?text= is ignored and you get empty results. Text-only search sends no image part.
Keep text under 512 characters.
Two options. Pick one and keep it consistent:
image and text in the same request and let the API combine them.image and filters the returned titles with the keyword on the client.Client-side title filter (what the example does after a visual search): case-insensitive match on the product title, with prefix matching so BOL still matches BOLLSIDAN.
If the site has its own text search engine (Algolia, Elasticsearch, ...), use that engine for the keyword and facets, restricted to the nyris SKUs, and keep the nyris order. See Combine with your text search.
AND across filter types, OR across values of one type.
filters[0].filterType = category
filters[0].filterValues[0] = pumps
filters[0].filterValues[1] = valves
filters[1].filterType = brand
filters[1].filterValues[0] = acme
Catalog of filter keys: GET /find/v1/filters?limit=100 with the same X-Api-Key. It returns an array of { "key": "color", "values": ["Red", "Blue"] }. Search values of one key with GET /find/v1/filters/{key}/{value}?limit=100, which returns string[]. {value} is the search term. Filters are on /find/v1, not /find/v1.1.
Use this when your platform works with JSON end to end and cannot easily send multipart or binary bodies: low-code tools, serverless functions, tool-calling backends, or a gateway that only forwards JSON.
POST /find/v1.1
X-Api-Key: <find-key>
Accept: application/items.complete+json
Content-Type: application/json
{
"image": "/9j/4AAQSkZJRg...",
"text": "hydraulics",
"filters": [
{ "key": "MachineSerial", "values": ["SN-40821"] },
{ "key": "Assembly", "values": ["Hydraulics", "Pneumatics"] }
]
}
image is plain base64 or a data URI (data:image/jpeg;base64,...). Leave it out only for a text-only search ({"text": "..."}).text is optional, max 512 characters.filters is optional. Each entry is { key, values }, with the same AND/OR rules as multipart.printf '{"image":"%s"}' "$(base64 < query.jpg | tr -d '\n')" > body.json
curl -sS -X POST "https://api.nyris.io/find/v1.1" \
-H "X-Api-Key: $NYRIS_API_KEY" \
-H "Accept: application/items.complete+json" \
-H "Content-Type: application/json" \
--data-binary @body.json
Writing the body to a file avoids the shell's argument-length limit for large images.
Base64 adds about 33% to the request. The 2 MB limit applies to the decoded image, not the encoded body. Resize first (see Image pipeline) and encode the resized JPEG, not the camera original. When you control the client, multipart or binary is smaller and faster.
Every response carries a request id (id in the body, header X-Matching-Request) and usually a session id (session, header X-Session). Treat the session as optional. To group related searches (for example a crop search after the full-frame search), send the session from the previous response:
X-Session: <session from previous response>
or POST /find/v1.1?session=<session>. Only send session ids the API returned; an invented value returns HTTP 400. Browsers may not expose the response headers to your code, so read id and session from the body.
If your products have coordinates (WGS84), limit results by distance from the user:
| Field | Example | Meaning |
|---|---|---|
lat |
52.5200 |
Latitude of the user |
lon |
13.4050 |
Longitude of the user |
dist |
10000 |
Maximum distance in meters |
With multipart, send them as form fields. Otherwise use query parameters: POST /find/v1.1?lat=52.52&lon=13.405&dist=10000. Field names follow docs.nyris.io; the example web app sends latitude / longitude form fields instead.
{
"results": [
{
"sku": "ABC-100",
"score": 0.91,
"title": "Hydraulic pump",
"image": "https://..."
}
],
"id": "request-id",
"session": "session-id"
}
Field names on each product depend on the index. Always read sku (fall back to key or objectID if you see those). Keep id and session for feedback.
results is already sorted by visual similarity. Keep that order; do not re-sort by stock, popularity, or recommendations. score is an internal ranking signal. Do not show it as a percentage or confidence on result cards. Users treat that number as accuracy, and it is misleading.
results: [] with HTTP 200 means the search ran and nothing matched. It does not mean the request was malformed. An empty result after a text search is often the text query-string mistake above.
Optional. Improves analytics in the portal.
On a website that calls the Find API from the browser, the nyris events tracking script can send clicks and conversions for you. See Events tracking script in Analytics & Feedback. Use the raw API below for mobile apps, backends, or custom tracking.
POST /feedback/v1
X-Api-Key: <find-key>
Content-Type: application/json
{
"request_id": "<id from find>",
"session_id": "<session from find>",
"timestamp": "2026-09-28T12:00:00.000Z",
"event": "click",
"data": { "positions": [0], "product_ids": ["ABC-100"] }
}
event |
When |
|---|---|
feedback |
User says results were useful or not. data.success is boolean. |
click |
User opens a product. positions is the zero-based index in results. |
conversion |
User adds to cart or completes the action you care about. Optional data.subtype: add_to_cart, purchase, or support_request. A purchase can add data.revenue { amount, currency, order_id }. |
region |
User changed the box. data.rect is { x, y, w, h }, each 0..1. |
timestamp is ISO 8601 UTC and required. A valid event returns HTTP 2xx.
When the user is already on a product page:
GET /recommend/v1/{sku}
X-Api-Key: <find-key>
Accept: application/items.complete+json
Returns visually similar products. The SKU itself is not in the results.
GET /find/v1.1/images/{sku}
X-Api-Key: <find-key>
Accept: application/items.complete+json
Returns { "images": ["https://img.nyris.io/..."] }.
Most of the wait is upload plus the model, not your UI. Shrink the JPEG on the device, show the photo immediately, and do not block the screen on a spinner.
A 4000×3000 camera JPEG is several megabytes. The area resize in Image pipeline usually produces about 100 kB. That is the single biggest win.
The example web app:
results is empty and the request is in flight.Do not cover the page with a full-screen blocking overlay.
Regions and the first Find call both need the resized JPEG. Start them together:
original = normalize(image) # decoded, EXIF-rotated, flattened
jpeg = resize(original)
start find(jpeg) # full frame, show these products first
start regions(jpeg) # when this returns, draw boxes
# if the user changes the box:
start find(resize(crop(original))) # crop the original, then resize
Skip the parallel full-frame Find when results go through an existing text search engine. Each Find there also costs an engine query, and the page would flip when the crop result arrives. See Combine with your text search.
Cancel the previous Find if the user fires another search before it finishes (AbortController, URLSession task cancel, OkHttp Call.cancel()). Ignore responses that are not the latest request id you stored.
Text input: debounce about 300 ms so you do not search on every keystroke.
preconnect to https://api.nyris.io (and https://img.nyris.io for product images) on the page that can start a search: <link rel="preconnect" href="https://api.nyris.io">.fetch. On mobile, use one URLSession / one OkHttp client, not a new client per request.The HTTP contract does not change. Only capture, decode, and resize change.
Snippets: examples/swift/Find.swift, examples/kotlin/Find.kt.
nyris also publishes native SDKs with camera UI and API client: SDK.Android and Nyris.IMX.iOS. Use them if you want a ready-made flow; use this chapter if you build your own.
UIImagePickerController or PHPickerViewController for the library. AVCaptureSession for the camera.UIImage into a new bitmap. UIKit applies EXIF orientation when you draw.jpegData(compressionQuality: 0.9).URLSession upload task, multipart body, header X-Api-Key.func jpegForSearch(image: UIImage, maxSide: CGFloat = 1024, quality: CGFloat = 0.9) -> Data? {
let aspect = image.size.width / image.size.height
let area = maxSide * maxSide
let width = max(1, (area * aspect).squareRoot().rounded())
let height = max(1, (width / aspect).rounded())
let format = UIGraphicsImageRendererFormat.default()
format.scale = 1 // default() uses the screen scale; sizes here are pixels
format.opaque = true
let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format)
let rendered = renderer.image { ctx in
UIColor.white.setFill()
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
image.draw(in: CGRect(x: 0, y: 0, width: width, height: height))
}
return rendered.jpegData(compressionQuality: quality)
}
Store the key in the app target as a build setting or remote config, not in a screenshot in the repo. A Find-only key in the binary is the model this guide uses.
BitmapFactory.Options.inSampleSize so you never allocate the full 12 megapixel bitmap.ExifInterface before the exact scale.compress(JPEG, 90, stream).MultipartBody.FORM, part name image. execute() blocks, so call it off the main thread.fun jpegForSearch(source: Bitmap, maxSide: Int = 1024, quality: Int = 90): ByteArray {
val aspect = source.width.toFloat() / source.height
val area = maxSide * maxSide.toFloat()
val width = kotlin.math.sqrt(area * aspect).roundToInt().coerceAtLeast(1)
val height = (width / aspect).roundToInt().coerceAtLeast(1)
val out = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(out)
canvas.drawColor(Color.WHITE)
val paint = Paint(Paint.FILTER_BITMAP_FLAG) // smooth downscaling
canvas.drawBitmap(source, null, RectF(0f, 0f, width.toFloat(), height.toFloat()), paint)
val stream = ByteArrayOutputStream()
out.compress(Bitmap.CompressFormat.JPEG, quality, stream)
return stream.toByteArray()
}
HEIC from the camera is already a Bitmap after ImageDecoder / BitmapFactory on current Android. You do not upload the HEIC container.
Use the system picker (expo-image-picker or react-native-image-picker). Ask the picker to resize (quality: 0.9, maxWidth / maxHeight around 1024) or resize yourself so you match the area formula.
Send with FormData:
const body = new FormData();
body.append("image", {
uri: asset.uri,
name: "query.jpg",
type: "image/jpeg",
});
await fetch("https://api.nyris.io/find/v1.1", {
method: "POST",
headers: {
"X-Api-Key": apiKey,
Accept: "application/items.complete+json",
},
body,
});
On React Native, do not set Content-Type: multipart/form-data by hand; the boundary would be missing.
image_picker returns a file. Package image can decode, bake orientation, resize, and encodeJpg(quality: 90). Upload with package:http MultipartRequest and MultipartFile.fromBytes.
| Status | Meaning | What to do |
|---|---|---|
200 and results: [] |
Search worked, no hits | Show an empty state. Do not retry. |
| 400 | Malformed request (bad JSON or base64, invented session id) | Fix the request. Do not retry as is. |
| 401 | Key missing or not a Find key | Check X-Api-Key. Data keys do not search. |
| 413 | Image larger than 2 MB (2,097,152 bytes) | Resize and lower JPEG quality, then retry once. |
| 415 | Unsupported image format (HEIC, TIFF, BMP, …) | Convert to JPEG on the client. Do not retry as is. |
| 429 | Rate limit | Back off. Do not tight-loop. |
| 5xx or network drop | Transient | Retry up to 2 times with a short delay. |
The example client throws if apiKey is empty before the request (The api-key is not set.). Surface that as “search is not configured”, not as “no products”.
Retry only idempotent search POSTs that failed on the network or with 5xx. Do not retry 400, 401, or 413 without changing the request, the key, or the image.
Images under 100 px on the shortest edge are not an error. The API accepts them, but results deteriorate, so show the user a hint instead of blocking the search.
If the user already started a newer search, drop the old response even if it succeeds later.
This guide sends X-Api-Key from the website or the mobile app.
NYRIS_API_KEY.Add a backend proxy when at least one of these is true:
The proxy is a thin POST forwarder: the app uploads the JPEG to you, you add X-Api-Key, you call https://api.nyris.io/find/v1.1, you return the JSON. Keep the resize on the device so your server does not receive multi-megabyte photos.
X-Options is an optional request header of space-separated key=value flags. The public ones are limit (1 to 100, default 20) and threshold (minimum score), for example X-Options: limit=10 threshold=0.5. The index already has server-side defaults. Leave the header unset until you have a reason to change ranking. See Find API.
Most shops already have a search results page powered by a text search engine (Algolia, Elasticsearch, OpenSearch, Typesense, Meilisearch, Coveo, Bloomreach, and so on). Do not build a second results page for visual search. Use the existing one and add the image as a new kind of query.
nyris decides which products match and in which order. Your text search engine supplies everything else: the product records, the product cards, facets, filters, pagination, prices, stock, visibility rules, and analytics.
Visual search is a query mode of the normal search results page, not a separate screen.
Visual search is exact search. The user photographed a specific item and expects the most visually similar product first. Keep the order nyris returns. Nothing may reorder it.
For visual search queries:
score or a percentage. See Find API.Filters and facets may remove products. The remaining products keep their nyris order.
The join works only if a nyris sku identifies the same product in your text search index, for example the Algolia objectID or an sku attribute. Agree on this mapping before you write code.
distinct in Algolia, collapse in Elasticsearch) for visual queries, or the engine may return a different variant than the one nyris matched.X-Options: limit=100, the maximum (see Errors and keys). The default of 20 is fine for a plain list, but facets over 20 products are thin.results[].sku in order. Build a rank map sku → position. If a SKU appears twice, keep the first position.When the user changes a facet, repeat steps 4 and 5 with the same SKU list. Do not call nyris again; the image did not change. When the user changes the crop, start again at step 1 and clear the selected facets.
Treat the whole chain (nyris, then the engine) as one search for stale-response handling. If the user changes the crop or a facet while a request is in flight, abort it or ignore its result.
Why reorder in your code
Text engines rank by their own criteria: text relevance, custom ranking, rules, personalization. Even with an empty query, the order they return is not the nyris order. Reordering by the rank map in your code is the only engine-independent way to guarantee it. Also switch off engine features that add, pin, or remove items (see the engine notes below), because reordering cannot undo an inserted or hidden product.
Elasticsearch can also return documents in a given ID order with a pinned query (at most 100 IDs; OpenSearch has no pinned query). That is optional. Keep the client-side reorder anyway as the final guarantee.
Known limitation: facets see only the candidates
Facets narrow the up to 100 products nyris returned. A product of the selected brand at nyris position 150 will not appear, and facet counts describe the candidates, not the catalog. If a facet also exists as a nyris filter (GET /find/v1/filters), you may re-run Find with that filter instead, so nyris searches within the brand. See Find API.
If the user types a keyword while an image is active, the keyword narrows the visual results. It does not re-rank them. Pick one approach for the whole site:
text to nyris. Reorder by the nyris rank map as usual; the engine's relevance order is discarded.text to nyris together with the image (see Find API), then continue at step 3 with the new nyris order.Do not run a separate full-catalog text search and merge the two lists.
Shown with the algoliasearch v5 JavaScript client and a search-only key.
import { algoliasearch } from "algoliasearch";
const algolia = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_KEY);
const LAST = Number.MAX_SAFE_INTEGER;
interface ProductHit {
objectID: string;
[field: string]: unknown;
}
function quoteFilterValue(value: string): string {
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
function skuFilter(skus: string[]): string {
return skus.map((sku) => `objectID:${quoteFilterValue(sku)}`).join(" OR ");
}
function rankMap(skus: string[]): Map<string, number> {
const rank = new Map<string, number>();
skus.forEach((sku, index) => {
if (!rank.has(sku)) rank.set(sku, index);
});
return rank;
}
export async function visualResults(
nyrisSkus: string[],
facetFilters: string[][],
pageFilters: string,
) {
if (nyrisSkus.length === 0) return { hits: [], facets: {} };
const rank = rankMap(nyrisSkus);
const skuRestriction = `(${skuFilter(nyrisSkus)})`;
const response = await algolia.searchSingleIndex<ProductHit>({
indexName: "products",
searchParams: {
query: "",
filters: pageFilters ? `(${pageFilters}) AND ${skuRestriction}` : skuRestriction,
facetFilters,
facets: ["*"],
hitsPerPage: rank.size,
distinct: false,
enableRules: false,
enablePersonalization: false,
enableReRanking: false,
analyticsTags: ["visual-search"],
},
});
const hits = [...response.hits].sort(
(a, b) => (rank.get(a.objectID) ?? LAST) - (rank.get(b.objectID) ?? LAST),
);
return { hits, facets: response.facets ?? {} };
}
Notes:
enableRules: false stops Query Rules from pinning, hiding, or inserting items. enablePersonalization: false and enableReRanking: false stop personalization and Dynamic Re-Ranking from changing the order. distinct: false stops variant grouping. Use these settings for visual queries only; text search keeps its normal settings.pageFilters are the filters your page always applies (market, visibility). Combine them with the SKU restriction using AND; never replace them.objectID, filter on that attribute and sort by it (hit.sku). It must be in attributesForFaceting, for example as filterOnly(sku).searchSingleIndex call returns conjunctive facet counts: after the user picks one brand, the other brands drop out of the brand facet. The existing results page usually shows disjunctive counts. Get those from InstantSearch or the Algolia helper, which send the extra queries for you, or send one extra query per refined facet yourself.analyticsTags so visual searches do not distort text search analytics. If you also send Algolia Insights events, set clickAnalytics: true to get a queryID, and tag the events as visual search so they do not train the text ranking.With InstantSearch
Keep the existing facet, refinement, and hits widgets. While an image is active:
Configure widget. If the page already sets filters there, AND the SKU restriction onto the existing value instead of replacing it.hitsPerPage to the number of SKUs and reorder in the hits widget's transformItems with the rank map.stateMapping). Otherwise a reload restores the SKU restriction without the image.Remove all of this when the user removes the image.
Restrict with a terms filter on the SKU field (mapped as keyword). Put the page's fixed filters in query, and the user's facet selections in post_filter, so facet counts stay disjunctive like on the normal results page:
{
"size": 3,
"query": {
"bool": {
"filter": [
{ "terms": { "sku": ["ABC-100", "XYZ-7", "QRS-55"] } },
{ "term": { "market": "de" } }
]
}
},
"post_filter": { "term": { "brand": "norelem" } },
"aggs": {
"brand": { "terms": { "field": "brand", "size": 50 } }
}
}
Set size to the number of SKUs. Do not wrap this in a function_score, add collapse, or apply learning-to-rank for visual queries. Then sort the hits by the nyris rank map before rendering.
| Place | When |
|---|---|
| Browser | Both keys are search-only keys. Simplest. The browser calls nyris, then the text engine. |
| Your backend | You need to hide keys, apply customer-specific visibility, or combine with prices from another system. The backend calls nyris and the engine and returns the merged, ordered list. |
Either way, the image pipeline (normalize, resize, crop) runs on the device before upload. See Image pipeline.
When the engine renders the product cards, nyris only needs to return SKUs. You may request Accept: application/minimal+json to make the response smaller, but then you cannot show nyris products on their own (see the text engine failure row below).
POST /feedback/v1) for clicks and conversions with the nyris request id. Set positions to the product's zero-based index in the nyris results list, not its position on the filtered page, and always include product_ids. See Find API.| Situation | Show |
|---|---|
| nyris returns no results | "No visually similar products found." Offer to adjust the crop or search by text. |
| nyris returns SKUs, the engine returns none | Same empty state. Report the SKUs as a sync problem. |
| Facets filter everything out | The normal "no results for these filters" state, with a way to clear filters. |
| nyris fails | An error with a retry. Do not silently replace the list with text search results or bestsellers. |
| Text engine fails | An error with a retry. Only if the shop has no per-market or per-customer visibility rules may you show the nyris products in nyris order from nyris fields. Otherwise you could show products the shopper must not see. |
sku maps to a product ID in the text index, and the mapping was tested on real products, including variants.Runnable clients for the Find API. They read NYRIS_API_KEY from the environment. Download them all as nyris-examples.zip, or one at a time:
Run them from the examples folder:
export NYRIS_API_KEY="your-find-key"
sh curl/find.sh text "chair"
python3 python/find.py text "chair"
node node/find.mjs text "chair"
Image search expects a JPEG. The scripts label every upload image/jpeg, so convert PNGs first (the API accepts PNG, but these scripts do not detect it). Resize in your app first; these scripts upload the file as given so you can see the HTTP contract.
image-json sends the same image as a JSON body with base64, for platforms that only speak JSON:
python3 python/find.py image-json ./query.jpg
resize-check prints the area-based output size and does not call the network:
python3 python/find.py resize-check 3000x2000
node node/find.mjs resize-check 3000x2000
Swift and Kotlin files are reference snippets. They are not a full Xcode or Gradle project.
All nyris API requests require an API key passed in the X-Api-Key header.
How do I get an API key?
To get started, email us at support@nyris.io. Our team will provision an API key for you.
You can call the API from a website or app. A key shipped in frontend code can be read from the page source or from the requests the browser sends, so treat it as public. If you want the key to stay on your server, send requests through your own backend, which then calls the nyris API.
Send an image to the Find API and receive a JSON object with metadata for the identified items. You can optionally pass a session ID to correlate multiple requests.
POST https://api.nyris.io/find/v1.1
To search with filters, send the image and filter fields as multipart/form-data. Filtered responses include only items matching the given filter types and values. You can also append a session ID as a query parameter: POST https://api.nyris.io/find/v1.1?session=xxxxxx.
Request Headers
| Key | Value |
|---|---|
X-Api-Key |
The API key provided by us. |
Content-Type |
image/jpeg or image/png (binary), multipart/form-data, or application/json (base64). WebP is accepted in multipart and JSON bodies. |
Content-Length |
The size of the request body in bytes. Most HTTP clients set it automatically. |
Accept |
(Optional) application/items.complete+json for the full response format. Default is minimal. |
X-Session |
(Optional) The session value returned by a previous response. Any other value is rejected with 400. |
X-Options |
(Optional) Search parameters, e.g. limit=10 threshold=0.5. See below. |
If no value is provided for X-Session (or the session query argument), a new session is started and its ID is returned with the response. Front-ends are encouraged to pass the session ID returned by the previous request to the next request, thereby linking requests together.
Request Body
Content-Type: image/jpeg or image/png.multipart/form-data and include the image plus the filter fields described below. An optional text form field sends a text query.Content-Type: application/json with a JSON object. The image is a base64-encoded string. You can also send text without an image for a text-only search.JSON Body Schema
| Field | Type | Required | Description |
|---|---|---|---|
image |
String | Yes, unless text is given (text-only search) |
Base64-encoded image (plain or data URI, e.g. data:image/jpeg;base64,...). JPEG, PNG, or WebP. |
text |
String | No | Optional text query (max 512 characters). Longer text is rejected with 400. |
filters |
Array | No | Same filter format as multipart: [{"key": "MachineSerial", "values": ["SN-40821"]}]. |
With a JSON (base64) body, the 2 MB limit applies to the decoded image. The encoded request is about 33% larger than the image.
Filter Schema (multipart)
When sending filters via multipart/form-data, include one or more filter type/value pairs. The AND operator is used between filter types; OR is used between values of the same type.
Form field names are indexed: filters[0].filterType, filters[0].filterValues[0], filters[0].filterValues[1], filters[1].filterType, and so on.
Send text as a form field, not as a query parameter. A text query parameter is ignored.
| Name | Type | Explanation |
|---|---|---|
filterType |
String | The filter type, example: MachineSerial |
filterValues |
String [] | The filter values, example: [SN-40821] |
text |
String | Optional text query (max 512 characters). Longer text is rejected with 400. |
Search Parameters (X-Options)
Control result count and score thresholds via the X-Options header. Parameters are key=value pairs separated by a space, e.g. X-Options: limit=10 threshold=0.5.
| Parameter | Example Value | Explanation |
|---|---|---|
limit |
limit=20 (default), integers between 1 and 100 |
The upper limit for the number of results to be returned. |
threshold |
threshold=0.8 |
The final threshold to apply to the scores. Only results above or equal to that score will be returned. |
Response Headers
Requests are automatically tagged with a Request ID and a Session ID:
| Key | Value |
|---|---|
X-Matching-Request |
The identifier of the request; used for tracing and with the Feedback API. |
X-Session |
A session identifier covering multiple requests; used for tracing and with the Feedback API. |
Both headers are exposed to browsers via CORS. The same values are in the body as id and session.
Response Format
By default, a minimal response is returned. Use the Accept header to request the full format:
| Accept value | Description |
|---|---|
application/minimal+json |
Return minimal response (the default). |
application/items.complete+json |
Return all available fields. |
application/offers.complete+json |
Older alias of application/items.complete+json. |
We guarantee that existing fields will not be removed or have their type changed. Additional fields can be added at any point; application code should gracefully ignore unknown fields.
Image Requirements
| Recommended | Accepted | Rejected | |
|---|---|---|---|
| Shortest edge | 100 px or more | Smaller images are accepted, but result quality deteriorates as the image gets smaller. | Never rejected for small size. |
| Resolution | About 1 Mpx (e.g. 1000 × 1000 px), regardless of aspect ratio | Any | — |
| File size | 1 MB or less for low latency. Around 100 kB is typical for a 1 Mpx JPEG. | Up to 2 MB (2,097,152 bytes) | Larger than 2 MB: HTTP 413 |
| Format | JPEG, converted on the client | JPEG/JFIF, PNG, WebP | All other formats (e.g. HEIC, TIFF, BMP, GIF): HTTP 415 (binary or multipart) or 400 "Invalid image format" (JSON base64) |
Convert other formats (HEIC from iPhones, TIFF, BMP, GIF, PDF pages) to JPEG on the client before sending. Base64 is only a transport encoding: the image inside a JSON body must still be JPEG, PNG, or WebP.
Resize and crop on the client before sending. Apply cropping to the original image first, then resize.
With a JSON (base64) body, the 2 MB limit applies to the decoded image. The encoded request is about 33% larger than the image.
A binary body with Content-Type: image/webp is not accepted. Send WebP as multipart or JSON.
Error Responses
| Status | When |
|---|---|
| 400 | Invalid request. Examples: unsupported image format inside a JSON body ("Invalid image format"), invalid base64, text over 512 characters, unknown X-Session. The body is RFC 9110 problem JSON. |
| 401 | X-Api-Key missing or invalid. |
| 413 | Image larger than 2 MB (2,097,152 bytes; for JSON bodies, measured on the decoded image). |
| 415 | Unsupported image format in a binary or multipart body (e.g. HEIC, TIFF, BMP). |
Additional fields may appear. Application code should ignore fields it does not use.
import java.nio.file.Files;
import java.nio.file.Path;
OkHttpClient client = new OkHttpClient();
byte[] imageBytes = Files.readAllBytes(Path.of("image.jpg"));
RequestBody body = RequestBody.create(imageBytes, MediaType.parse("image/jpeg"));
Request request = new Request.Builder()
.url("https://api.nyris.io/find/v1.1")
.post(body)
.addHeader("x-api-key", "KEY")
.addHeader("x-session", "...")
.build();
Response response = client.newCall(request).execute();
OkHttpClient client = new OkHttpClient();
RequestBody body = new MultipartBody.Builder().setType(MultipartBody.FORM)
.addFormDataPart("filters[0].filterType","color")
.addFormDataPart("filters[0].filterValues[0]","Red")
.addFormDataPart("image","image.jpg",
RequestBody.create(MediaType.parse("image/jpeg"),
new File("image.jpg")))
.build();
Request request = new Request.Builder()
.url("https://api.nyris.io/find/v1.1")
.method("POST", body)
.addHeader("x-api-key", "<SET_YOUR_KEY_HERE>")
.addHeader("cache-control", "no-cache")
.build();
Response response = client.newCall(request).execute();
{
"image": "/9j/4AAQSkZJRg...",
"text": "hydraulics",
"filters": [
{
"key": "MachineSerial",
"values": ["SN-40821"]
},
{
"key": "Assembly",
"values": ["Hydraulics", "Pneumatics"]
}
]
}
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
OkHttpClient client = new OkHttpClient();
byte[] imageBytes = Files.readAllBytes(Path.of("image.jpg"));
String imageB64 = Base64.getEncoder().encodeToString(imageBytes);
String json = "{"
+ "\"image\":\"" + imageB64 + "\","
+ "\"text\":\"hydraulics\","
+ "\"filters\":["
+ "{\"key\":\"MachineSerial\",\"values\":[\"SN-40821\"]},"
+ "{\"key\":\"Assembly\",\"values\":[\"Hydraulics\",\"Pneumatics\"]}"
+ "]}";
RequestBody body = RequestBody.create(
MediaType.parse("application/json"), json);
Request request = new Request.Builder()
.url("https://api.nyris.io/find/v1.1")
.post(body)
.addHeader("x-api-key", "KEY")
.build();
Response response = client.newCall(request).execute();
{
"id": "c7ac45c0165a54feb005aa2049f19235",
"session": "c7ac45c0165a54feb005aa2049f19235",
"results": [
{
"sku": "123454643",
"score": 0.96,
"image": "https://img.nyris.io/example.jpg",
"ids": {
"catalogItem": "123454643"
}
},
{
"sku": "123454644",
"score": 0.92,
"image": "https://img.nyris.io/example2.jpg",
"ids": {
"catalogItem": "123454644"
}
}
]
}
{
"id": "c7ac45c0165a54feb005aa2049f19235",
"session": "c7ac45c0165a54feb005aa2049f19235",
"results": [{
"title": "test product",
"descriptionShort": "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
"descriptionLong": "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.",
"language": "de-DE",
"brand": "test brand",
"catalogNumbers": ["test_catalog_number_0", "test_catalog_number_1"],
"customIds": {
"DEMO_CUSTOM_ID": "DEMO_ID_123"
},
"keywords": ["demo", "demonstration", "test"],
"categories": ["Demo", "Test"],
"availability": "in stock",
"feedId": "1",
"groupId": "1",
"price": "10.00 EUR",
"salePrice": "1.00 EUR",
"links": {
"main": "https://example.com/product/123",
"mobile": "https://example.com/product/123/thumbnail.jpg"
},
"images": ["https://img.nyris.io/example1.jpg", "https://img.nyris.io/example2.jpg"],
"metadata": "This product is for demonstrations only",
"sku": "123456",
"score": 1,
"image": "https://img.nyris.io/example.jpg",
"oid": "c7ac45c0165a54feb005aa2049f19235",
"ids": {
"catalogItem": "123456"
},
"filters": {
"color": ["Red"],
"product": ["Shoes"]
}
}]
}
If products have geolocation data, you can narrow results by distance. Coordinates must be in WGS84 format.
When using multipart/form-data (for filters), pass geolocation as form fields. Otherwise, use query parameters: POST https://api.nyris.io/find/v1.1?lat=LAT&lon=LON&dist=DIST.
| Name | Example | Explanation |
|---|---|---|
lat |
52.5200 |
The latitude of the user. |
lon |
13.4050 |
The longitude of the user. |
dist |
10000 |
The maximum distance from the center in meters. |
The Find API matches on the entire image. For images containing multiple objects, you may want to detect and crop to specific areas first. The Regions API identifies parts of the image likely to contain relevant objects, allowing your UI to propose sections of the image for the user to search with.
POST https://api.nyris.io/find/v2/regions
Request Headers
| Key | Value |
|---|---|
X-Api-Key |
The API key provided by us. |
Content-Type |
image/jpeg, image/png, image/webp, multipart/form-data, or application/json (base64). |
Content-Length |
The size of the request body in bytes. Most HTTP clients set it automatically. |
Request Body
Content-Type: image/jpeg, image/png, or image/webp.multipart/form-data and include the image as a file field named image.Content-Type: application/json with a JSON object containing the image as a base64-encoded string.JSON Body Schema
| Field | Type | Required | Description |
|---|---|---|---|
image |
String | Yes | Base64-encoded image (plain or data URI). |
Response Format
The response contains a regions array. Each region has a confidence score (0–1) and a bounding box with pixel coordinates relative to the submitted image.
| Property | Description |
|---|---|
confidence |
Confidence value (0..1) that an object was detected at this location. |
region.top |
Top-most coordinate in pixels (floating point). |
region.left |
Left-most coordinate in pixels (floating point). |
region.bottom |
Bottom-most coordinate in pixels (floating point). |
region.right |
Right-most coordinate in pixels (floating point). |
classId |
Optional class identifier. A number, for example -1. |
The response may contain additional fields, including modelName and modelVersion at the top level. Ignore fields you do not use.
import java.nio.file.Files;
import java.nio.file.Path;
OkHttpClient client = new OkHttpClient();
byte[] imageBytes = Files.readAllBytes(Path.of("image.jpg"));
RequestBody body = RequestBody.create(imageBytes, MediaType.parse("image/jpeg"));
Request request = new Request.Builder()
.url("https://api.nyris.io/find/v2/regions")
.post(body)
.addHeader("x-api-key", "KEY")
.build();
Response response = client.newCall(request).execute();
{
"image": "/9j/4AAQSkZJRg..."
}
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
OkHttpClient client = new OkHttpClient();
byte[] imageBytes = Files.readAllBytes(Path.of("image.jpg"));
String imageB64 = Base64.getEncoder().encodeToString(imageBytes);
String json = "{\"image\":\"" + imageB64 + "\"}";
RequestBody body = RequestBody.create(
MediaType.parse("application/json"), json);
Request request = new Request.Builder()
.url("https://api.nyris.io/find/v2/regions")
.post(body)
.addHeader("x-api-key", "KEY")
.build();
Response response = client.newCall(request).execute();
{
"regions": [
{
"classId": -1,
"confidence": 0.8876233,
"region": {
"top": 43.0,
"left": 153.0,
"bottom": 577.0,
"right": 427.0
}
}
],
"modelName": "redacted",
"modelVersion": null
}
Get visually similar items for a product in your catalogue by submitting its SKU. Since we already have your product data (from the feed import), we perform a visual similarity search based on the main image of the given product. The submitted SKU itself is excluded from results.
GET https://api.nyris.io/recommend/v1/{sku}
Request Headers
| Key | Value |
|---|---|
X-Api-Key |
The API key provided by us. |
Accept |
(Optional) application/items.complete+json for full response. |
Accept-Language |
(Optional) Filter results by language, e.g. de,*;q=0.5. |
Path Parameters
| Name | Example | Explanation |
|---|---|---|
sku |
03764560 |
The SKU of the product to find recommendations for. |
{
"id": "575e838883cf5e60859889c112b1463e",
"session": "575e838883cf5e60859889c112b1463e",
"results": [
{
"sku": "123454643",
"image": "https://img.nyris.io/example.jpg",
"score": 0.96
},
{
"sku": "123454644",
"image": "https://img.nyris.io/example2.jpg",
"score": 0.92
}
]
}
Retrieve the public images associated with a product by its SKU.
GET https://api.nyris.io/find/v1.1/images/{sku}
Request Headers
| Key | Value |
|---|---|
X-Api-Key |
The API key provided by us. |
Accept |
application/items.complete+json |
Path Parameters
| Name | Example | Explanation |
|---|---|---|
sku |
D005 |
The SKU of the product to retrieve images for. |
{
"images": [
"https://img.nyris.io/7bb/7bb5d2940a23084f307ed9a4bf8d74c6e03cf38cd10ca1e909eb6205774629ff.jpg"
]
}
Retrieve the available filter attributes that can be used with the Find API.
GET https://api.nyris.io/find/v1/filters
Request Headers
| Key | Value |
|---|---|
X-Api-Key |
The API key provided by us. |
Accept |
application/items.complete+json |
An optional limit query parameter is accepted, for example GET https://api.nyris.io/find/v1/filters?limit=100.
To look up values for one filter key, call GET https://api.nyris.io/find/v1/filters/{key}/{value}?limit=100. The response is a JSON array of strings.
[
{
"key": "MachineSerial",
"values": ["SN-40821", "SN-40822"]
},
{
"key": "Assembly",
"values": ["Hydraulics", "Pneumatics"]
}
]
The fastest and easiest way to integrate visual search to your website is using our Javascript Widget. All you have to do is to add some lines of code to a runtime file of your website and you are ready to start using visual search.
What I need to start using this code?
You need to follow these steps:
Done. You are ready to use visual search on your website!
Sample
See the nyris demo widget in action: https://widget.nyris.app/
Where is the source code? Can I change it
The code is on github. Follow the readme file to build the project. You can change or adjust the JS / CSS files according to your needs and host them on your server as well.
You can find the latest Android SDK including the implementation documentation on github:
You can find the latest iOS SDK including the implementation documentation on github:
https://github.com/nyris/Nyris.IMX.iOS
Should you have any questions, please send an e-mail to support@nyris.io
There are several ways to submit your product and image data to us. You can provide any kind of structured file that we can pull (xml, csv, txt, json, etc.), or use the Data Management API to programmatically manage your items and images. If you are already providing your data to an affiliate network like Affilinet, Awin or Google Shopping, you are probably all set for the start. In this case we just need access to your feed URL. Please be aware that those feeds usually only include the link to the main product image. As image recognition works best the more images of a specific product we have, you might need to adjust those feeds to include all your product images. Please find more details below.
If you don't have a product feed, we can also directly access your product data through an API for the following systems:
Shop Systems
PIM
You can also create a data feed using Google Sheets. Details on how to use Google Sheets and an example file can be found in a section below.
The Data Management API lets you programmatically manage items and images in your nyris index. Use it to create, update, list, and delete product data and associated images.
The Data Management API requires a separate API key. For security, the keys used for the Find API and Recommend API do not work with the Data Management API, and vice versa. Contact mailto:support@nyris.io to obtain your Data API key.
The full OpenAPI reference is available at api.nyris.io/manage/swagger.
All Data Management API requests require an API key passed via the X-Api-Key header. This key is different from the one used for the Find and Recommend APIs.
| Key | Value |
|---|---|
X-Api-Key |
Your Data Management API key (not the same as the Find API key). |
Creates a new item in your index. The key field is required and must be unique within your index.
{
"key": "product-001",
"text": {
"name": "Red Running Shoe",
"description": "Lightweight running shoe in red",
"brand": "ExampleBrand",
"language": "en-US"
},
"category": ["Shoes", "Running"],
"links": {
"main": "https://example.com/products/product-001"
},
"price": {
"value": 89.99,
"currency": "EUR"
}
}
{
"id": "18b7f87a-1577-4486-a680-a460d970b584",
"indexId": "adc23661-bc7f-4514-875e-c60d1ac61b93",
"key": "product-001",
"text": {
"name": "Red Running Shoe",
"description": "Lightweight running shoe in red",
"brand": "ExampleBrand",
"language": "en-US"
},
"category": ["Shoes", "Running"],
"links": {
"main": "https://example.com/products/product-001"
},
"price": {
"value": 89.99,
"currency": "EUR"
},
"images": [],
"created": "2026-03-19T12:00:00Z",
"modified": "2026-03-19T12:00:00Z"
}
Creates an item if the key does not exist yet, or updates the existing item. This is the recommended way to keep your index in sync with your product catalog.
The request body is the same as for creating an item. Returns 201 if a new item was created, 200 if an existing item was updated.
Returns a paginated list of items in your index.
| Name | Type | Description |
|---|---|---|
size |
integer | Number of items per page (optional). |
pageToken |
uuid | Token for the next page (from a previous response). |
sort |
string | Sort by UpdateDate, CreationDate, or UniqueKey. |
order |
string | Ascending or Descending. |
{
"items": [
{
"id": "18b7f87a-1577-4486-a680-a460d970b584",
"key": "product-001",
"text": { "name": "Red Running Shoe" },
"images": [],
"created": "2026-03-19T12:00:00Z",
"modified": "2026-03-19T12:00:00Z"
}
],
"pageToken": "18b7f87a-1577-4486-a680-a460d970b584",
"nextPageToken": "29c5bf8b-2688-5597-b791-991443195695"
}
Retrieves a single item using your unique key.
Deletes an item by its unique key. All associated images are also removed.
Adds an image to an existing item, identified by its key. You can provide the image as a URL (JSON body) or upload it directly (multipart/form-data or raw image bytes).
{
"url": "https://example.com/images/product-001.jpg",
"visible": true,
"main": true
}
{
"id": "63a4ac04-d267-442c-9be4-880332084127",
"indexId": "adc23661-bc7f-4514-875e-c60d1ac61b93",
"itemId": "18b7f87a-1577-4486-a680-a460d970b584",
"url": "https://example.com/images/product-001.jpg",
"visible": true,
"main": true,
"created": "2026-03-19T12:01:00Z"
}
Returns all images belonging to an item.
Removes a specific image by its ID.
The complete OpenAPI specification, including all endpoints, schemas, and error codes, is available in the interactive Swagger documentation:
You can also import the OpenAPI spec directly into Postman or other API tools using the JSON URL:
https://api.nyris.io/manage/swagger/v1/swagger.json
The bare minimum is a unique ID (sku) and the main_image_link. To get a full list of possible attributes please download the following sheet or use a copy of it as base for your item or product feed. When gathering your data, please keep always in mind that all submitted information will be used for improving your search results. Completeness and quality of data often makes the difference:
Download Feed Example and full attribute description (google sheets)
| Attribute | Example | Comment |
|---|---|---|
sku |
sku1 | unique item or product identifier (e.g. EAN code) |
main_image_link |
https://storage.googleapis.com/nyris/share/image_a1.png | main image link. should be publicly available. ideally, product in question should be at the center of the image |
Image Links
To improve the search, additional images can be provided. All referenced images (main, additional and recognition types)
are used for search. main and additional images are treated identically, with the main image being the designated
reference to be shown e.g. in the Portal. If requested, these image links can be returned as part of the response.
recognition image links, on the other hand, are purely used for recognition and are never returned as part of any response.
| Attribute | Example | Comment |
|---|---|---|
additional_image_link_0 |
https://storage.googleapis.com/nyris/share/image_a1.png | additional images |
additional_image_link_1 |
additional images | |
additional_image_link_N |
Nth additional image | |
recognition_image_link_0 |
additional recognition images (images that will never show up on frontend) ideally taken from different angles and w/o packaging | |
recognition_image_link_1 |
https://storage.googleapis.com/nyris/share/image_r1.png | additional recognition images (images that will never show up on frontend) ideally taken from different angles and w/o packaging |
recognition_image_link_N |
Nth additional recognition image |
Textual description
Providing these values will enable both OCR (text matching) functionality, finer grained recognition of results and item class distribution results. While optional, providing these values is thus strongly recommended.
| Attribute | Example | Comment |
|---|---|---|
title |
title 1 | Product or item title (supports OCR recognition step) |
brand |
brand 1 | Brand (supports brand recognition step) |
description_short |
description short 1 | Short description (supports OCR recognition step) |
description_long |
description long 1 | Long description (supports OCR recognition step) |
custom_category |
cat1 > cat2 | your product or item category. Please separate with > (e.g. building materials, wood, windows & doors > windows > window ledges). Providing this field will be used to improve recommendations. |
language |
de-DE | Language Culture Name (https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/db9b9b72-b10b-4e7e-844c-09f88c972219) |
Key-value pairs
By providing custom key-value pairs, we are able to improve detection in combination with text matching functionality, and/or will be able to return named IDs as part of the response. If your system requires multiple IDs or custom data pairs, use these fields.
| Attribute | Example | Comment |
|---|---|---|
custom_id_key_0 |
pzn | custom ID key (e.g. for Barcode recognition) and |
custom_id_value_0 |
123456 | custom ID value (e.g. for Barcode recognition), |
custom_id_key_N |
pzn | Nth custom ID key and |
custom_id_value_N |
123456 | Nth custom ID value |
Catalog numbers
These values are mainly used for identifying products within catalogs, and catalogs by IDs.
| Attribute | Example | Comment |
|---|---|---|
catalog_number_0 |
3306 | catalog numbers from your printed advertisement. Used by PDF importer |
catalog_number_1 |
cat1 | catalog numbers from your printed advertisement. Used by PDF importer |
catalog_number_N |
... | Nth catalog number |
Keywords
These values are used for exactly matching keywords, such as item materials, colors, etc., with text found during OCR; thus, providing these values improves text matching. Keywords are treated as a set, so values specified multiple times would be considered identical.
To simplify specification of keywords, you can specify keywords in a delimiter-separated list
via the configurable keywords column. By default, keywords are split at the , (comma) delimiter:
| Attribute | Example | Comment |
|---|---|---|
keywords |
keyword, keyword, keyword 2 | Registers the two keywords keyword and keyword 2 |
keyword(",") |
keyword 1, keyword 2 | Registers the two keywords keyword 1 and keyword 2 |
keyword("AND") |
keyword 1 AND keyword 2 | Registers the two keywords keyword 1 and keyword 2 |
To provide single keywords or individual keywords per column, use the keyword / keyword_N variant instead.
Here, values are not split:
| Attribute | Example | Comment |
|---|---|---|
keyword |
a nice, individual keyword | Registers the keyword a nice, individual keyword |
keyword_0 |
keyword 0 | The first keyword in a set |
keyword_1 |
keyword 1 | The second keyword in a set |
keyword_N |
... | The Nth keyword in a set |
Geolocation Fields
If provided, the coordinates can be used to narrow down the search by the location of the product. The provided coordinates have to be in the WGS84 format with signed decimal numbers as the notation.
| Attribute | Example | Comment |
|---|---|---|
latitude |
52.5200 |
Latitude of the product. Use together with longitude to associate the product with a location. |
longitude |
13.4050 |
Longitude of the product. Use together with latitude to associate the product with a location. |
Item relations
Some items are related to other entries, and some IDs may have different meaning across logical separations of feeds (e.g. when there is a feed per country).
| Attribute | Example | Comment |
|---|---|---|
feed_id |
1 | Feed ID, required only if you have multiple feeds as one merchant. The value has to be an integer. |
group_id |
23 | for variation products (size, colour etc. variations) |
Item URLs
| Attribute | Example | Comment |
|---|---|---|
main_offer_link |
https://www.nyris.io/products/visual-search-api | product or landing page link |
mobile_offer_link |
https://vimeo.com/788577734 | secondary product or landing page link (mobile, video etc.) |
PreFilter Attributes
| Attribute | Example | Comment |
|---|---|---|
filter_key_0 |
color | filter key used by faceted search and |
filter_value_0 |
Red,Blue | comma separated filter values for the key for the item, |
filter_key_N |
shape | Nth filter key (Max N = 10)and |
filter_value_N |
candle,capsule | Nth filter values for the item |
Other Optional Fields
| Attribute | Example | Comment |
|---|---|---|
meta_data |
some additional text data | any data you would like to store additionally (text field); the content is not interpreted |
availability |
in stock | valid values: in stock, out of stock, preorder, back in stock soon, removed |
price |
17.30 EUR | . as decimal, 3-digit ISO Code for currency |
sale_price |
15.00 EUR | . as decimal, 3-digit ISO Code for currency |
image_version |
1234 | image version; if changed, images will be re-imported |
Visual search analytics show how customers interact with search results and help improve search performance over time.
Nyris provides two levels of analytics:
| Type | Merchant implementation | What you get |
|---|---|---|
| General usage & index analytics | None required | Search volume, product count, image count in your index |
| Click & conversion analytics | Required (see below) | Which results were clicked, which led to conversions, tied to each search request_id |
This document covers click & conversion analytics — how to send customer events to the Nyris Feedback API.
For most commerce stores, use the Nyris events tracking script — a single drop-in snippet that captures searches, clicks, and conversions automatically.
Once embedded, the script:
request_id of each search,dataLayer,It requires no changes to your store's source code in the typical case and is built so it cannot break the host page (all logic is error-isolated).
Place the tag in the <head>. async is recommended.
Minimal — only the API key is required:
Typical commerce setup — configure result cards, product ids, and add-to-cart labels:
With this markup, a click on the card (or its link) is reported as a click, and a click on the add-to-cart button is reported as a conversion — both carrying the product id:
<script
async
src="https://assets.nyris.io/nyris-tracking/v1/nyris-tracker.min.js"
data-api-key="YOUR_NYRIS_API_KEY">
</script>
<script
async
src="https://assets.nyris.io/nyris-tracking/v1/nyris-tracker.min.js"
data-api-key="YOUR_NYRIS_API_KEY"
data-results-selector=".product-card"
data-product-id-attr="data-product-id"
data-cart-button-labels="Add to cart,In den Warenkorb">
</script>
<div class="product-card" data-product-id="SKU-9987">
<a href="/products/sku-9987">Trail Runner Jacket</a>
<button type="button">Add to cart</button>
</div>
The script works when the browser calls the Nyris Find API directly (https://api.nyris.io/find/...). It intercepts those responses to obtain the request_id.
If your store proxies search through your own backend and never calls the Find API from the browser, inject the request id into the results container instead:
Without a request_id (from interception or data-nyris-request-id), click and conversion events are not sent.
<div id="search-results" data-nyris-request-id="REQUEST_ID_FROM_SERVER">
<!-- result cards -->
</div>
The tracker resolves the product id for each result card in this order:
data-product-id-attr on (or inside) the card (default: data-product-id).data-product-text-selector, if configured.For most stores the position fallback works out of the box when result order matches the API response.
Add-to-cart is detected automatically from button labels. Purchases and support requests must be declared when they happen.
Supported conversion subtypes (fixed): add_to_cart, purchase, support_request. Unknown values are ignored by the tracker.
Purchase — HTML attributes:
Support request — script tag only (preferred for inquiry Send):
No button markup. Matching label clicks inside the scope fire support_request.
Omit scope only for intentional page-wide matching.
Or mark a specific button with data-nyris-conversion="support_request".
JavaScript API:
Custom DOM event (useful with tag managers):
If you already send GA4 e-commerce events to window.dataLayer, enable the GA4 bridge (default) to mirror add_to_cart and purchase events automatically. Support requests are not a standard GA4 ecommerce event — declare them via HTML, JS, or the custom DOM event.
<button
data-nyris-conversion="purchase"
data-nyris-product-ids="SKU-9987,SKU-1234"
data-nyris-order-id="ORD-123"
data-nyris-revenue-amount="49.99"
data-nyris-revenue-currency="EUR">
Complete purchase
</button>
<script async
src="https://assets.nyris.io/nyris-tracking/v1/nyris-tracker.min.js"
data-api-key="YOUR_NYRIS_API_KEY"
data-results-selector=".wrap-main-item-result"
data-support-button-labels="Send,Senden"
data-support-button-scope="[role=dialog], .inquiry-modal">
</script>
Set options as data-* attributes on the script tag, or as window.nyrisTrackerConfig before the script loads.
data-* attribute |
nyrisTrackerConfig key |
Default | Description |
|---|---|---|---|
data-api-key |
apiKey |
— (required) | Nyris API key. Sent as X-Api-Key. |
data-results-selector |
resultsSelector |
.product-card |
CSS selector for a single search-result card. |
data-results-container |
resultsContainer |
document.body |
Scope where results live. |
data-product-id-attr |
productIdAttr |
data-product-id |
Attribute holding the product id/SKU. |
data-product-text-selector |
productTextSelector |
none | CSS selector whose text is the product id. |
data-cart-button-labels |
cartButtonLabels |
Add to cart,Add to basket,In den Warenkorb |
Comma-separated, case-insensitive add-to-cart labels. Replaces defaults when set. |
data-support-button-labels |
supportButtonLabels |
none | Labels for support/inquiry Send (e.g. Send,Senden). Fires support_request — one script tag, no button markup. |
data-support-button-scope |
supportButtonScope |
none | CSS selector limiting those matches (e.g. [role=dialog], .inquiry-modal). Recommended. |
data-mode |
mode |
cookiefree |
cookiefree or full. See GDPR modes. |
data-attribution-window-minutes |
attributionWindowMinutes |
30 |
How long a search stays eligible for attributed conversions. |
data-ga4-bridge |
ga4Bridge |
true |
Bidirectional GA4 dataLayer integration. |
data-feedback-url |
feedbackUrl |
https://api.nyris.io/feedback/v1 |
Override endpoint (restricted to api.nyris.io/feedback/). |
data-debug |
debug |
false |
Log [NyrisTracker] messages to the console. |
window.nyrisTrackerConfig example:
<script>
window.nyrisTrackerConfig = {
apiKey: 'YOUR_NYRIS_API_KEY',
resultsSelector: '.search-result',
cartButtonLabels: ['Add to cart', 'Buy now'],
};
</script>
<script async src="https://assets.nyris.io/nyris-tracking/v1/nyris-tracker.min.js"></script>
| Method | Description |
|---|---|
NyrisTracker.conversion(subtype, options) |
Report a conversion. subtype: add_to_cart, purchase, or support_request. options: product_ids, positions, quantity, revenue (purchase). |
NyrisTracker.grantConsent() |
Switch to full mode after cookie consent. |
NyrisTracker.getRequestId() |
Current Nyris request_id, or null. |
| Mode | Storage | Consent | Notes |
|---|---|---|---|
cookiefree (default) |
sessionStorage, else in-memory |
Not required | No cookies, no persistent visitor id. |
full |
localStorage |
Required | Call NyrisTracker.grantConsent() after opt-in. |
The default cookiefree mode uses no cookies and no cross-session identifier. Confirm with your DPO whether a consent banner is required for your jurisdiction.
Enable debug output while integrating:
Then open the browser console and look for [NyrisTracker] initialized, search captured, click, and add_to_cart / conversion messages.
data-debug="true"
Use the raw Feedback API implementation below if you:
Implement click and conversion analytics yourself by POSTing JSON events to the Feedback API. Use this path when you cannot embed the tracking script or need a custom integration.
Every Find API response includes a unique request id and optionally a session id:
| Source | Header | JSON body fallback |
|---|---|---|
| Request id | X-Matching-Request |
id |
| Session id | X-Session |
session |
Store the request id when you receive search results. Attach it to every subsequent click or conversion event for that search.
Perform a Find API search first (see Find API documentation), then POST events to the Feedback API using the same API key.
POST https://api.nyris.io/feedback/v1
| Header | Value |
|---|---|
X-Api-Key |
Your Nyris API key |
Content-Type |
application/json, application/event+json, or text/json |
Every event is a JSON object with these fields:
| Property | Required | Description |
|---|---|---|
request_id |
Yes | Request id from the Find API response |
session_id |
No | Session id from the Find API response. Groups multiple searches from the same user. |
timestamp |
Yes | Event time in ISO 8601 UTC, e.g. 2020-11-06T17:53:25.511Z |
event |
Yes | Event type: click, conversion, feedback, or region |
data |
Depends on event | Event-specific payload (see below) |
A successful POST returns HTTP 2xx. Invalid JSON or missing required fields may return an error.
event value |
Purpose |
|---|---|
click |
User clicked or selected a result (position and/or product id) |
conversion |
A result led to business value (e.g. add to cart, purchase) |
feedback |
End-user feedback on the result set ("Did we find what you were looking for?") |
region |
User selected a crop/region before searching (object proposal mode) |
Tracks which result the user interacted with.
data properties:
| Property | Type | Description |
|---|---|---|
product_ids |
string[] |
Product ids as in your product data / Find API SKUs |
positions |
number[] |
Zero-based index in the Find API result list |
Provide product ids where possible — they remain accurate even if the frontend reorders or filters results.
Example:
{
"request_id": "e8848827-27af-41d7-a98f-1a2f94b0f19a",
"session_id": "e8848827-27af-41d7-a98f-1a2f94b0f19a",
"timestamp": "2020-11-06T17:53:25.511Z",
"event": "click",
"data": {
"positions": [0],
"product_ids": ["1234"]
}
}
Tracks that a search result led to a conversion (add to cart, purchase, support request, etc.).
data properties:
| Property | Type | Description |
|---|---|---|
subtype |
string |
Optional conversion type. Recommended fixed values below. Max 128 chars; trimmed and lowercased by the API. |
product_ids |
string[] |
Product ids involved in the conversion |
positions |
number[] |
Zero-based positions in the original result list |
quantity |
number |
Optional item quantity |
revenue |
object | Optional; typically used with purchase |
revenue.amount |
number |
Order total |
revenue.currency |
string |
ISO 4217 currency code |
revenue.order_id |
string |
Optional order id (deduplication / reporting) |
The data field is optional. A conversion with no data still marks the request as successful if a prior click event was sent for the same request_id.
The API accepts any non-empty subtype string. For consistent analytics, use these fixed values:
subtype |
Meaning |
|---|---|
add_to_cart |
Soft conversion — product from search added to cart |
purchase |
Hard / sales conversion — order completed |
support_request |
User started support from search (chat, email, ticket) |
Omit subtype for legacy undifferentiated conversions.
Example — add to cart:
Example — purchase:
Example — support request:
Example (minimal — relies on prior click):
{
"request_id": "e8848827-27af-41d7-a98f-1a2f94b0f19a",
"session_id": "e8848827-27af-41d7-a98f-1a2f94b0f19a",
"timestamp": "2020-11-06T17:53:25.511Z",
"event": "conversion",
"data": {
"subtype": "add_to_cart",
"positions": [0],
"product_ids": ["1234"],
"quantity": 1
}
}
{
"request_id": "e8848827-27af-41d7-a98f-1a2f94b0f19a",
"session_id": "e8848827-27af-41d7-a98f-1a2f94b0f19a",
"timestamp": "2020-11-06T17:53:25.511Z",
"event": "conversion",
"data": {
"subtype": "purchase",
"positions": [0],
"product_ids": ["1234"],
"quantity": 1,
"revenue": {
"amount": 49.99,
"currency": "EUR",
"order_id": "ORD-123"
}
}
}
{
"request_id": "e8848827-27af-41d7-a98f-1a2f94b0f19a",
"session_id": "e8848827-27af-41d7-a98f-1a2f94b0f19a",
"timestamp": "2020-11-06T17:53:25.511Z",
"event": "conversion",
"data": {
"subtype": "support_request",
"product_ids": ["1234"],
"positions": [0]
}
}
{
"request_id": "e8848827-27af-41d7-a98f-1a2f94b0f19a",
"session_id": "e8848827-27af-41d7-a98f-1a2f94b0f19a",
"timestamp": "2020-11-06T17:53:25.511Z",
"event": "conversion"
}
End-user feedback on the overall result set, e.g. from a "Did we find what you were looking for?" prompt.
data properties:
| Property | Type | Description |
|---|---|---|
success |
boolean |
Whether the search was successful from the user's perspective |
comment |
string |
Optional free-text comment |
Where possible, also send click and conversion events — they provide automatic, behaviour-based feedback.
Example:
{
"request_id": "e8848827-27af-41d7-a98f-1a2f94b0f19a",
"session_id": "e8848827-27af-41d7-a98f-1a2f94b0f19a",
"timestamp": "2020-11-06T17:53:25.511Z",
"event": "feedback",
"data": {
"success": true,
"comment": "Thank you!"
}
}
Tracks the image region the user selected before searching (crop / object proposal mode).
data properties:
| Property | Type | Description |
|---|---|---|
rect |
object | Normalized crop rectangle |
rect properties (all fractions in the range 0..1):
| Property | Description |
|---|---|
x |
Left edge, as a fraction of image width |
y |
Top edge, as a fraction of image height |
w |
Width, as a fraction of image width |
h |
Height, as a fraction of image height |
A region covering the full image: "x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0.
Example:
{
"request_id": "e8848827-27af-41d7-a98f-1a2f94b0f19a",
"session_id": "e8848827-27af-41d7-a98f-1a2f94b0f19a",
"timestamp": "2020-11-06T17:53:25.511Z",
"event": "region",
"data": {
"rect": {
"x": 0.25,
"y": 0.25,
"w": 0.5,
"h": 0.5
}
}
}
X-Matching-Request / X-Session (or id / session from the JSON body if headers are not exposed).click event with product_ids and positions.conversion event with the same request_id and relevant product_ids.Product ids must match the SKUs in your product data and Find API results, or Nyris cannot correlate events with your catalogue.
All event types should return HTTP 2xx when the payload is valid.