Core Concepts
The Solenya API is organised around four resource types that form a simple pipeline: Feeds flow into Indexes, which serve Users, who query Items.
- Feed: A connection to your Google Product Feed (product catalog). Feeds are the source of truth for items.
- Index: A configured, queryable view over one feed, with its own metadata. A single feed may have multiple indexes.
- User: An end-user identified by a customer-generated UUIDv7, persisted client-side (e.g. in a browser cookie or mobile app storage). User records are created lazily on first API request. No server-side mutation is needed.
- Item: A product or variant available for retrieval via the
itemsquery that is displayed as a recommendation.
Feeds
The ingestion of the feed is maintained by Solenya and used to tell our system what can or cannot be shown to users on your e-commerce website.
Add a Feed
mutation AddFeed {
add_feed(feed_url: "https://example.com/feed.xml") {
url
}
}Delete a Feed
mutation DeleteFeed {
delete_feed(feed_url: "https://example.com/feed.xml") {
url
}
}Query Feeds
In v1, feed queries return a ResultSet with paginated results:
query GetFeeds($pageSize: Int = 25, $pageNumber: Int = 1) {
feeds {
page(page_size: $pageSize, page_number: $pageNumber) {
rows {
record {
url
}
}
page_info {
has_next_page
}
}
}
}Indexes
An index is an abstraction of Google Product Feeds which stores configuration and metadata about a storefront, allowing customers to maintain and test different search and recommendation experiences for market segments, storefronts, testing cohorts, or environments (staging, production, etc.). Customers can associate a unique feed with each of their indexes, or share a feed across multiple indexes.
Add a New Index
mutation AddIndex {
add_index(
feed_list: ["https://example.com/feed.xml"]
ingest_schedule: "0 * * * *"
) {
uuid
}
}ingest_schedule: A cron expression controlling how often the feed is ingested. Defaults to"0 * * * *"(hourly).
Update an Index
mutation UpdateIndex {
update_index(
index_uuid: "<index_uuid>"
feed_list: ["https://example.com/feed.xml"]
ingest_schedule: "0 */6 * * *"
) {
uuid
}
}Both feed_list and ingest_schedule are optional on update; pass only the fields you want to change.
Delete an Index
mutation DeleteIndex {
delete_index(index_uuid: "<index_uuid>") {
uuid
}
}Query Indexes
query GetIndexes($pageSize: Int = 10, $pageNumber: Int = 1) {
indexes {
page(page_size: $pageSize, page_number: $pageNumber) {
rows {
record {
uuid
feeds {
url
}
}
}
page_info {
has_next_page
}
}
}
}Users
Users are identified by a customer-generated UUIDv7. The same UUID is used for both item queries (Solenya-User-UUID header) and event tracking (solenya_user_uuid field), ensuring that search interactions and downstream events (views, purchases, experiments) can be joined for personalisation and analytics.
Generating and Persisting the UUID
Generate a UUIDv7 once per user and persist it for future use. The Solenya API creates the user record lazily on first request. No mutation is needed.
Web (browser cookie):
import { uuidv7 } from "uuidv7";
function getSolenyaUserUUID() {
const match = document.cookie.match(/(?:^|; )solenya_user_uuid=([^;]*)/);
if (match) return match[1];
const newId = uuidv7();
document.cookie = `solenya_user_uuid=${newId}; path=/; max-age=31536000; SameSite=Lax`;
return newId;
}Mobile (e.g. React Native AsyncStorage):
import AsyncStorage from "@react-native-async-storage/async-storage";
import { uuidv7 } from "uuidv7";
async function getSolenyaUserUUID(): Promise<string> {
const stored = await AsyncStorage.getItem("solenya_user_uuid");
if (stored) return stored;
const newId = uuidv7();
await AsyncStorage.setItem("solenya_user_uuid", newId);
return newId;
}Passing the UUID
Pass the UUID as the Solenya-User-UUID header on all requests to the Solenya GraphQL API. If you proxy requests through your own backend, forward the header from the client request.
The same UUID should be used for event tracking via the solenya_user_uuid form field (see Event Tracking REST API).
Account Users
Account users represent team members with access to your Solenya account. Each account user has scopes that control their permissions.
Query Account Users
query GetAccountUsers($pageSize: Int = 25, $pageNumber: Int = 1) {
account_users {
page(page_size: $pageSize, page_number: $pageNumber) {
rows {
record {
uuid
email
oidc_name
oidc_picture
scopes
is_active
created_at
updated_at
last_login_at
}
}
page_info {
has_next_page
}
}
}
}Add an Account User
mutation AddAccountUser {
add_account_user(email: "user@example.com", scopes: "index:read:*") {
uuid
email
scopes
}
}Update Account User Scopes
mutation UpdateAccountUserScopes {
update_account_user_scopes(
account_user_uuid: "<account_user_uuid>"
scopes: "index:read:*,events:write:*"
) {
uuid
scopes
}
}Disable an Account User
mutation DisableAccountUser {
disable_account_user(account_user_uuid: "<account_user_uuid>") {
uuid
is_active
}
}Items
The items query powers search, browse, item-item similarity, and personalised recommendations. In v1, items are accessed via a namespaced feed gateway: the top-level items field returns a namespace object, and feed-specific resolvers (merchant_feed) sit beneath it.
Note: All
itemsqueries require theSolenya-Index-UUIDandSolenya-User-UUIDHTTP headers (see Required Headers). These are not passed as GraphQL arguments.
Result Shape
All item queries return a ResultSet that wraps results in a consistent four-layer structure:
items {
merchant_feed(...) {
page(page_size, page_number) {
rows {
record { ... }
metadata { score }
}
page_info {
has_next_page
}
}
facets {
brand(top_n: 10) { value count }
price(bucket_size: 10.0) { range count }
}
}
}Because page and facets are independent lazy field resolvers, you can request either or both in the same query without extra overhead.
Item-Item Recommendation (Similar Products)
query SimilarProducts {
items {
merchant_feed(
rank_with: {
multimodal_query: {
item: { selector: item_group_id, eq: "ADI_GN2901_BLANK" }
}
}
distinct_on: { selector: item_group_id }
) {
page(page_size: 25, page_number: 1) {
rows {
record {
item_group_id
}
metadata {
score
}
}
page_info {
has_next_page
}
}
}
}
}Text Search
Simple Text Search
query SearchProducts {
items {
merchant_feed(
rank_with: {
multimodal_query: {
text: "Blue shoes"
}
}
distinct_on: { selector: item_group_id }
) {
page(page_size: 25, page_number: 1) {
rows {
record {
item_group_id
}
}
page_info {
has_next_page
}
}
}
}
}Complex Search with Filters and Pagination
query SearchQuery {
items {
merchant_feed(
rank_with: {
multimodal_query: {
text: "Snazzy street-wear"
}
}
distinct_on: { selector: item_group_id }
filter_term: {
and: [
{ where: { record: { availability: { eq: { value: "in_stock" } } } } }
{
or: [
{ where: { record: { brand: { eq: { value: "adidas" } } } } }
{ where: { record: { brand: { in: { values: ["Converse", "Reebok", "Nike", "Skechers"] } } } } }
]
}
{ where: { record: { size: { eq: { value: "10" } } } } }
{ where: { record: { product_type: { like: { value: "Home > Women%" } } } } }
{ where: { record: { sale_price: { lt: { value: 800 } } } } }
]
}
) {
page(page_size: 5, page_number: 2) {
rows {
record {
item_group_id
image_link
brand
size
product_type
sale_price
}
metadata {
score
}
}
page_info {
has_next_page
}
}
}
}
}Reverse Image Search
query ImageSearch {
items {
merchant_feed(
rank_with: {
multimodal_query: {
images: {
urls: ["https://example.com/image.jpg"]
}
}
}
distinct_on: { selector: item_group_id }
) {
page(page_size: 25, page_number: 1) {
rows {
record {
item_group_id
}
}
page_info {
has_next_page
}
}
}
}
}File Upload
Provide files instead of urls in the images input. See the Strawberry file upload docs. Send the request as a multipart form:
curl https://api.solenya.ai/v1/graphql \
-H 'Authorization: Bearer <access_token>' \
-H 'Solenya-Index-UUID: <index_uuid>' \
-H 'Solenya-User-UUID: <user_uuid>' \
-F 'operations={"query":"query ImageSearchWithFile($file: Upload!) { items { merchant_feed(rank_with: { multimodal_query: { images: { files: [$file] } } }, distinct_on: { selector: item_group_id }) { page(page_size: 25, page_number: 1) { rows { record { item_group_id } } page_info { has_next_page } } } } }","variables":{"file":null}}' \
-F 'map={"0":["variables.file"]}' \
-F '0=@<path_to_image.png>;type=image/png'Personalised Recommendations
To get personalised recommendations, set user: { personalisation: true } inside the multimodal_query. Personalisation is opt-in and requires the user to have prior interaction history. For new users without history, the system applies a default prompt to avoid cold-start issues.
query RecommendedForYou {
items {
merchant_feed(
rank_with: {
multimodal_query: {
user: { personalisation: true }
}
}
distinct_on: { selector: item_group_id }
) {
page(page_size: 5, page_number: 1) {
rows {
record {
item_group_id
}
}
page_info {
has_next_page
}
}
}
}
}Browse
query Browse {
items {
merchant_feed(
rank_with: {
multimodal_query: {
user: { personalisation: true }
}
}
filter_term: {
where: { record: { product_type: { like: { value: "%Women%" } } } }
}
distinct_on: { selector: item_group_id }
) {
page(page_size: 25, page_number: 1) {
rows {
record {
item_group_id
}
}
page_info {
has_next_page
}
}
}
}
}Cart-aware Recommendations
Pass multiple item IDs using the in operator:
query CartSimilar {
items {
merchant_feed(
rank_with: {
multimodal_query: {
item: { selector: id, in: ["ADI_GN2901_BLANK-UK10", "ADI_GN2901_BLANK-UK11"] }
}
}
distinct_on: { selector: item_group_id }
) {
page(page_size: 25, page_number: 1) {
rows {
record {
item_group_id
}
}
page_info {
has_next_page
}
}
}
}
}Filters
Solenya v1 uses a typed filter AST via filter_term. Every filter condition is wrapped in a where clause that names the field explicitly and applies a typed operator.
Filter Architecture
ItemFilterInput ← root input (@oneOf: where | and | or | not)
└─ where: ItemFieldFilterInput
└─ record: ItemRecordFilterInput
├─ brand: StringFilterInput (eq, in, contains, like)
├─ availability: StringFilterInput
├─ size: StringFilterInput
├─ product_type: StringFilterInput
├─ promotion_id: StringFilterInput
├─ id: IdFilterInput (eq, in)
├─ item_group_id: IdFilterInput
├─ price: NumericFilterInput (eq, gt, lt)
└─ sale_price: NumericFilterInputFilter Structure
The root ItemFilterInput uses a @oneOf directive, requiring exactly one key per node:
| Key | Description |
|---|---|
where | A single field condition |
and | All conditions must match |
or | Any condition must match |
not | Inverts a condition |
String Field Operators
Applies to: availability, brand, custom_label_3, product_type, promotion_id, size
Equality (eq):
{ where: { record: { brand: { eq: { value: "Nike" } } } } }Inclusion (in):
{ where: { record: { size: { in: { values: ["8", "9", "10"] } } } } }Contains (contains): checks whether a list attribute contains the given values:
{ where: { record: { promotion_id: { contains: { values: ["1", "2"] } } } } }Like Match (like): prefix/wildcard pattern matching:
{ where: { record: { product_type: { like: { value: "Home > Women%" } } } } }ID Field Operators
Applies to: id, item_group_id
{ where: { record: { item_group_id: { eq: { value: "ADI_GN2901_BLANK" } } } } }
{ where: { record: { id: { in: { values: ["ADI_GN2901_BLANK-UK10", "ADI_GN2901_BLANK-UK11"] } } } } }Numeric Field Operators
Applies to: price, sale_price
{ where: { record: { sale_price: { lt: { value: 800 } } } } }
{ where: { record: { sale_price: { gt: { value: 50 } } } } }
{ where: { record: { price: { eq: { value: 99.99 } } } } }Range: combining gt and lt on the same field is implicitly AND-ed:
{ where: { record: { sale_price: { gt: { value: 20 }, lt: { value: 80 } } } } }Greater than or equal / less than or equal: compose using not:
{ not: { where: { record: { sale_price: { lt: { value: 80 } } } } } }Logical Operations
# AND
{
and: [
{ where: { record: { availability: { eq: { value: "in_stock" } } } } }
{ where: { record: { brand: { eq: { value: "Adidas" } } } } }
]
}
# OR
{
or: [
{ where: { record: { brand: { eq: { value: "Nike" } } } } }
{ where: { record: { brand: { eq: { value: "Reebok" } } } } }
]
}
# NOT
{
not: { where: { record: { brand: { eq: { value: "Crocs" } } } } }
}Category Filtering
Use like for product_type prefix matching:
# Department only
{ where: { record: { product_type: { like: { value: "Home > Men%" } } } } }
# Multiple full paths
{
or: [
{ where: { record: { product_type: { like: { value: "Home > Men > Shoes%" } } } } }
{ where: { record: { product_type: { like: { value: "Home > Women > Shoes%" } } } } }
]
}Brand Filtering
{ where: { record: { brand: { in: { values: ["adidas", "Nike", "Diesel"] } } } } }Price Filtering
{ where: { record: { sale_price: { lt: { value: 800 } } } } }Offer ID Filtering
In the Google Product Feed there is no offer_id, but promotion_id is populated as a nullable list of strings containing offer_id values. Filter using contains:
{ where: { record: { promotion_id: { contains: { values: ["40944"] } } } } }Facets
The items field exposes catalog-level facet aggregations as a lazy field resolver on the result set. Facets aggregate across all items matching the filter, independent of pagination, making them ideal for driving sidebar filter counts.
Available Facets
| Facet | Type | Arguments | Description |
|---|---|---|---|
brand | [FacetValue] | top_n: Int | Top N brands by item count |
availability | [FacetValue] | top_n: Int | Availability values by count |
size | [FacetValue] | top_n: Int | Sizes by count |
item_group_id | [FacetValue] | top_n: Int | Top product groups by count |
product_type | [FacetValue] | top_n: Int, level: Int, split: String | Hierarchical taxonomy at specified depth |
price | [FacetBucket] | bucket_size: Float | Price range buckets |
sale_price | [FacetBucket] | bucket_size: Float | Sale price range buckets |
FacetValue has value: String! and count: Int!. FacetBucket has range: String! and count: Int!.
product_type Hierarchy
level | Result for "Home > Men > Shoes" |
|---|---|
1 (default) | "Home" |
2 | "Home > Men" |
3 | "Home > Men > Shoes" |
Fetching Facets Only
query GetFacets {
items {
merchant_feed(
filter_term: { where: { record: { availability: { eq: { value: "in_stock" } } } } }
distinct_on: { selector: item_group_id }
) {
facets {
brand(top_n: 20) {
value
count
}
price(bucket_size: 10.0) {
range
count
}
product_type(level: 1, split: ">") {
value
count
}
}
}
}
}Fetching Facets and a Page Together
query GetFacetsAndPage {
items {
merchant_feed(
rank_with: { multimodal_query: { text: "shoes" } }
filter_term: { where: { record: { availability: { eq: { value: "in_stock" } } } } }
distinct_on: { selector: item_group_id }
) {
facets {
brand(top_n: 15) { value count }
price(bucket_size: 10.0) { range count }
}
page(page_size: 25, page_number: 1) {
rows {
record {
id
title
price
}
metadata {
score
}
}
page_info {
has_next_page
}
}
}
}
}Putting It All Together
Combining text search, personalisation, filtering, facets, and pagination in a single query:
query SearchQuery {
items {
merchant_feed(
rank_with: {
multimodal_query: {
text: "Snazzy street-wear"
user: { personalisation: true }
}
}
distinct_on: { selector: item_group_id }
filter_term: {
and: [
{ where: { record: { availability: { eq: { value: "in_stock" } } } } }
{
or: [
{ where: { record: { brand: { eq: { value: "adidas" } } } } }
{ where: { record: { brand: { in: { values: ["Converse", "Reebok", "Nike"] } } } } }
]
}
{ where: { record: { size: { eq: { value: "10" } } } } }
{ where: { record: { product_type: { like: { value: "Home > Women%" } } } } }
{ where: { record: { sale_price: { lt: { value: 800 } } } } }
]
}
) {
facets {
brand(top_n: 10) { value count }
size { value count }
}
page(page_size: 5, page_number: 2) {
rows {
record {
item_group_id
image_link
brand
size
product_type
sale_price
}
metadata {
score
}
}
page_info {
has_next_page
}
}
}
}
}rank_with.multimodal_query.text: Semantic search query.rank_with.multimodal_query.user.personalisation: Opt in to user-specific ranking.distinct_on: One representative per product group.filter_term: Typed AST filters: in-stock, specific brands, size, category, and price cap.facets: Catalog-level aggregations for sidebar filtering.page: Paginated results with per-row ranking score.page_info.has_next_page: Indicates whether further pages exist.
Your queries are now perfectly pickled and ready to go! 🚀✨