---
title: Custom Website
description: "This guide integrates your shop with *Releva* using our JavaScript library and a few server-side calls. It is written for shops on a custom stack — if you run Shopify, WooCommerce, Magento, PrestaShop, OpenCart or CS-Cart, install the plugin for…"
url: "https://releva.ai/documentation/integrations/front-end-integration/"
updated: "2026-09-23"
category: Integrations
---

# Custom Website

This guide integrates your shop with *Releva* using our JavaScript library and a few server-side calls. It is written for shops on a custom stack — if you run Shopify, WooCommerce, Magento, PrestaShop, OpenCart or CS-Cart, install the plugin for your platform instead and skip this entirely.

The work splits into three independent parts. They are usually done by different people, and only the first two are needed for Releva to start producing results:

| Part | What it is | Where it runs |
| --- | --- | --- |
| **A. Front-end tracking** | *One* `Releva.push()` call, with a few extra fields on certain page types. This is what tells Releva what each visitor is doing. | Your storefront, in the browser |
| **B. Catalog & order sync** | Three or four HTTP calls that keep Releva's copy of your catalogue current and report paid orders. | Your backend |
| **C. Consent & lifecycle** | Consent signalling, subscribe / unsubscribe, new registrations. Conditional — you implement the ones that apply to you. | Both |

### The minimum viable integration

If you do nothing else, do these four things. They are what the recommendation and personalization algorithms actually need, and together they are a day of work rather than a project:

