Get early access

Docs

Build with Recapi

Three steps to your first recommendations, with every call in JavaScript, Python, PHP, Java, Go, Swift and Ruby. Your dashboard's quick start shows the same snippets with your own keys.

  1. 1

    Get your keys

    Ask for early access, then copy a public key (rk_pub_…) for browsers and a secret key (rk_sec_…) for your server.

  2. 2

    Send what people do

    Each view, add to cart or purchase, with its item and a level from 1 to 10.

  3. 3

    Show the lists

    Ask for a list where you want it on the page, and see what it brings in your dashboard.

Pick your library

Use a library rather than calling the API yourself. It keeps each user's history and sends it with every request, so lists follow the latest click without a lookup. It batches and retries events, falls back to popular items when Recapi is slow, and credits clicks and purchases to the list that showed the item. Recommendations without it are slower and less relevant, and your dashboard counts no clicks.

Install

Each library is on its language's package registry, with no extra dependency. Your Recapi API also serves each one itself, for servers that can't reach the registry: your dashboard's quick start shows how.

index.html
<script type="module">
  import { createClient } from "https://api.recapi.io/sdk/0.6.1/index.js";
</script>

// or, with a bundler (Vite, webpack…): npm install @recapi/client
import { createClient } from "@recapi/client";
shell
# Python 3.11 or later, from PyPI
pip install recapi
shell
# PHP 8.2 or later, with curl: one file, from Packagist
composer require recapi/client
build.gradle.kts
// Java 11 or later, Kotlin, Android 7 or later: Maven Central, no dependency
dependencies { implementation("io.recapi:recapi:0.2.3") }
shell
# Go 1.24 or later: the module recapi.io/go, no dependency
go get recapi.io/go
Package.swift
// Swift 6, iOS 15 or macOS 12 or later: a Swift package, Foundation only
.package(url: "https://github.com/Blushedface/recapi-swift.git", from: "0.1.4")
// and in your target: .product(name: "Recapi", package: "recapi-swift")
shell
# Ruby 3.1 or later, Rails, Solidus: the gem, standard library only
bundle add recapi
shell
# .NET 8 or later, ASP.NET Core: the NuGet package, standard library only
dotnet add package Recapi

Create a client

In a browser, use a public key. On your server, use a secret key: it never leaves your server. Create one client per page or per process.

app.js
const recapi = createClient({
  apiKey: "rk_pub_…",
  baseUrl: "https://api.recapi.io"
});
app.py
import recapi

client = recapi.Client("rk_sec_…", "https://api.recapi.io")
# Several processes or servers? Share each user's state between them:
# recapi.Client(…, store=redis.Redis()), or Django's cache.
# In async handlers (FastAPI), use recapi.AsyncClient with the same arguments.
index.php
require __DIR__ . '/vendor/autoload.php';

// SessionStore keeps each visitor's history in their PHP session.
session_start();
$recapi = new Recapi\Client('rk_sec_…', 'https://api.recapi.io', store: new Recapi\SessionStore());
App.java
import io.recapi.*;

Client recapi = new Client("rk_sec_…", "https://api.recapi.io");
// An Android app takes a public key (rk_pub_…), and calls recommend() off the main thread.
// It keeps each visitor's history across launches: Client.builder(…).store(new FileStore(dir)).build()
// Several servers? Share each user's state: Client.builder(…).store(yourRedisStore).build()
main.go
import recapi "recapi.io/go"

client, err := recapi.New("rk_sec_…", "https://api.recapi.io", recapi.Options{})
defer client.Close()   // sends the queued events at exit
// Several servers? Share each user's state: recapi.Options{Store: yourRedisStore}
App.swift
import Recapi

let recapi = try RecapiClient(apiKey: "rk_sec_…", baseURL: "https://api.recapi.io")
// An iOS app takes a public key (rk_pub_…). At exit, await recapi.close() sends the queued events.
// It keeps each visitor's history across launches: RecapiClient(…, store: FileStore())
// Several servers? Share each user's state: RecapiClient(…, store: yourRedisStore)
config/initializers/recapi.rb
require "recapi"

RECAPI = Recapi::Client.new("rk_sec_…", "https://api.recapi.io", store: Rails.cache)
# One client per process: it sends the queued events at exit.
Program.cs
using Recapi;

builder.Services.AddSingleton(_ => new RecapiClient("rk_sec_…", "https://api.recapi.io"));
// One client per process, disposed at shutdown: it sends the queued events then.
// Several servers? Share each user's state: new RecapiOptions { Store = yourRedisStore }

Describe your items

Optional, and worth it: tags let you ask for a list from one category, and help new items get shown. Send up to 1,000 items per call, from your server. On Shopify, upload your products export in the dashboard instead.

app.js
// Items need a secret key, so they are sent from your server:
// see the Python, PHP, Java, Go, Swift, Ruby or C# tab.
app.py
client.upsert_items([
    {"item_id": "item_4471", "tags": ["sports"], "title": "Cup final highlights"},
])
index.php
$recapi->upsertItems([
    ['item_id' => 'item_4471', 'tags' => ['sports'], 'title' => 'Cup final highlights'],
]);
App.java
recapi.upsertItems(List.of(
    Map.of("item_id", "item_4471", "tags", List.of("sports"), "title", "Cup final highlights")));
