Patterna Docs

Serve scope

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

Developers 9 min read View as Markdown

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"

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
  }'
  • surface: the surface key you created in the panel.
  • mode: personalized, cohort or non_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 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.
  • 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"]
  }'
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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#

StatusCodeWhenWhat your backend does
400bad_requestmissing surface, mode or context.locale; slots outside 1 to 50; more than 200 exclude_item_ids; an unknown fieldfix the request; do not hide it behind a fallback
401unauthorized, wrong_scopemissing, revoked or non-serve keyfix the configuration
404unknown_surfaceno surface with that keyfix the surface key
409surface_offthe surface's status is offshow your own list; this is expected while a surface is off
422mode_conflicta user_id in non_profiled modea consent bug: fix it, never retry with the id
422bad_tester_idX-Patterna-Tester with a value other than 1send 1 or leave the header out
422no_candidatesno published item of the surface's kinds and locale survives the filtersshow your own list and check the catalog sync and filters
429rate_limitedthe key's bucket is emptyshow your own list
5xx, timeoutinternalengine or network troubleshow 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 -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" }
    ]
  }'
  • 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.

StatusCodeWhen
400bad_requestno request_id, 0 or more than 50 interactions, an unknown type, an empty item_id, an unknown field
401unauthorized, wrong_scopewrong key
404unknown_requestno impression with that request_id. The engine waits one write interval for a fresh impression before it answers this.
422unknown_iteman item_id that is not in the slate; details.unknown_item_ids lists them
422displayed_position_requireda device_resort surface and an interaction without displayed_position

See also#

esc
  • Type to search every page, heading and code sample.