# Serve scope

> Ask for a slate, then report what the user saw, tapped or dismissed.

Source: https://staging.patterna.nl/docs/api-serve

The `serve` scope has two calls. Your backend asks for a slate with `POST /recommendations`. It reports what happened to that slate with `POST /interactions`, using the `request_id` from the slate. Use a `serve` key. Keep it on your backend; never ship it in an app.

## Setup for the code examples

Every example below comes in four tabs. The TypeScript and Go tabs use the SDKs for serve and ingest calls. The SDKs have admin methods too (`applyFlow`, `setSurfaceStatus`, `ledger` and so on), but these tabs call the admin REST API through the small `rest` helper defined here so every request is visible. The Python tab calls the REST API with httpx; the Python SDK in `packages/sdk-py` has the same shape as the other two. The Go snippets run inside a function that has `ctx context.Context` and `pc *patterna.Client` from `newClient()` and returns an `error`; add `time` to the imports where a snippet uses it.

```curl
# Keys come from Connectors or Governance. Each is shown once and starts with pk_s_, pk_i_ or pk_a_.
export PATTERNA_SERVE_KEY="paste-the-serve-key"
export PATTERNA_INGEST_KEY="paste-the-ingest-key"
export PATTERNA_ADMIN_KEY="paste-the-admin-key"
```

```ts
import { PatternaClient } from "patterna-sdk";

export const API_BASE = "https://api.patterna.nl/v1";

// Serve and ingest calls, plus user mode, profile and deletion, go through the SDK.
export const patterna = new PatternaClient({
  baseUrl: API_BASE,
  keys: {
    serve: process.env.PATTERNA_SERVE_KEY,
    ingest: process.env.PATTERNA_INGEST_KEY,
    admin: process.env.PATTERNA_ADMIN_KEY,
  },
});

// Endpoints the SDK leaves out (admin, proof, catalog reads) use plain fetch.
export async function rest<T = unknown>(
  keyEnv: string,
  method: string,
  path: string,
  body?: unknown,
): Promise<T | undefined> {
  const res = await fetch(`${API_BASE}${path}`, {
    method,
    headers: {
      authorization: `Bearer ${process.env[keyEnv]}`,
      ...(body === undefined ? {} : { "content-type": "application/json" }),
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const text = await res.text();
  if (!res.ok) {
    throw new Error(`Patterna ${method} ${path}: ${res.status} ${text} (request id ${res.headers.get("x-request-id")})`);
  }
  return text ? (JSON.parse(text) as T) : undefined;
}
```

```go
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"

	patterna "github.com/payam-h/patterna-sdk/go"
)

const apiBase = "https://api.patterna.nl/v1"

// newClient builds the SDK client: serve and ingest calls, plus user mode, profile and deletion.
func newClient() (*patterna.Client, error) {
	return patterna.New(patterna.Config{
		BaseURL:   apiBase,
		ServeKey:  os.Getenv("PATTERNA_SERVE_KEY"),
		IngestKey: os.Getenv("PATTERNA_INGEST_KEY"),
		AdminKey:  os.Getenv("PATTERNA_ADMIN_KEY"),
		Logger:    slog.Default(),
	})
}

// rest calls the endpoints the SDK leaves out (admin, proof, catalog reads).
func rest(ctx context.Context, keyEnv, method, path string, body, out any) error {
	var rd io.Reader
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil {
			return err
		}
		rd = bytes.NewReader(b)
	}
	req, err := http.NewRequestWithContext(ctx, method, apiBase+path, rd)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv(keyEnv))
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 300 {
		msg, _ := io.ReadAll(resp.Body)
		return fmt.Errorf("patterna %s %s: %d %s (request id %s)", method, path, resp.StatusCode, msg, resp.Header.Get("X-Request-Id"))
	}
	if out == nil || resp.StatusCode == http.StatusNoContent {
		return nil
	}
	return json.NewDecoder(resp.Body).Decode(out)
}
```

```python
import os

import httpx

API_BASE = "https://api.patterna.nl/v1"


def client(key_env: str) -> httpx.Client:
    return httpx.Client(
        base_url=API_BASE,
        headers={"Authorization": f"Bearer {os.environ[key_env]}"},
        timeout=5.0,  # ingest and admin budget; recommend passes its own 150 ms
    )


serve = client("PATTERNA_SERVE_KEY")
ingest = client("PATTERNA_INGEST_KEY")
admin = client("PATTERNA_ADMIN_KEY")
```

## POST /recommendations

Ranks a slate for one surface. The impression is logged with per-position propensities before the response returns.

### Personalized request