main.go
n, err := client.UpsertItems(ctx, []recapi.CatalogItem{
    {ItemID: "item_4471", Tags: []string{"sports"}, Title: "Cup final highlights"},
})
App.swift
let n = try await recapi.upsertItems([
    CatalogItem(itemID: "item_4471", tags: ["sports"], title: "Cup final highlights"),
])
app.rb
RECAPI.upsert_items([
  { item_id: "item_4471", tags: ["sports"], title: "Cup final highlights" }
])
Catalog.cs
var n = await recapi.UpsertItemsAsync(new[] {
    new CatalogItem { ItemId = "item_4471", Tags = new[] { "sports" }, Title = "Cup final highlights" },
});

Send what people do

Each time someone interacts with an item, send the item and a level. The call returns at once: the library queues the event and sends it in the background.

Levels go from -1 to 10: the bigger the interaction, the higher the level.

  • -1: not interested, such as a "not interested" button. The item is not shown to them again until they interact with it. Never use it for skips.
  • 0: shown in a list, nothing more.
  • 1 to 2: light interest, short of opening the item, such as a quick view or a hover.
  • 3: clicked or opened, including a click on a recommendation.
  • 4 to 9: a deeper interaction, higher as it matters more: 5 for an add to cart or an article read to the end.
  • 10: the biggest interaction, such as a purchase.

