Serve scope
Ask for a slate, then report what the user saw, tapped or dismissed.
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.
# 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"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;
}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)
}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 -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
}'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));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())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,cohortornon_profiled. Required. See Consent modes.user_id: the random per-tenant id you minted. Never an email or an identity-provider subject.context.locale: required. Everything else incontextis optional.context.hour_bucket: 0 to 23, andcontext.weekday: 0 (Monday) to 6, both from the device clock.context.subscription_tier(free,premiumor your own tier; withfreethe paywall cooldown applies) andcontext.tenure_days(days since the account was created; send it rounded to 0, 7 or 90). Bad values answer400.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.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 -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"]
}'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"],
});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)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 (armnone). 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 innon_profiledmode (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.context.anchor_item_idis looked up in the whole catalog, whatever its kind, so an article can anchor a product surface. A slot sayspairs_withonly 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, saysmore_about_tag. See Reasons.context.measured: defaulttrue. Sendfalsewhen 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'sfallback.by_anchorlist 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; inpersonalizedandcohortmode membership also comes fromcommunity_joined/community_leftevents. Used for this request only, not stored.deadline_msin the body, or the headerX-Deadline-Ms(10 to 10000; the shorter wins): what is left of your client budget. See Timeouts and the ledger.- Header
X-Patterna-Tester: 1: this user is on your own tester roster. A surface in statustestersserves the ranked list to the request, so Patterna ids never need to be known. Any other value is422 bad_tester_id. - A UUID you mint in
X-Request-Idbecomes the slate'srequest_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#
{
"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) ornone(no running experiment, a shadow request or the testers gate). Render both arms the same way.policy_versionandflow_version: what produced this slate. The control arm's slates carrypolicy_version: "editorial".shown:falsewhen the slate is shorter than the surface'smin_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 loggednot_shownand counts in its arm with no success (intention to treat), in both arms. Abandon on it answers409 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 withfill: trueand 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) orsurface. Absent on ranked slates.fallback:trueonly 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.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 position0. Report this number back, even if you re-sort.reason_keyandreason: the template key and the sentence per locale. Overrides are keyed by reason key; see 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 turnedphase_tagson 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 withPATCH /surfaces/{key}). Then every interaction must carrydisplayed_position, or it is refused with422 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 -sS -X POST https://api.patterna.nl/v1/recommendations/0b6e9f62-5c1e-4f0a-9f43-2d7c2e9b8a10/abandon \
-H "Authorization: Bearer $PATTERNA_SERVE_KEY"Response 200:
{ "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 -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" }
]
}'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" },
]);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
}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 bodyrequest_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'skind; 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 adevice_resortsurface.block: up to 32 characters naming the block that showed the item, when one slate is rendered in two places.type:seen,clickordismiss.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 for budgets, abandonment and intention to treat.
- Flow config reference for what ranks the slate.
- Backend API connector for keys and signing.
- SDK TypeScript and SDK Go for timeouts and fallback.
- API overview for errors, rate limits and request ids.