Patterna Docs

Backend API

Call Patterna from your backend with scoped keys, and sign the events that affect ranking.

Developers 9 min read View as Markdown

Your backend talks to Patterna over HTTPS with a scoped API key. The app never holds a key. This page covers the keys, one working call per scope, and event signing.

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 (patterna-sdk 0.2.0, installed from the public repository payam-h/patterna-sdk; not on PyPI) 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.
# Serve keys start with pk_s_, ingest keys with pk_i_, admin keys with pk_a_.
export PATTERNA_SERVE_KEY="pk_s_paste-the-serve-key"
export PATTERNA_INGEST_KEY="pk_i_paste-the-ingest-key"
export PATTERNA_ADMIN_KEY="pk_a_paste-the-admin-key"

Keys and scopes#

Every key has one scope. Pick the smallest scope that does the job.

ScopePrefixWhat it may callTypical holder
servepk_s_POST /recommendations, POST /interactionsthe backend code path that renders a list
ingestpk_i_POST /events, PUT /catalog/items, item reads, status and tags, PUT /users/{user_id}/mode, PUT /users/{user_id}/profile, DELETE /users/{user_id}, the webhooksthe backend, connectors, catalog sync, consent and deletion
adminpk_a_everything above, plus surfaces, flows, experiments, ledger, exports, keys, user export and trace, auditthe panel and staff tooling

An admin key passes every scope. A serve key on an ingest route (or the other way round) gets 401 wrong_scope.

  • You create keys in Connectors or Governance. The secret is shown once. Store it in your secret manager straight away.
  • Patterna keeps only a SHA-256 hash of the key. Lists show the prefix (pk_s_…, pk_i_…, pk_a_…), never the secret.
  • A key has an environment label (production by default). The label is for your own bookkeeping and isolates nothing: a staging key on the production tenant writes production data. For testing, use a separate staging tenant on its own environment; ask us for one.
  • The tenant comes from the key, never from the request body. A tenant_id in a body is ignored.
  • Every request sends Authorization: Bearer <key>. Every response carries X-Request-Id. Log it; support asks for it.

The three user calls (mode, profile, DELETE) take the ingest key, so your backend needs no admin key for consent and deletion. GET /users/{user_id} (export) and the trace need an admin key.

serve: recommend and report#

Ask for a slate. The response holds a request_id you must echo back with the interactions.

curl -sS -X POST "https://patterna-api.orven.am/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", "platform": "ios" },
    "slots": 6
  }'

In non_profiled mode, send session_id and leave out user_id. See Consent modes.

Report what the user saw, tapped or dismissed. position is the slot Patterna served, counted from 0 (items[0] is position 0). Copy kind and position from the slate item. If the app showed the item somewhere else than its served slot, add displayed_position as well.

The engine checks reports against the logged slate: kind and the served position are taken from the slate (what you send is ignored), an item_id that was not in the slate fails the call with 422 unknown_item (details.unknown_item_ids lists them), and on a surface with device_resort on, every interaction needs displayed_position (422 displayed_position_required). When you render one slate in two blocks, send the optional block (up to 32 characters) with each interaction. The ledger can then also give a row per block (by=block).

curl -sS -X POST "https://patterna-api.orven.am/v1/interactions" \
  -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "request_id": "REQUEST_ID_FROM_THE_SLATE",
    "interactions": [
      { "item_id": "doc_123", "kind": "document", "position": 0, "type": "seen",  "occurred_at": "2026-10-13T09:00:00Z" },
      { "item_id": "doc_123", "kind": "document", "position": 0, "type": "click", "occurred_at": "2026-10-13T09:00:04Z" }
    ]
  }'

Budget: Patterna answers in 60 ms p99 from cache and 250 ms live. Call it with a 150 ms timeout and show your own list when it does not answer. Never retry /recommendations: every call logs an impression. The engine's own budget is 120 ms by default. Send your own deadline as the X-Deadline-Ms header or a deadline_ms body field (10 to 10000; the engine answers within the smaller of its budget and your deadline minus a tenth for the network); the SDKs send 120 ms. When you give up on a slate anyway, or decide not to show one you received, call POST /recommendations/{request_id}/abandon: the slate is flagged client_abandoned, stays in its arm with zero taps and counts in the fallback rate. See Timeouts and the ledger.