```curl
curl -sS -X POST https://api.patterna.nl/v1/recommendations \
  -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "surface": "home_articles",
    "mode": "personalized",
    "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe",
    "context": {
      "locale": "nl",
      "app_version": "3.4.0",
      "platform": "ios",
      "hour_bucket": 8,
      "premium": false
    },
    "slots": 6
  }'
```

```ts
const slate = await patterna.recommend({
  surface: "home_articles",
  mode: "personalized",
  user_id: "u_mfrggzdfmztwq2lknnwg23tpobyxe",
  context: { locale: "nl", app_version: "3.4.0", platform: "ios", hour_bucket: 8, premium: false },
  slots: 6,
});
// Never retried: each call logs an impression. Budget 150 ms.
console.log(slate.request_id, slate.items.map((i) => i.item_id));
```

```go
hour, premium := 8, false
slate, err := pc.Recommend(ctx, patterna.RecommendRequest{
	Surface: "home_articles",
	Mode:    patterna.ModePersonalized,
	UserID:  "u_mfrggzdfmztwq2lknnwg23tpobyxe",
	Context: patterna.RecommendContext{
		Locale:     "nl",
		AppVersion: "3.4.0",
		Platform:   "ios",
		HourBucket: &hour,
		Premium:    &premium,
	},
	Slots: 6,
})
if err != nil {
	return err // in a request path use RecommendWithFallback instead
}
fmt.Println(slate.RequestID, slate.ItemIDs())
```

```python
r = serve.post(
    "/recommendations",
    json={
        "surface": "home_articles",
        "mode": "personalized",
        "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe",
        "context": {
            "locale": "nl",
            "app_version": "3.4.0",
            "platform": "ios",
            "hour_bucket": 8,
            "premium": False,
        },
        "slots": 6,
    },
    timeout=0.15,  # never retry: each call logs an impression
)
r.raise_for_status()
slate = r.json()
print(slate["request_id"], [i["item_id"] for i in slate["items"]])
```