On an item from a recommendation, any level from 1 counts as a click on that list, and from 5 as a conversion (you can change that level in your dashboard's settings). So send 0, not 1, for items that are only shown.

app.js
recapi.track("item_4471", 3);    // a click
recapi.track("item_4471", 10);   // a purchase
recapi.track("item_9120", -1);   // not interested

// Short-lived jobs and serverless handlers: send the queue before returning
// (Cloudflare Workers: ctx.waitUntil(recapi.flush()), Next.js: after(() => recapi.flush())).
await recapi.flush();
app.py
client.track("item_4471", 3, user_id="u_812")    # a click
client.track("item_4471", 10, user_id="u_812")   # a purchase
client.track("item_9120", -1, user_id="u_812")   # not interested

# Visitors who are not logged in: a random id kept in their session.
client.track("item_4471", 3, anon_id=session["recapi_anon"])

# Short-lived jobs and serverless handlers: send the queue before returning.
client.flush()
index.php
$recapi->track('item_4471', 3, userId: 'u_812');    // a click
$recapi->track('item_4471', 10, userId: 'u_812');   // a purchase
$recapi->track('item_9120', -1, userId: 'u_812');   // not interested

// Visitors who are not logged in: a random id kept in their session.
$_SESSION['recapi_anon'] ??= bin2hex(random_bytes(16));
$recapi->track('item_4471', 3, anonId: $_SESSION['recapi_anon']);
App.java
recapi.track(new Event("item_4471", 3).userId("u_812"));    // a click
recapi.track(new Event("item_4471", 10).userId("u_812"));   // a purchase
recapi.track(new Event("item_9120", -1).userId("u_812"));   // not interested

// Visitors who are not logged in: a random id kept in their session.
recapi.track(new Event("item_4471", 3).anonId(visitorId));

// Short-lived jobs, or an Android activity's onStop (off the main thread): send the queue.
recapi.flush();
main.go
client.Track(ctx, recapi.Event{ItemID: "item_4471", Level: 3, UserID: "u_812"})    // a click
client.Track(ctx, recapi.Event{ItemID: "item_4471", Level: 10, UserID: "u_812"})   // a purchase
client.Track(ctx, recapi.Event{ItemID: "item_9120", Level: -1, UserID: "u_812"})   // not interested

// Visitors who are not logged in: a random id kept in their session.
client.Track(ctx, recapi.Event{ItemID: "item_4471", Level: 3, AnonID: visitorID})

// Short-lived jobs and serverless handlers: send the queue before returning.
client.Flush()
App.swift
try recapi.track(itemID: "item_4471", level: 3, userID: "u_812")    // a click
try recapi.track(itemID: "item_4471", level: 10, userID: "u_812")   // a purchase
try recapi.track(itemID: "item_9120", level: -1, userID: "u_812")   // not interested

// Visitors who are not logged in: a random id kept in their session.
try recapi.track(itemID: "item_4471", level: 3, anonID: visitorID)

// Short-lived jobs, or an app going to the background: send the queue.
try await recapi.flush()
app.rb
RECAPI.track("item_4471", 3, user_id: "u_812")    # a click
RECAPI.track("item_4471", 10, user_id: "u_812")   # a purchase
RECAPI.track("item_9120", -1, user_id: "u_812")   # not interested

# Visitors who are not logged in: a random id kept in their session.
RECAPI.track("item_4471", 3, anon_id: session[:recapi_anon])

# Short-lived jobs: send the queue before returning.
RECAPI.flush
Shop.cs
await recapi.TrackAsync(new RecapiEvent { ItemId = "item_4471", Level = 3, UserId = "u_812" });    // a click
await recapi.TrackAsync(new RecapiEvent { ItemId = "item_4471", Level = 10, UserId = "u_812" });   // a purchase
await recapi.TrackAsync(new RecapiEvent { ItemId = "item_9120", Level = -1, UserId = "u_812" });   // not interested

// Visitors who are not logged in: a random id kept in their session.
await recapi.TrackAsync(new RecapiEvent { ItemId = "item_4471", Level = 3, AnonId = visitorId });

// Short-lived jobs: send the queue before returning.
await recapi.FlushAsync();

Show recommendations

Ask for a list where you show it. If Recapi is slow or failing, you get popular items instead (fallback is true), and an empty list if it is down: hide the slot then. No error handling needed. Clicks on the items are credited to the list by themselves.

app.js
const { items } = await recapi.recommend({
  placement: "product_page", count: 8, tagsAny: ["sports"]
});
render(items);   // [{ itemId, score, slot }]

// On pages without a user:
const popular = await recapi.popular({ count: 8 });
app.py
result = client.recommend(user_id="u_812", placement="product_page", count=8, tags_any=["sports"])
item_ids = [item.item_id for item in result.items]

# On pages without a user:
popular = client.popular(count=8)
index.php
$result = $recapi->recommend(userId: 'u_812', placement: 'product_page', count: 8, tagsAny: ['sports']);
$itemIds = array_map(fn ($item) => $item->itemId, $result->items);

// On pages without a user:
$popular = $recapi->popular(count: 8);
App.java
Recommendations result = recapi.recommend(new RecommendRequest()
    .userId("u_812").placement("product_page").count(8).tagsAny(List.of("sports")));
for (Item item : result.items()) render(item.itemId());

// On pages without a user:
List<Item> popular = recapi.popular(8);
main.go
result, err := client.Recommend(ctx, recapi.Request{
    UserID: "u_812", Placement: "product_page", Count: 8, TagsAny: []string{"sports"}})
for _, item := range result.Items {
    render(item.ItemID)
}

// On pages without a user:
popular, err := client.Popular(ctx, recapi.PopularRequest{Count: 8})
App.swift
let result = try await recapi.recommend(RecommendRequest(
    userID: "u_812", count: 8, placement: "product_page", tagsAny: ["sports"]))
for item in result.items { render(item.itemID) }

// On pages without a user:
let popular = try await recapi.popular(count: 8)
app.rb
result = RECAPI.recommend(user_id: "u_812", placement: "product_page", count: 8, tags_any: ["sports"])
item_ids = result.items.map(&:item_id)

# On pages without a user:
popular = RECAPI.popular(count: 8)
Shop.cs
var result = await recapi.RecommendAsync(new RecommendRequest {
    UserId = "u_812", Placement = "product_page", Count = 8, TagsAny = new[] { "sports" } });
foreach (var item in result.Items) Render(item.ItemId);

// On pages without a user:
var popular = await recapi.PopularAsync(new PopularRequest { Count = 8 });

A page cache (a CDN, WP Rocket, Django's cache_page) would show one visitor's list to everyone. Render the list in the browser with the JavaScript library, or from a part of the page the cache leaves out.

Log in and log out

When a visitor logs in, tell Recapi who they are: what they did before logging in keeps counting.

app.js
recapi.identify("u_812");   // at login
recapi.reset();             // at logout
app.py
client.identify(session["recapi_anon"], "u_812")   # at login
index.php
$recapi->identify($_SESSION['recapi_anon'], 'u_812');   // at login
App.java
recapi.identify(visitorId, "u_812");   // at login
main.go
client.Identify(ctx, visitorID, "u_812")   // at login
App.swift
try recapi.identify(anonID: visitorID, userID: "u_812")   // at login
app.rb
RECAPI.identify(session[:recapi_anon], "u_812")   # at login
Shop.cs
await recapi.IdentifyAsync(visitorId, "u_812");   // at login

Privacy

In the browser, connect the library to your cookie banner: without consent it stores nothing on the device. To erase a user's data, make one call from your server.

app.js
const recapi = createClient({ apiKey, baseUrl, consent: cmp.hasConsent("personalisation") });
cmp.onChange((granted) => recapi.setConsent(granted));

// Erasing a user needs a secret key: see the Python, PHP, Java, Go, Swift, Ruby or C# tab.
app.py
client.delete_user("u_812")
index.php
$recapi->deleteUser('u_812');
App.java
recapi.deleteUser("u_812");
main.go
err := client.DeleteUser(ctx, "u_812")
App.swift
try await recapi.deleteUser("u_812")
app.rb
RECAPI.delete_user("u_812")
Shop.cs
await recapi.DeleteUserAsync("u_812");

Shopify

No code to write: paste two snippets, one in your theme and one as a custom pixel, then upload your products and orders exports. Your dashboard's quick start shows both snippets with your keys, step by step.