A surface can set min_slots (PATCH /surfaces/{key}, 0 to 50). When fewer items pass the request's rules (counted before the arm, so both arms hide alike), the response carries shown: false and no items: hide the block, and do not call abandon for it (409 not_shown), because the engine has already logged it as not shown and counts it in its arm as served with zero taps (intention to treat). Set min_slots to 1 for "no recommendations means no block".

Two request fields serve anchored and community surfaces, and two answer fields say what the engine did:

  • context.control_item_ids (up to 50): your own editorial list for this page, in order (for example the article's pairs-with products, or the editor's related articles). No surface setting is needed. The control arm serves it, with the anchor, exclude_item_ids, joined communities, kinds, premium and locale applied and newest items filling the rest. Without it control serves the surface's fallback.by_anchor list for the anchor, else the surface's own list. Send it in both arms; treatment uses it only to fill a short slate.
  • context.member_of (up to 500): the community ids the user already joined, for non_profiled requests. They are left out of the slate in every mode and are not stored; in personalized and cohort mode membership also comes from community_joined / community_left events.
  • fill_count on the slate: when a treatment slate comes up short after its filters, the engine fills it from the editorial list, like control, and says how many slots it filled (absent when 0; each filled slot is logged with fill: true).
  • control_source on editorial slates: request, by_anchor or surface, the list control served.

What to do with an error from /recommendations:

AnswerMeaningWhat to do
timeout, network error, 5xx, 429 rate_limitedPatterna could not answer in timeshow your own list
409 surface_offthe surface is switched off in the panelshow your own list; this is expected while a surface is off
422 no_candidatesno published item of the surface's kinds and locale is left after the filtersshow your own list, then fix the catalog or the flow
400 bad_request (also any unknown JSON field), 401 unauthorized, 401 wrong_scope, 404 unknown_surface, 422 mode_conflicta bug in the callfail loudly in your logs and alerts; showing your list is fine, hiding the bug is not

422 mode_conflict means a non_profiled request carried a user_id. All codes: Error codes.

ingest: events and catalog#

Send canonical events, up to 500 per call. event_id is the idempotency key, so a resend is safe.

curl -sS -X POST "https://patterna-api.orven.am/v1/events" \
  -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      {
        "event_id": "community_joined:c_42:u_mfrggzdfmztwq2lknnwg23tpobyxe",
        "type": "community_joined",
        "occurred_at": "2026-10-13T09:10:00Z",
        "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe",
        "source": "backend",
        "context": { "locale": "nl", "platform": "ios" },
        "props": { "community_id": "c_42", "source": "community_page" }
      }
    ]
  }'

The answer is 202 with accepted, duplicates, rejected (event id and reason) and dropped_fields. A prop outside the event type's allowlist that is not personal data is dropped and counted in dropped_fields (field name → count); the event itself is accepted. Each event is judged alone and the call still answers 202. Each rejected entry has event_id, reason, code and, for PII, fields (the paths, never the values). Codes: invalid_event, pii_rejected (a PII field name such as email or name in props or context, or an email or provider|id user id), invalid_signature, no_signing_secret, unknown_request, unknown_item, displayed_position_required. The response also counts pii_rejected. Unknown fields that are not PII are still dropped into dropped_fields. The batch never fails as a whole for one bad event. The event types are in Event types.

JSON
{ "accepted": 1, "duplicates": 0, "rejected": [], "dropped_fields": { "props.email": 1 } }

Upsert catalog items, up to 500 per call. Send editorial fields only. An attribute with an email, name or ip key fails the call with 422 pii_rejected.

curl -sS -X PUT "https://patterna-api.orven.am/v1/catalog/items" \
  -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {
        "external_id": "doc_123",
        "kind": "document",
        "collection": "recipes",
        "title": "Magnesiumrijke havermout",
        "locale": "nl",
        "status": "published",
        "premium": false,
        "published_at": "2026-10-01T08:00:00Z",
        "text": "Body text for tagging. Stored only as embedding and tags, never returned."
      }
    ]
  }'