- `surface`: the surface key you created in the panel.
- `mode`: `personalized`, `cohort` or `non_profiled`. Required. See [Consent modes](https://staging.patterna.nl/docs/consent-modes).
- `user_id`: the random per-tenant id you minted. Never an email or an identity-provider subject.
- `context.locale`: required. Everything else in `context` is optional.
- `context.hour_bucket`: 0 to 23, and `context.weekday`: 0 (Monday) to 6, both from the device clock.
- `context.subscription_tier` (`free`, `premium` or your own tier; with `free` the paywall cooldown applies) and `context.tenure_days` (days since the account was created; send it rounded to 0, 7 or 90). Bad values answer `400`.
- `context.region`: one of the region names you configured, never a location. What every context field does: [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals).
- `slots`: 1 to 50.

### Non-profiled request

In `non_profiled` mode `user_id` must be absent (a `user_id` here is `422 mode_conflict`) and `session_id` is required.

```curl
curl -sS -X POST https://api.patterna.nl/v1/recommendations \
  -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "surface": "more_to_read",
    "mode": "non_profiled",
    "session_id": "s_k3j2h4g5f6d7s8a9",
    "context": {
      "locale": "nl",
      "platform": "android",
      "anchor_item_id": "doc_1842"
    },
    "exclude_item_ids": ["doc_1842"]
  }'
```

```ts
const slate = await patterna.recommend({
  surface: "more_to_read",
  mode: "non_profiled",
  session_id: "s_k3j2h4g5f6d7s8a9", // no user_id in this mode: the SDK refuses one
  context: { locale: "nl", platform: "android", anchor_item_id: "doc_1842" },
  exclude_item_ids: ["doc_1842"],
});
```

```go
slate, err := pc.Recommend(ctx, patterna.RecommendRequest{
	Surface:   "more_to_read",
	Mode:      patterna.ModeNonProfiled,
	SessionID: "s_k3j2h4g5f6d7s8a9", // no UserID in this mode: the SDK refuses one
	Context: patterna.RecommendContext{
		Locale:       "nl",
		Platform:     "android",
		AnchorItemID: "doc_1842",
	},
	ExcludeItemIDs: []string{"doc_1842"},
})
if err != nil {
	return err
}
fmt.Println(slate.RequestID)
```

```python
r = serve.post(
    "/recommendations",
    json={
        "surface": "more_to_read",
        "mode": "non_profiled",
        "session_id": "s_k3j2h4g5f6d7s8a9",  # no user_id in this mode
        "context": {"locale": "nl", "platform": "android", "anchor_item_id": "doc_1842"},
        "exclude_item_ids": ["doc_1842"],
    },
    timeout=0.15,
)
r.raise_for_status()
slate = r.json()
```

- `session_id`: a rotating id. It is the only identifier in this mode.
- `context.anchor_item_id`: the item the user is reading, for item-to-item surfaces such as More to read.
- `exclude_item_ids`: up to 200 ids that must not appear.

Other optional fields:

- `context.candidate_kinds`: limit the item kinds for this request.
- `context.premium`: whether the user has premium; overrides the stored profile for premium filters.
- `shadow: true`: rank and log, but mark as not shown (arm `none`). The panel preview uses it.
- `assignment_unit`: exactly 64 hex characters, the hex HMAC-SHA256 of your own user id under a key you hold. Used only in `non_profiled` mode (ignored otherwise), only for the holdout arm and the ledger's unit, so one person keeps one arm across rotating session ids. See [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors).
- `context.anchor_item_id` is looked up in the whole catalog, whatever its kind, so an article can anchor a product surface. A slot says `pairs_with` only where it is true: a product under a recipe (a document in a collection whose name contains recipe, recept, maaltijd or meal) or under a workout (workout, training, oefening, exercise), or a document under a community; each pairing has its own default text ("Pairs with this recipe", "Pairs with this workout", "Read in this community"). Any other cross-kind anchor, such as a product under an ordinary article, says `more_about_tag`. See [Reasons](https://staging.patterna.nl/docs/reasons).
- `context.measured`: default `true`. Send `false` when the user did not allow usage analytics, so your app will not report seen or taps. The slate is served as usual but left out of the ledger, the power curve and learning, in both arms.
- `context.control_item_ids` (up to 50): your own editorial list for this page, in order (for example an article's pairs-with products, or the editor's related articles). No surface setting is needed. The control arm serves it as its slate (`control_source: request`), with the anchor, `exclude_item_ids`, joined communities, candidate kinds, premium and locale applied; newest items that pass fill the rest. Without it, control serves the surface's `fallback.by_anchor` list for the anchor, else the surface's own list. The treatment arm uses it only to fill a short slate. Send it on every request to the surface, in both arms: you do not know the arm in advance.
- `context.member_of` (up to 500): the community ids the user already joined, for requests without an identity (`non_profiled`). The engine leaves them out of the slate in every mode; in `personalized` and `cohort` mode membership also comes from `community_joined` / `community_left` events. Used for this request only, not stored.
- `deadline_ms` in the body, or the header `X-Deadline-Ms` (10 to 10000; the shorter wins): what is left of your client budget. See [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger).
- Header `X-Patterna-Tester: 1`: this user is on your own tester roster. A surface in status `testers` serves the ranked list to the request, so Patterna ids never need to be known. Any other value is `422 bad_tester_id`.
- A UUID you mint in `X-Request-Id` becomes the slate's `request_id`.

Locale: `context.locale` is matched on its base language: `nl-NL`, `nl_nl` and `nl` all mean `nl`, against every locale an item carries. When no published item of the surface's kinds has that language, the engine serves from every locale rather than nothing.

### Response 200

```json
{
  "request_id": "0b6e9f62-5c1e-4f0a-9f43-2d7c2e9b8a10",
  "surface": "home_articles",
  "arm": "treatment",
  "policy_version": "linear-2026-10-13",
  "flow_version": 3,
  "fallback": false,
  "latency_ms": 41,
  "items": [
    {
      "item_id": "doc_1842",
      "kind": "document",
      "position": 0,
      "reason_key": "matches_goals",
      "reason": {
        "nl": "Past bij je doelen",
        "en": "Matches your goals"
      },
      "generator": "content_affinity",
      "score": 0.82,
      "phase_fit": ["luteal"]
    }
  ]
}
```

- `request_id`: echo it in `/interactions`.
- `arm`: `treatment`, `control` (the holdout) or `none` (no running experiment, a shadow request or the testers gate). Render both arms the same way.
- `policy_version` and `flow_version`: what produced this slate. The control arm's slates carry `policy_version: "editorial"`.
- `shown`: `false` when the slate is shorter than the surface's `min_slots`. Hide the block and do not call abandon: the engine has logged it as not shown. Such a slate comes back with no items (`items: []`), is logged `not_shown` and counts in its arm with no success (intention to treat), in both arms. Abandon on it answers `409 not_shown`.
- `fill_count`: how many trailing positions were filled from the editorial list (the list control would serve) because the ranked candidates ran out; absent when 0. Each filled slot is logged with `fill: true` and propensity 1 (see the trace). Control is padded with newest items the same way.
- `control_source`: on editorial slates only (control, and the fallback list), where the list came from: `request` (`context.control_item_ids`), `by_anchor` (`fallback.by_anchor`) or `surface`. Absent on ranked slates.
- `fallback`: `true` only when the safety net answered: the engine ran out of budget, hit storage trouble, or the surface has no active flow. A control slate is the editorial list and is **not** a fallback (`fallback: false`). See [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger).
- `item_id`: your own external id. Hydrate it on your side (title, premium redaction, locale, status).
- `position`: the slot Patterna served. Positions start at 0: `items[0]` has position `0`. Report this number back, even if you re-sort.
- `reason_key` and `reason`: the template key and the sentence per locale. Overrides are keyed by reason key; see [Reason keys](https://staging.patterna.nl/docs/reference-reason-keys).
- `phase_fit`: tags an app may use to re-sort on the device. Never used on the server. Sent only when the tenant turned `phase_tags` on in Settings; off by default.

### Errors

| Status | Code | When | What your backend does |
|---|---|---|---|
| `400` | `bad_request` | missing `surface`, `mode` or `context.locale`; `slots` outside 1 to 50; more than 200 `exclude_item_ids`; an unknown field | fix the request; do not hide it behind a fallback |
| `401` | `unauthorized`, `wrong_scope` | missing, revoked or non-serve key | fix the configuration |
| `404` | `unknown_surface` | no surface with that key | fix the surface key |
| `409` | `surface_off` | the surface's status is `off` | show your own list; this is expected while a surface is off |
| `422` | `mode_conflict` | a `user_id` in `non_profiled` mode | a consent bug: fix it, never retry with the id |
| `422` | `bad_tester_id` | `X-Patterna-Tester` with a value other than `1` | send `1` or leave the header out |
| `422` | `no_candidates` | no published item of the surface's kinds and locale survives the filters | show your own list and check the catalog sync and filters |
| `429` | `rate_limited` | the key's bucket is empty | show your own list |
| `5xx`, timeout | `internal` | engine or network trouble | show your own list |

The Go SDK's `RecommendWithFallback` returns an error only for bugs it catches before sending (a consent violation, invalid input, a missing key). Every other failure, including a `4xx` from the engine, falls back and leaves the error in `Cause`. Log `Cause` and alert on `400`, `401`, `404` and `mode_conflict`, so a bug does not hide behind your own list.

### Rules

- Never retry. Each call logs an impression.
- Give up after 150 ms and show your own list. Do not report interactions for your own list; report the abandoned request instead (below). The SDKs do this.
- A surface under an experiment must not be re-sorted on the device, unless the surface has `device_resort: true` (set with `PATCH /surfaces/{key}`). Then every interaction must carry `displayed_position`, or it is refused with `422 displayed_position_required`.

### One slate, several blocks

When one screen shows recommendations in two places, for example one product at the top of an article and a carousel of six at the end, ask for one slate of seven and split it: position 0 goes to the top block, positions 1 to 6 to the carousel. Keep reporting the slate positions. One request keeps the items unique across both blocks and logs one impression with exact propensities. Send `block` (for example `top` or `end`) with each interaction so the trace shows where it happened; the ledger can also give a row per block (`by=block`). Set `min_slots` on the surface to the smallest slate worth showing (usually 1): a slate shorter than that comes back with `shown: false` and you hide the block. If the end block has nothing at positions 1 to 6, hide only that block.

## POST /recommendations/{request_id}/abandon

Tells Patterna your client gave up on a request and showed its own list. Send it after a timeout when you know the request id (you sent it as `X-Request-Id`), or when your app hid a block it received (for example hydration dropped items below what you show). Never for a slate answered with `shown: false`: that answers `409 not_shown`. No body. Idempotent.

```curl
curl -sS -X POST https://api.patterna.nl/v1/recommendations/0b6e9f62-5c1e-4f0a-9f43-2d7c2e9b8a10/abandon \
  -H "Authorization: Bearer $PATTERNA_SERVE_KEY"
```

Response `200`:

```json
{ "request_id": "0b6e9f62-5c1e-4f0a-9f43-2d7c2e9b8a10", "arm": "treatment", "fallback": true, "client_abandoned": true }
```

The impression is flagged `fallback: true, client_abandoned: true`. It stays in its arm (intention to treat) and counts in the fallback rate. `RecommendWithFallback` reports it for you (`Abandon` / `AbandonSync` in Go). Errors: `401`, `404 unknown_request`.

## POST /interactions

Reports seen, click and dismiss for one slate. Up to 50 interactions per call.

```curl
curl -sS -X POST https://api.patterna.nl/v1/interactions \
  -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "request_id": "0b6e9f62-5c1e-4f0a-9f43-2d7c2e9b8a10",
    "interactions": [
      { "item_id": "doc_1842", "kind": "document", "position": 0, "type": "seen", "occurred_at": "2026-10-13T08:12:03Z" },
      { "item_id": "doc_1842", "kind": "document", "position": 0, "type": "click", "occurred_at": "2026-10-13T08:12:09Z" },
      { "item_id": "doc_0977", "kind": "document", "position": 2, "type": "dismiss", "occurred_at": "2026-10-13T08:12:15Z" }
    ]
  }'
```

```ts
await patterna.reportInteractions("0b6e9f62-5c1e-4f0a-9f43-2d7c2e9b8a10", [
  { item_id: "doc_1842", kind: "document", position: 0, type: "seen", occurred_at: "2026-10-13T08:12:03Z" },
  { item_id: "doc_1842", kind: "document", position: 0, type: "click", occurred_at: "2026-10-13T08:12:09Z" },
  { item_id: "doc_0977", kind: "document", position: 2, type: "dismiss", occurred_at: "2026-10-13T08:12:15Z" },
]);
```

```go
at := func(s string) time.Time { t, _ := time.Parse(time.RFC3339, s); return t }
// ReportInteractions (no Sync) queues the report and returns at once; use it in a request path.
err := pc.ReportInteractionsSync(ctx, "0b6e9f62-5c1e-4f0a-9f43-2d7c2e9b8a10", []patterna.Interaction{
	{ItemID: "doc_1842", Kind: "document", Position: 0, Type: patterna.InteractionSeen, OccurredAt: at("2026-10-13T08:12:03Z")},
	{ItemID: "doc_1842", Kind: "document", Position: 0, Type: patterna.InteractionClick, OccurredAt: at("2026-10-13T08:12:09Z")},
	{ItemID: "doc_0977", Kind: "document", Position: 2, Type: patterna.InteractionDismiss, OccurredAt: at("2026-10-13T08:12:15Z")},
})
if err != nil {
	return err
}
```

```python
r = serve.post(
    "/interactions",
    json={
        "request_id": "0b6e9f62-5c1e-4f0a-9f43-2d7c2e9b8a10",
        "interactions": [
            {"item_id": "doc_1842", "kind": "document", "position": 0, "type": "seen", "occurred_at": "2026-10-13T08:12:03Z"},
            {"item_id": "doc_1842", "kind": "document", "position": 0, "type": "click", "occurred_at": "2026-10-13T08:12:09Z"},
            {"item_id": "doc_0977", "kind": "document", "position": 2, "type": "dismiss", "occurred_at": "2026-10-13T08:12:15Z"},
        ],
    },
)
r.raise_for_status()  # 202, no body
```

- `request_id`: from the slate.
- `kind`: the item's kind (`document`, `community`, `event`, `product`). The engine takes the kind and the served position from the logged slate and ignores the client's `kind`; send it anyway, it costs nothing and helps when you read your own logs.
- `position`: the served slot, from 0.
- `displayed_position`: where the app showed the item, only when it re-sorted the slate on the device. Required on a `device_resort` surface.
- `block`: up to 32 characters naming the block that showed the item, when one slate is rendered in two places.
- `type`: `seen`, `click` or `dismiss`.
- `occurred_at`: optional; defaults to the time the engine receives the report.

Response: `202 Accepted`, no body. A repeat of the same request id, item and type is ignored.

| Status | Code | When |
|---|---|---|
| `400` | `bad_request` | no `request_id`, 0 or more than 50 interactions, an unknown `type`, an empty `item_id`, an unknown field |
| `401` | `unauthorized`, `wrong_scope` | wrong key |
| `404` | `unknown_request` | no impression with that `request_id`. The engine waits one write interval for a fresh impression before it answers this. |
| `422` | `unknown_item` | an `item_id` that is not in the slate; `details.unknown_item_ids` lists them |
| `422` | `displayed_position_required` | a `device_resort` surface and an interaction without `displayed_position` |

## See also

- [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger) for budgets, abandonment and intention to treat.
- [Flow config reference](https://staging.patterna.nl/docs/reference-flow-config) for what ranks the slate.
- [Backend API connector](https://staging.patterna.nl/docs/connector-backend-api) for keys and signing.
- [SDK TypeScript](https://staging.patterna.nl/docs/connector-sdk-typescript) and [SDK Go](https://staging.patterna.nl/docs/connector-sdk-go) for timeouts and fallback.
- [API overview](https://staging.patterna.nl/docs/api-overview) for errors, rate limits and request ids.
