# Sero AppKit — server-side personalization

Everything the Sero pixel personalizes in the browser, as JSON for YOUR server: a headless
storefront (Next.js, Nuxt, Remix, Astro), a native app, an e-mail renderer, a chatbot, a POS.
One API key with the `personalization_read` scope, one visitor reference per call.

Base URL `https://cdp.sero.tech/api/v1` · header `X-API-Key` · reference: https://cdp.sero.tech/api/v1/docs.md

## 1. Identify the visitor

The pixel keeps its visitor id in the first-party cookie **`_sero_vid`** (written once the
visitor consented). Read it on the server from the request's cookie header, or in the browser:

```js
sero('getIds', (ids) => {
  // ids.visitor_id  — the same id the pixel sends to /decide (pass it as anonymous_id)
  // ids.session_id
  // ids.consent     — 'consented' | 'rejected' | 'pending'
});
```

Rules of thumb:
- Same `anonymous_id` as the pixel ⇒ the visitor gets the SAME experiment variant server-side.
- A known customer (account page, e-mail, POS) ⇒ pass `email` or `email_sha256` instead.
- No cookie yet (first page view, no consent) ⇒ call without a match: you get the anonymous
  default (bestsellers, no rules). Never block rendering on it.
- Respect consent: personalize server-side only when your own consent logic allows it; the
  `context` call tells you whether the visitor is known/identified.

## 2. TypeScript / JavaScript SDK (`@serotech/appkit`)

Zero dependencies, works in Node 18+, edge runtimes, Next.js route handlers, Nuxt server routes.

```bash
npm i @serotech/appkit
```

```ts
import { createSeroAppKit, visitorIdFromCookie } from '@serotech/appkit';

const sero = createSeroAppKit({ apiKey: process.env.SERO_CDP_API_KEY!, baseUrl: 'https://cdp.sero.tech' });

// Next.js App Router — app/api/recs/route.ts
export async function GET(req: Request) {
  const anonymousId = visitorIdFromCookie(req.headers.get('cookie'));
  const visitor = anonymousId ? { anonymous_id: anonymousId } : {};
  const recs = await sero.recommendations({ visitor, type: 'personal', limit: 8 });
  return Response.json(recs.products);
}
```

```ts
// A product page, server-rendered: similar products + the visitor's context in one go
const [ctx, similar] = await Promise.all([
  sero.context({ visitor }),
  sero.recommendations({ visitor, type: 'similar', product_id: sku, limit: 6 }),
]);
const greeting = ctx.first_name ? `Hoi ${ctx.first_name}!` : 'Welkom!';

// A curated product set ("Winter favorieten"), ranked for this visitor, minus what they bought
const set = await sero.productSet('3f1e…', { visitor, limit: 8, exclude_purchased: true });

// The full decision for a page (A/B tests + blocks) — then REPORT the exposures
const decision = await sero.decide({ visitor, page_url: url, page_type: 'product', product_id: sku });
await sero.track.exposures(visitor, decision);           // one exposure per assigned arm (incl. control)
// later, when a block is clicked: await sero.track.click(visitor, block.source_widget_id ?? block.id)
```

Every method returns typed JSON; `sero.track.*` posts `POST /events` custom events with the same
`anonymous_id` (session id + event id are generated for you). Errors: the client throws
`SeroAppKitError` with `status` + `code`; wrap calls in your own fallback so a Sero outage never
blocks a page.

### Nuxt

```ts
// server/api/recs.get.ts
export default defineEventHandler(async (event) => {
  const anonymousId = visitorIdFromCookie(getHeader(event, 'cookie'));
  const { products } = await sero.recommendations({
    visitor: anonymousId ? { anonymous_id: anonymousId } : {},
    type: 'personal',
  });
  return products;
});
```

### Shopify (App Proxy)

Shopify themes cannot call third-party APIs with a secret key; route the call through an App
Proxy (`/apps/sero/recs`) handled by your app's backend with the snippet above, and pass the
cookie along. The pixel's `_sero_vid` cookie is first-party on your shop domain, so it reaches
the proxy request.

## 3. PHP SDK (Magento 2, WooCommerce, Laravel)

```bash
composer require sero/appkit
```

```php
use Sero\AppKit\SeroAppKit;

$sero = new SeroAppKit(getenv('SERO_CDP_API_KEY'), 'https://cdp.sero.tech');
$visitor = SeroAppKit::visitorFromCookie($_COOKIE);   // ['anonymous_id' => …] or []

$recs = $sero->recommendations($visitor, ['type' => 'similar', 'product_id' => $sku, 'limit' => 6]);
foreach ($recs['products'] as $p) { /* $p['name'], $p['price_cents'], $p['url'], $p['image_url'] */ }

$set = $sero->productSet($setId, $visitor, ['limit' => 8, 'exclude_purchased' => true]);
$ctx = $sero->context($visitor);              // $ctx['first_name'], $ctx['budget_band'], …
$decision = $sero->decide($visitor, ['page_url' => $url, 'page_type' => 'product', 'product_id' => $sku]);
$sero->trackExposures($visitor, $decision);   // report the assigned arms
```

Magento 2: call it from a block/ViewModel and cache the result per visitor for a minute; the
SDK never throws on an unknown visitor. WooCommerce: a shortcode or a template hook with the
same calls.

## 4. Endpoints (for any language)

```
POST https://cdp.sero.tech/api/v1/personalize/context
POST https://cdp.sero.tech/api/v1/personalize/product-sets/{id}
POST https://cdp.sero.tech/api/v1/personalize/recommendations
POST https://cdp.sero.tech/api/v1/personalize/decide
GET  https://cdp.sero.tech/api/v1/personalize/product-sets
```
Body: `{ "visitor": { "anonymous_id" | "email" | "email_sha256" | "visitor_id" }, …params }`.
Responses are `private, no-store`; cache them per visitor on your side for ≤60 s if you need to.

Feedback events (`POST /events`, `event_type: "custom"`, same `anonymous_id`):
- `experiment_exposure` — `properties: { event_name, experiment_id, variant_id, device? }` — for EVERY assigned arm
- `widget_click` — `properties: { event_name, widget_id }`
- `goal_click` — `properties: { event_name, goal_id }`

## 5. Guardrails

- Never put an e-mail address in a URL or a log; use `email_sha256` (lowercase SHA-256 of the
  lowercased, trimmed address) when in doubt.
- The context is the SAFE subset (name, country, bands, categories, declared non-PII attributes).
  There is no way to read raw PII through AppKit.
- Rate limit: 6 000 calls/min per workspace across all keys (`429` + `Retry-After`). Cache per
  visitor and reuse one `decide` per page render.
- An unknown or not-yet-projected visitor is a normal answer (`known: false`), not an error.