Keeping the catalog in sync#

  • Call PUT /catalog/items from your content system on every create and edit, and POST /catalog/items/{external_id}/status with archived when an item is taken down. Do it detached from the editor's action: a Patterna failure must never fail the save.
  • Run a nightly reconcile that upserts every published item. Upserts are idempotent, so this catches lost calls.
  • GET /catalog/items?changed_since=<RFC3339>&cursor=<cursor> pages through what Patterna holds, so the reconcile can also find items you deleted, and DELETE /catalog/items/{external_id}?kind=<kind> removes an item for good. The listing includes archived items; deleted ones are gone. The delete answers 200 with external_id, kind, deleted, tags_removed and references_removed; slates already logged keep the id.

admin: surfaces and the ledger#

curl -sS "https://patterna-api.orven.am/v1/surfaces" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"
curl -sS "https://patterna-api.orven.am/v1/proof/ledger?surface=home_articles&from=2026-09-01&to=2026-09-30" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"

Most teams never need an admin key outside the panel. Keep it out of request paths.

Signing events#

Events that affect ranking (clicks, joins, completions, orders) count for learning only when Patterna trusts their origin. The model is simple:

  • POST /events takes only an ingest key (or an admin key). There is no separate "SDK key" scope, and an app never holds a key.
  • Every event that arrives on an ingest key is trusted and stored as signed. Your backend vouches for it by holding the key.
  • A signature is optional. Add one when an event passed through a hop you do not fully trust before your backend forwarded it (an app, a queue). A signature that is present is always checked: a wrong one rejects that event (invalid_signature), even on an ingest key. A signed event on a tenant without a secret is rejected with no_signing_secret.
  • Sign on your backend. A signing secret inside an app bundle is a public secret.

The signed form:

  1. Take the event object as sent, minus the signature field.
  2. Serialise it as JSON with object keys sorted at every level, no whitespace, HTML characters unescaped, U+2028 and U+2029 escaped. Keep numbers as integers.
  3. signature = lowercase hex HMAC-SHA256 over those UTF-8 bytes.
  4. The HMAC key is the secret as given: pss_ plus 64 hex characters, used as the UTF-8 bytes of that whole string, byte for byte. Nothing to decode. This is what all three SDKs do.

Since engine 0.2 secrets are pss_ strings. A secret created before that is 64 hex characters without the prefix and no longer verifies: rotate it once (POST /settings/signing-secret/rotate) and sign with the new string. Most backends never sign at all: the ingest key is enough.

Test vector (a fixture key, not a real secret):

secretpss_000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
canonical body{"event_id":"evt_1","occurred_at":"2026-10-13T09:00:00Z","props":{"item_id":"doc_42","kind":"document","position":0},"request_id":"req_1","session_id":"s_1","type":"rec_click"}
signature8fefcd355112cecfce0cde75bdc7566d7d6620918e6b6109bfbd7878e317d815

If you get 3e3a9cbc22f2101a…, you hex-decoded the part after pss_; if you get c53144ead01bfbeb…, you dropped the pss_ prefix. Both are wrong: key with the whole string.

# Shell check only. jq + openssl match the canonical form ONLY when every number is an
# integer and no string contains U+2028 or U+2029. event.json is the event as you will send it.
# The key is the whole pss_ secret string.
jq -jcS 'del(.signature)' event.json \
  | openssl dgst -sha256 -hmac "$PATTERNA_SIGNING_SECRET" \
  | awk '{print $NF}'

The shell tab matches the canonical form only for events whose numbers are integers and whose strings contain no U+2028 or U+2029. The Python tab escapes those two characters; keep numbers as integers and leave absent fields out instead of sending null. Check any signer you write yourself against the vector above.

The signing secret: an owner rotates it with POST /settings/signing-secret/rotate, which returns the new secret once (and creates a first one when none exists). POST /settings/signing-secret creates the first secret on a tenant that has none: 200 with signing_secret, issued_at and note, or 409 signing_secret_exists. See Rotate keys and the signing secret.

Next#

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