Solenya logoSolenya

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.

Loading diagram…
  • 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 items query 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

Open in IDE ↗
mutation AddFeed {
  add_feed(feed_url: "https://example.com/feed.xml") {
    url
  }
}

Delete a Feed

Open in IDE ↗
mutation DeleteFeed {
  delete_feed(feed_url: "https://example.com/feed.xml") {
    url
  }
}

Query Feeds

In v1, feed queries return a ResultSet with paginated results:

Open in IDE ↗
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

Open in IDE ↗
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

Open in IDE ↗
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

Open in IDE ↗
mutation DeleteIndex {
  delete_index(index_uuid: "<index_uuid>") {
    uuid
  }
}

Query Indexes

Open in IDE ↗
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

Open in IDE ↗
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

Open in IDE ↗
mutation AddAccountUser {
  add_account_user(email: "user@example.com", scopes: "index:read:*") {
    uuid
    email
    scopes
  }
}

Update Account User Scopes

Open in IDE ↗
mutation UpdateAccountUserScopes {
  update_account_user_scopes(
    account_user_uuid: "<account_user_uuid>"
    scopes: "index:read:*,events:write:*"
  ) {
    uuid
    scopes
  }
}

Disable an Account User

Open in IDE ↗
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 items queries require the Solenya-Index-UUID and Solenya-User-UUID HTTP 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:

Open in IDE ↗
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)

Open in IDE ↗
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
        }
      }
    }
  }
}
Open in IDE ↗
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

Open in IDE ↗
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
        }
      }
    }
  }
}
Open in IDE ↗
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:

Open in Docs ↗
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.

Open in IDE ↗
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

Open in IDE ↗
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:

Open in IDE ↗
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:     NumericFilterInput

Filter Structure

The root ItemFilterInput uses a @oneOf directive, requiring exactly one key per node:

KeyDescription
whereA single field condition
andAll conditions must match
orAny condition must match
notInverts a condition

String Field Operators

Applies to: availability, brand, custom_label_3, product_type, promotion_id, size

Equality (eq):

Open in IDE ↗
{ where: { record: { brand: { eq: { value: "Nike" } } } } }

Inclusion (in):

Open in IDE ↗
{ where: { record: { size: { in: { values: ["8", "9", "10"] } } } } }

Contains (contains): checks whether a list attribute contains the given values:

Open in IDE ↗
{ where: { record: { promotion_id: { contains: { values: ["1", "2"] } } } } }

Like Match (like): prefix/wildcard pattern matching:

Open in IDE ↗
{ where: { record: { product_type: { like: { value: "Home > Women%" } } } } }

ID Field Operators

Applies to: id, item_group_id

Open in IDE ↗
{ 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

Open in IDE ↗
{ 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:

Open in IDE ↗
{ where: { record: { sale_price: { gt: { value: 20 }, lt: { value: 80 } } } } }

Greater than or equal / less than or equal: compose using not:

Open in IDE ↗
{ not: { where: { record: { sale_price: { lt: { value: 80 } } } } } }

Logical Operations

Open in IDE ↗
# 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:

Open in IDE ↗
# 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

Open in IDE ↗
{ where: { record: { brand: { in: { values: ["adidas", "Nike", "Diesel"] } } } } }

Price Filtering

Open in IDE ↗
{ 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:

Open in IDE ↗
{ 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

FacetTypeArgumentsDescription
brand[FacetValue]top_n: IntTop N brands by item count
availability[FacetValue]top_n: IntAvailability values by count
size[FacetValue]top_n: IntSizes by count
item_group_id[FacetValue]top_n: IntTop product groups by count
product_type[FacetValue]top_n: Int, level: Int, split: StringHierarchical taxonomy at specified depth
price[FacetBucket]bucket_size: FloatPrice range buckets
sale_price[FacetBucket]bucket_size: FloatSale price range buckets

FacetValue has value: String! and count: Int!. FacetBucket has range: String! and count: Int!.

product_type Hierarchy

levelResult for "Home > Men > Shoes"
1 (default)"Home"
2"Home > Men"
3"Home > Men > Shoes"

Fetching Facets Only

Open in IDE ↗
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

Open in IDE ↗
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:

Open in IDE ↗
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! 🚀✨