1. **The base `Releva.push()` call on every page** (Part A) — who the visitor is and what is in their cart.
2. **Product context on product pages** (Part A) — two extra fields on the same call. This is the behavioural signal everything else is built on.
3. **The [Product Create / Update API](https://releva.ai/documentation/catalog/product-update/)** (Part B) — without a catalogue there is nothing to recommend.
4. **The [Cart Paid API](https://releva.ai/documentation/orders-refunds/cart-paid/)** (Part B) — without orders there is no revenue, no attribution and no purchase history.

Everything after that — listing and search context, banners on more page types, subscriptions, the fallback sync — improves results but is not required to go live. One exception: if you operate where GDPR or CCPA applies, the consent function in Part C is a legal requirement, not an enhancement.

### Before you begin: your credentials

Three values appear in this guide. Two of them live on the same screen; the third is per-page. Getting these straight up front saves the most common support ticket we receive.

| Value | Where to find it | Used by | Secret? |
| --- | --- | --- | --- |
| `<yourAccessToken>` | Settings → General → **Access Token** (with a copy button) | Part A only — the `Releva.push()` call in the browser | No. It ships in your page source; that is by design. |
| `<secretKey>` | Settings → General → **Secret Key** (just below the access token) | Part B and the server-side half of Part C, as `Authorization: Bearer <secretKey>` | **Yes.** Never put it in front-end code. |
| `<yourPageToken>` | The **Pages** section — each page you create there has its own token, with a copy button | Part A, in `page.token` | No. |

A page token identifies *a kind of page* (home, product, category, search, cart), not an individual product. Create one page in Releva per page type you want personalized content on, and reuse its token across every URL of that type. A push with no token is accepted and still records behaviour — you simply get no banners or recommenders back for that page.

### Part A — Front-end tracking

There is one call to learn. You make `Releva.push()` on every page with a *context object* describing what the visitor is looking at, and Releva answers with the personalized content configured for that page. On specific page types you add two or three fields to that same object — you are not writing a new integration per page type.

| On this page type | Add to the context |
| --- | --- |
| Every page | `cart`, `profile` (and `wishlist`, if you have one) |
| Product page | `page.token`, `product.id` |
| Category / listing | `page.token`, `page.ids`, `page.categories`, optionally `page.filter` |
| Search results | `page.token`, `page.query`, `page.ids`, optionally `page.filter` |
| Checkout success | `cart.products` = the lines of the order just placed, plus `profile.email` / `profile.phoneNumber` |

#### A1. The base call, on every page

Add this towards the end of the `body` element. Replace every placeholder with real values — and send an empty `products` array when the cart is empty, rather than omitting the cart.

```html
<script>
document.addEventListener('relevaSdkLoaded', function () {
  Releva.push('<yourAccessToken>', {
    cart: {
      products: [
        {
          id: 'productId1',
          price: 12.5,
          currency: 'USD',
          quantity: 2
        }
      ]
    },
    profile: {
      id: '<yourInternalCustomerId>'
    }
  }, function (results) {
    // Called with the personalized content for this page.
  }, function (error) {
    console.error(error);
  });
});
</script>
<script async type="text/javascript"
        src="https://releva.ai/sdk/v0/js/releva-sdk-js.min.js"
        onload="document.dispatchEvent(new Event('relevaSdkLoaded'));"></script>
```

The `onload` attribute and the `relevaSdkLoaded` listener exist only to make sure `Releva.push` runs after the library has loaded. Any other way of guaranteeing that order is fine.

Send `profile` only when you know who the visitor is. An anonymous visitor is tracked perfectly well without it, and Releva merges their history into their profile the moment they log in.

#### A2. Product pages

Same call, two more fields:

```json
{
  "page": {
    "token": "<yourProductPageToken>",
    "categories": ["men/shoes/sport"]
  },
  "product": {
    "id": "productId1"
  }
}
```

#### A3. Category and listing pages

Tell Releva which products the visitor is actually looking at, and which filters they applied:

```json
{
  "page": {
    "token": "<yourListingPageToken>",
    "ids": ["productId1", "productId2", "productId3"],
    "categories": ["men/shoes/sport"],
    "filter": {
      "operator": "and",
      "nested": [
        { "key": "price", "operator": "gte", "value": "10", "action": "include" },
        { "key": "price", "operator": "lte", "value": "50", "action": "include" },
        {
          "operator": "or",
          "nested": [
            { "key": "custom.string.size", "operator": "eq", "value": "42", "action": "include" },
            { "key": "custom.string.size", "operator": "eq", "value": "43", "action": "include" }
          ]
        }
      ]
    }
  }
}
```

#### A4. Search results

Add the query the visitor typed and the ids it returned. Sending `query` also records a search event.

```json
{
  "page": {
    "token": "<yourSearchPageToken>",
    "query": "running shoes",
    "ids": ["productId1", "productId2", "productId3"]
  }
}
```

To record a search that was driven purely by filters with no typed text, send `"query": null` together with the `filter` object. The `locale` you send should describe both the page and the query; in the rare case where they differ, put the query locale in a custom field.

#### A5. The checkout success page

Here `cart.products` changes meaning: send **the lines of the order that was just placed**, not what is still in the basket. Include an email address or a phone number even for guest checkouts — without one the order cannot be attached to a person.

```json
{
  "cart": {
    "products": [
      {
        "id": "productId1",
        "price": 12.5,
        "currency": "USD",
        "quantity": 2
      }
    ]
  },
  "profile": {
    "email": "customer@example.com",
    "phoneNumber": "+15857661234"
  }
}
```

Phone numbers must be in international format — `585-766-1234` in a US shop becomes `+15857661234`. If you cannot convert a number reliably, leave it out.

This front-end call is a convenience, not the record of the sale. The authoritative one is the [Cart Paid API](https://releva.ai/documentation/orders-refunds/cart-paid/) in Part B, which your backend calls with the same order. Both are deduplicated on `orderId`, so sending both is correct and expected.

#### How to confirm Part A works

1. Load a page of your shop with DevTools open, on the **Network** tab, filtered to `push`.
2. You should see one `POST` to `https://releva.ai/api/v0/push/<yourAccessToken>` returning **200**.
3. Open its response. A working call returns a JSON object containing a `userId` — that is Releva's id for this visitor. Once you have configured content for the page, the same response carries the recommenders and banners for it.
4. Check the request payload: the `context` object is exactly what you sent. If a field you expected is missing here, it never reached us.

A 4xx response names the offending field in its `message`. The most common causes are a mistyped access token and a price sent as a string (`"12.50"`) where a number is expected.

### Custom fields, end to end

Custom fields are how your own product attributes — size, colour, brand, material — reach Releva, and they are what filtering, segmentation and recommender rules operate on. They are worth ten minutes now because everything downstream depends on their shape.

There are exactly three types (`string`, `numeric`, `date`), and every field is written as a `key` with an array of `values`. Here is one field — a shoe size — followed all the way through.

**1. You send it with the product** (Part B, Product Create / Update):

```json
{
  "id": "productId1",
  "name": "Trail Runner",
  "listPrice": 120,
  "categories": ["men/shoes/sport"],
  "available": true,
  "custom": {
    "string": [
      { "key": "size", "values": ["42", "43", "44"] },
      { "key": "color", "values": ["red"] }
    ],
    "numeric": [
      { "key": "weightGrams", "values": [310] }
    ]
  }
}
```

**2. You send the visitor's selection with the cart** (Part A) — the same shape, describing what *this* shopper chose:

```json
{
  "id": "productId1",
  "price": 120,
  "currency": "USD",
  "quantity": 1,
  "custom": {
    "string": [
      { "key": "size", "values": ["42"] }
    ]
  }
}
```

**3. You filter on it** by joining the type and the key with dots:

```json
{ "key": "custom.string.size", "operator": "eq", "value": "42", "action": "include" }
```

| You wrote | You filter on |
| --- | --- |
| `custom.string` entry with `"key": "size"` | `custom.string.size` |
| `custom.numeric` entry with `"key": "weightGrams"` | `custom.numeric.weightGrams` |
| `custom.date` entry with `"key": "inPromoUntil"` | `custom.date.inPromoUntil` |

The rule is `custom.<type>.<yourKey>`. Standard fields such as `price`, `available` and `categories` are filtered on by their own name, with no prefix.

Two things worth getting right the first time: keep a key in the same type forever (a size sent as `string` on some products and `numeric` on others produces two unrelated fields), and use the same spelling everywhere — `size` and `Size` are different fields.

### Part B — Catalog & order sync

These run on your server and authenticate with `Authorization: Bearer <secretKey>`. They are ordinary JSON over HTTPS; each one answers `202` with an empty body when accepted.

| Call | When you call it | Needed? |
| --- | --- | --- |
| [Product Create / Update](https://releva.ai/documentation/catalog/product-update/) | Whenever a product changes — price, stock, name, anything. This is how Releva learns your catalogue. | **Required** |
| [Cart Paid](https://releva.ai/documentation/orders-refunds/cart-paid/) | When an order is paid for. | **Required** |
| [Periodical Product Sync](https://releva.ai/documentation/catalog/periodical-product-sync/) | An endpoint *you* build, which Releva calls on a schedule to walk your whole catalogue. It repairs anything a missed update left stale. | Strongly recommended |
| [Refund](https://releva.ai/documentation/orders-refunds/order-refund/) | When an order is refunded, so revenue and attribution are corrected. | If you take refunds |
| [Product Delete](https://releva.ai/documentation/catalog/product-delete/) | When a product is permanently gone. For a product that is merely out of stock, send `available: false` instead. | Optional |

Start with Product Create / Update and Cart Paid. The product ids you send here must be **the same ids** you send in `product.id` and `cart.products[].id` in Part A — that is what ties browsing behaviour to the catalogue. If you sell in several languages or currencies, the id has to be unique per locale; the conventions are on the Product Create / Update page.

#### How to confirm Part B works

1. Send one product and check for `202` with a body of `{}`. A `400` names the field at fault in its `message`; a `402` means the shop is disabled.
2. Find that product in the **Products** section of the admin panel — it appears within about ten seconds — and confirm the price, availability and custom fields look right.
3. Place a test order and confirm it appears in your revenue reporting. Orders are held for about thirty seconds before processing, so allow a minute.

### Part C — Consent, subscriptions & lifecycle

Implement the parts that apply to you.

#### Consent (required under GDPR / CCPA)

If you operate where data protection law applies, pass a `hasConsent` function as the last argument to `Releva.push`. It must return `true` only when the visitor has accepted behavioural tracking for first-party marketing and analytics:

```js
Releva.push('<yourAccessToken>', { /* context */ },
  function (results) { /* ... */ },
  function (error) { /* ... */ },
  {
    hasConsent: function () {
      // Example: a cookie banner that sets `cookielaw=1` on acceptance
      return Releva.getCookie('cookielaw') === '1';
    }
  });
```

#### Subscriptions

- Call the [Unsubscribe API](https://releva.ai/documentation/consent-status/unsubscribe/) when someone opts out of marketing messages, and the [Subscribe API](https://releva.ai/documentation/consent-status/subscribe/) when they opt in. Skip this entirely if you do not use Releva for email or text messages.
- For a sign-up form on the storefront itself, the [front-end subscribe event](https://releva.ai/documentation/consent-status/subscribe-frontend/) is simpler than the server-side call.

#### New registrations

Call the [Profile Register API](https://releva.ai/documentation/profiles-2/profile-register/) when a user creates an account, so the welcome journey can start and the profile carries its registration date.

#### Web push notifications

Skip this unless you plan to send web push. Download [service-worker.min.js](https://releva.ai/sdk/v0/js/service-worker.min.js), save it at the root of your site, and point the SDK at it:

```js
Releva.push('<yourAccessToken>', { /* context */ },
  function (results) { /* ... */ },
  function (error) { /* ... */ },
  {
    serviceWorkerUrl: '/service-worker.min.js',
    hasConsent: function () {
      return Releva.getCookie('cookielaw') === '1';
    }
  });
```

The file must be served from the root of your domain. You may rename it, as long as `serviceWorkerUrl` matches.

### A complete example

If you would rather assemble from one working file than from the sections above, this is every page type in a single snippet. Build the context for the page you are rendering and pass it to one call.

```js
<script>
document.addEventListener('relevaSdkLoaded', function () {
  // 1. Always: who the visitor is and what is in their cart.
  var context = {
    cart: {
      products: window.myShop.cartLines.map(function (line) {
        return {
          id: line.productId,
          price: line.unitPrice,
          currency: window.myShop.currency,
          quantity: line.quantity,
          custom: {
            string: [{ key: 'size', values: [line.size] }]
          }
        };
      })
    }
  };

  if (window.myShop.customer) {
    context.profile = {
      id: window.myShop.customer.id,
      email: window.myShop.customer.email
    };
  }

  // 2. Page-specific fields.
  switch (window.myShop.pageType) {
    case 'product':
      context.page = { token: '<yourProductPageToken>', categories: window.myShop.categories };
      context.product = { id: window.myShop.productId };
      break;

    case 'listing':
      context.page = {
        token: '<yourListingPageToken>',
        ids: window.myShop.visibleProductIds,
        categories: window.myShop.categories
      };
      break;

    case 'search':
      context.page = {
        token: '<yourSearchPageToken>',
        query: window.myShop.searchQuery,
        ids: window.myShop.visibleProductIds
      };
      break;

    case 'checkoutSuccess':
      // cart.products is the ORDER, not the basket - set upstream.
      context.profile = context.profile || {};
      context.profile.email = window.myShop.order.email;
      break;
  }

  Releva.push('<yourAccessToken>', context,
    function (results) {
      // Render recommenders and banners.
    },
    function (error) {
      console.error(error);
    },
    {
      hasConsent: function () {
        return Releva.getCookie('cookielaw') === '1';
      }
    });
});
</script>
<script async type="text/javascript"
        src="https://releva.ai/sdk/v0/js/releva-sdk-js.min.js"
        onload="document.dispatchEvent(new Event('relevaSdkLoaded'));"></script>
```

### Full field reference

Everything the context object accepts. You will not need most of it on day one.

  **context.cart** — the visitor's basket, or the order on a checkout success page

| Field | Type | Description |
| --- | --- | --- |
| `products` | Array[Object] | The products in the cart. Send an empty array when the cart is emptied. |
| `products[].id` | String | The product id, identical to `context.product.id` for the same product and to the id you send to the Product Create / Update API. Must be unique across locales in a multi-language shop. |
| `products[].price` | Float | The unit price paid, as a number. |
| `products[].currency` | ISO-4217 String (Optional) | **Required if you sell in more than one currency.** |
| `products[].quantity` | Integer | Units in the cart. |
| `products[].custom` | Object (Optional) | What the shopper chose — size, colour. See [Custom fields, end to end](#custom-fields). |

   **context.profile** — the logged-in visitor

| Field | Type | Description |
| --- | --- | --- |
| `id` | String | Your own id for this customer. Use the same one everywhere. |
| `custom` | Object (Optional) | Custom profile fields. **Replaces** the stored set — send the full list. |
| `addCustom` | Object (Optional) | Custom fields to add without overwriting the others. |
| `location` | Object (Optional) | `{ "lat": 39.9352959, "lon": 23.5825895 }` in decimal notation. |

   **context.page** — the page being viewed

| Field | Type | Description |
| --- | --- | --- |
| `token` | String (Optional) | The page's token from the Pages section. Omit it and you still get tracking, but no personalized content for this page. |
| `locale` | ISO 639-1 String | The page language, e.g. `en`, `bg`. |
| `currency` | ISO-4217 String (Optional) | **Required if you sell in more than one currency.** |
| `ids` | Array[String] | On a listing or search page, the product ids currently shown. Omit elsewhere. |
| `categories` | Array[String] | The categories currently selected. |
| `query` | String (Optional) | The search text. Sending it also records a search event; `null` records a filter-only search. |
| `custom` | Object (Optional) | Custom-field filters applied to the listing. |
| `filter` | Object (Optional) | The filter applied to the listing — see below. |
| `blocks.tags` | Array[String] (Optional) | Render only product and banner blocks carrying at least one of these tags. |

   **context.product**, **context.wishlist** and **context.company**

| Field | Type | Description |
| --- | --- | --- |
| `product.id` | String | The product being viewed. Set on product pages only. |
| `product.custom` | Object (Optional) | The currently selected options, e.g. the chosen size. |
| `wishlist.products[].id` | String | A product in the visitor's wishlist. |
| `wishlist.products[].custom` | Object (Optional) | The chosen configuration for it. |
| `company.id` | String (Optional) | The company the visitor is acting for. B2B only. |

   **context.page.filter** — filter syntax

A filter is either a group (`operator` plus `nested`) or a condition. Groups nest to any depth.

| Field | Type | Description |
| --- | --- | --- |
| `operator` | String | On a group: `and` (every child must match) or `or` (any child). |
| `nested` | Array | The child conditions or groups. |
| `key` | String | On a condition: a standard field such as `price`, or a custom field written `custom.string.brand`. |
| `operator` | String | `eq`, `lt`, `gt`, `lte`, `gte`, or a range pair — `gte,lte`, `gte,lt`, `gt,lte`, `gt,lt`. |
| `value` | String | The value to compare against. For a range operator, two values separated by a comma. |
| `action` | String | `include`, `exclude`, `boost` (to the top) or `bury` (to the bottom). |
| `weight` | Integer (Optional) | Orders several `boost` or `bury` rules against each other. |

### Are you done?

1. Every page of the shop fires one `POST` to `/api/v0/push/<yourAccessToken>` that returns 200.
2. A product page sends `product.id`, and the id matches one in your Releva catalogue.
3. Adding to and removing from the cart changes `cart.products` on the next push, and emptying it sends an empty array.
4. A test order appears in your revenue reporting, from the Cart Paid call.
5. Your catalogue in the admin panel matches your shop — prices, availability, custom fields.
6. If GDPR or CCPA applies to you, declining the cookie banner makes `hasConsent` return false.

All six passing means the algorithms have what they need. Everything else is refinement.

### All set!

Congratulations — this concludes the integration of your shop. From here, the [Custom Event API](https://releva.ai/documentation/events/custom-event-api/) lets you report anything specific to your business that the standard calls do not cover, and it is the usual next step once the basics are flowing.
