Patterna Docs

Admin scope

Users, surfaces, flows, experiments, the ledger, keys and the audit log.

Developers 11 min read View as Markdown

The admin scope configures the tenant and reads the proof. The panel uses it for you; call it yourself for scripts and operator work. Use an admin key, or a panel session (then a role without access gets 403 forbidden_role).

PUT /users/{user_id}/mode, PUT /users/{user_id}/profile and DELETE /users/{user_id} take an ingest key (an admin key passes too): your backend owns consent and deletion, so it should not need an admin key. They are on this page because they belong with the other user calls.

The admin calls on this page also come as a Go admin package and a patterna CLI (surfaces, flows, experiments, activation), so you can keep flow configs as files in your repository and apply them from CI.

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"

Users#

All user calls take the random per-tenant user_id you minted.

PUT /users/{user_id}/mode#

Sets the consent mode. Switching to non_profiled is the opt-out: it deletes the user row (profile and affinities) at once, and models forget the user at the next nightly retrain.

The opt-out has the same semantics as a deletion of the profile. In the request the user row (profile and affinities) is deleted, Patterna stores a salted hash of the id as the opt-out marker, and it files a deletion request that holds only that salted hash. The hourly deletions job then unlinks the user's impressions (the id and the logged candidates are cleared; the salted unit_hash stays, so ledger rows do not change after the fact), keeps their interactions (they belong to the slate), and deletes the user's events, holdout assignments and precomputed shortlist. Serve honours the marker at once: a personalized or cohort request for that id is served as non_profiled and logged without the id, keeping the unit it had and, unless the holdout is stratified by persona (the user has no persona any more), the arm; the audit log records serve.optout_downgrade once per day, without the id. Setting the mode to personalized or cohort again (with this call, or a profile that carries mode) clears the marker. The nightly retrain clears the models.

Opt-out or deletion? Use the opt-out when the person keeps the account but withdraws consent; use DELETE /users/{user_id} when the account goes. Both keep impressions unlinked for the ledger. Only deletion also removes the opt-out marker.

curl -sS -X PUT https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe/mode \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "mode": "non_profiled" }'

mode: personalized, cohort or non_profiled. Response: 200. See Consent modes.

PUT /users/{user_id}/profile#

Sets declared attributes as tag affinities. Never health values.

mode (personalized or cohort) is optional for a user Patterna knows: without it the user keeps the current mode. For an id Patterna has never seen, a profile without mode is refused with 422 mode_required, so a profile write never creates consent by default; a user who opted out is refused with 422 mode_conflict until the mode is set. A profile with an explicit mode clears an earlier opt-out. mode: non_profiled is refused with 422 mode_conflict: a non_profiled user has no profile.

curl -sS -X PUT https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe/profile \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "personalized",
    "premium": false,
    "goals": ["beter-slapen", "meer-energie"],
    "activity_level": "moderate",
    "stress_level": "high",
    "wearable": "oura",
    "supplements_use": "some",
    "locale": "nl"
  }'
  • goals: tags from your taxonomy. Cohort mode uses these only.
  • activity_level, stress_level: low, moderate or high.
  • supplements_use: none, some or stack.

Response: 200.

DELETE /users/{user_id}#

Deletes every row about the user before it answers. Takes an ingest key (or admin).

In one transaction the engine deletes the user row, the holdout assignments, the opt-out marker, the user's events and precomputed shortlist, and unlinks the impressions (the user id is removed and the logged candidates emptied). Impressions keep their unit_hash (a SHA-256 of a secret salt Patterna holds per tenant and the user id; the salt is set once and never rotated, so it cannot be reversed and someone who holds a user id cannot check whether a row belongs to it), so ledger counts and intervals do not change afterwards. The deletion record itself stores a SHA-256 hash, not the id. Learned models forget the user at the next nightly retrain.

curl -sS -X DELETE https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"

Response 202:

JSON
{
  "id": "7d4c2a90-3f1b-4e8a-b6a1-0c9e5d2f8b31",
  "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe",
  "requested_at": "2026-10-13T10:00:00Z",
  "rows_deleted_at": "2026-10-13T10:00:00Z",
  "models_retrained_at": null,
  "stored_as": "sha256"
}
  • rows_deleted_at: set in the same call. When you get the 202, the rows are gone.
  • models_retrained_at: null until the nightly learn run that no longer includes the user.
  • stored_as: the deletion log keeps a salted hash of the id, never the id.

See Handle a deletion request.

GET /users/{user_id}#

Exports every row about the user as one JSON object.

curl -sS https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -o user-export.json

The body has user_id, profile (mode, persona, attributes, tag_affinity; absent for a user without a row), events (event_id, type, occurred_at, props) and impressions (request_id, served_at, surface, arm, slate). Admin key only.

GET /users/{user_id}/trace#

Why did this user see what they saw. The last limit requests, default 20.

curl -sS "https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe/trace?limit=1" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"
JSON
[
  {
    "request_id": "req_01HZX4T9W6Q2",
    "served_at": "2026-10-13T08:12:01Z",
    "surface": "home_articles",
    "mode": "personalized",
    "arm": "treatment",
    "policy_version": "linear-2026-10-13",
    "explored": true,
    "slate": [
      {
        "item_id": "doc_1842",
        "position": 0,
        "propensity": 0.41,
        "reason_key": "similar_to_read",
        "generator": "content_affinity",
        "contributions": { "content_affinity": 0.52, "trending": 0.3 },
        "outcome": "click"
      }
    ]
  }
]
  • explored: this request used Plackett-Luce sampling.
  • propensity: the probability that this item landed in this position. See Exploration and propensities.
  • contributions: score share per generator.
  • outcome: seen, click, dismiss or none.

Surfaces#

GET /surfaces#

curl
curl -sS https://api.patterna.nl/v1/surfaces \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"
JSON
[
  {
    "key": "home_articles",
    "name": "Home articles",
    "item_kinds": ["document"],
    "slots": 6,
    "fallback": { "kind": "newest", "item_ids": [] },
    "exploration_share": 0.2,
    "status": "live",
    "active_flow_version": 3,
    "impressions_7d": 18240,
    "tap_rate_7d": 0.052,
    "experiment_id": "exp_home_articles_01"
  }
]
  • status: shadow, testers, live or off. A request to an off surface gets 409 surface_off. See Surfaces and flows.
  • fallback: the surface's editorial list: kind newest, curated (with item_ids) or popular (7-day trending on this surface, all locales together), and optionally by_anchor, an object from an anchor item id to the editor's list of item ids for that page. The control arm is served this list as its own policy (editorial); the same list is the safety net when ranking fails, and only then is the slate flagged fallback. On an anchored page control serves, in order, the request's context.control_item_ids, else by_anchor for the anchor, else the list above, with the anchor, exclude_item_ids, joined communities, candidate kinds, premium and locale applied, and newest items filling the rest (control_source on the answer says which). See Run a holdout.
  • tap_rate_7d: null with no impressions.

POST /surfaces#

curl
curl -sS -X POST https://api.patterna.nl/v1/surfaces \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "more_to_read",
    "name": "More to read",
    "item_kinds": ["document"],
    "slots": 4,
    "fallback": { "kind": "newest", "item_ids": [] },
    "exploration_share": 0.2,
    "status": "shadow"
  }'

Response 201 with the surface. Start every surface in shadow.

PATCH /surfaces/{key}#

Changes name, status, slots, exploration_share, fallback, testers (up to 200 of your user ids, or session ids for non_profiled testing) or notes. Also min_slots (0 to 50, 0 = always shown, 1 = hide a block with nothing to show: when fewer items than this pass the request's rules, counted before the arm, the slate comes back with shown: false and no items, is logged as not shown and counts in its arm as served with zero success) and device_resort (the app may re-sort under a holdout; interactions without displayed_position get 422 displayed_position_required). Both are PATCH only.

curl
curl -sS -X PATCH https://api.patterna.nl/v1/surfaces/more_to_read \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "testers" }'

Response 200 with the surface.

Flows#

A flow is the versioned recipe of a surface. Creating a version does not activate it.

GET /surfaces/{key}/flows#

curl
curl -sS https://api.patterna.nl/v1/surfaces/home_articles/flows \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"
JSON
[
  {
    "version": 3,
    "note": "Raise content affinity after the October tagging pass",
    "created_at": "2026-10-12T14:00:00Z",
    "activated_at": "2026-10-13T07:00:00Z",
    "created_by": "[email protected]",
    "config": {
      "generators": [
        { "kind": "trending", "weight": 0.4 },
        { "kind": "content_affinity", "weight": 0.6 }
      ],
      "filters": { "exclude_seen_days": 7, "locales": ["nl"], "premium_policy": "cap", "premium_cap": 2, "per_collection_cap": 3, "freshness_days": null, "diversity": 0.3 },
      "ranker": { "kind": "linear", "policy_version": "" },
      "exploration": { "kind": "plackett_luce", "temperature": 0.7, "share": 0.2 },
      "reasons": {
        "trending": { "nl": "Veel gelezen deze week", "en": "Read a lot this week" },
        "matches_goals": { "nl": "Past bij wat je leest", "en": "Fits what you read" }
      }
    }
  }
]
  • activated_at: null for versions that were never active.
  • ranker.policy_version: empty means the built-in linear weights.
  • reasons: text per locale, keyed by reason key or by generator kind (the reason-key text wins). The engine fills {anchor_title}, {tag} and {segment}; an unknown key or placeholder answers 400 invalid_reason_template. See Reason keys.
  • filters.locales is accepted and stored but not read by the engine; filters.diversity is the only diversity setting. Every field: Flow config reference.

POST /surfaces/{key}/flows#

Creates a new, inactive version.

curl
curl -sS -X POST https://api.patterna.nl/v1/surfaces/home_articles/flows \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "note": "Add item-to-item at low weight",
    "config": {
      "generators": [
        { "kind": "trending", "weight": 0.3 },
        { "kind": "content_affinity", "weight": 0.5 },
        { "kind": "item_to_item", "weight": 0.2 }
      ],
      "ranker": { "kind": "linear" },
      "exploration": { "kind": "plackett_luce", "temperature": 0.7, "share": 0.2 }
    }
  }'

Only generators is required (at least one, of a known kind); the rest has defaults. A config the engine cannot use answers 400 with the reason, for example unknown generator kind, exploration.share must be between 0 and 0.5 or ranker.kind lambdamart is not available yet. Response 201 with the flow. An unknown reason key or placeholder answers 400 invalid_reason_template. Every field, default and range: Flow config reference.

POST /surfaces/{key}/flows/{version}/activate#

Activates a version. Audited. The previous version stays available for rollback.

curl -sS -X POST https://api.patterna.nl/v1/surfaces/home_articles/flows/4/activate \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "note": "Item-to-item looked better in simulate" }'

Response 200: { "ok": true, "active_flow_version": 4 }. The body is optional and the note is optional; it lands in the audit log. On a live surface with the tenant's two-person rule on, activation answers 409 approval_required: request an approval and let a second person approve it. Rolling back is activating an older version: see Roll back a flow. Every field of the config is in the Flow config reference.

POST /surfaces/{key}/simulate#

Replays a config on the last days of logged impressions (default 14). Changes nothing.

curl
curl -sS -X POST https://api.patterna.nl/v1/surfaces/home_articles/simulate \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "days": 14,
    "config": {
      "generators": [ { "kind": "item_to_item", "weight": 1 } ],
      "ranker": { "kind": "linear" },
      "exploration": { "kind": "none" }
    }
  }'
JSON
{
  "days": 14,
  "impressions_used": 8120,
  "effective_sample_size": 1430.5,
  "metric": "tap_rate",
  "current_policy": 0.048,
  "candidate_policy": 0.053,
  "ci_low": 0.044,
  "ci_high": 0.062,
  "twin_curve": [
    { "week": 1, "control": 0.042, "treatment": 0.047 },
    { "week": 2, "control": 0.042, "treatment": 0.05 }
  ]
}
  • candidate_policy: the SNIPS estimate of the metric under the new config, with ci_low and ci_high.
  • effective_sample_size: how much of the logged traffic really informs the estimate. Low means the candidate is far from what was logged.
  • twin_curve: only when the tenant has a twin. See The twin.

Experiments#

POST /experiments#

Starts a holdout on a surface.

curl -sS -X POST https://api.patterna.nl/v1/experiments \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "surface": "home_articles",
    "name": "Home articles holdout Q4",
    "treatment_share": 0.5,
    "primary_metric": "tap_rate",
    "stratify_by": "persona",
    "baseline_rate": 0.042,
    "relative_change": 0.2
  }'

Response 201:

JSON
{
  "id": "exp_home_articles_01",
  "surface": "home_articles",
  "name": "Home articles holdout Q4",
  "treatment_share": 0.5,
  "primary_metric": "tap_rate",
  "stratify_by": "persona",
  "baseline_rate": 0.042,
  "relative_change": 0.2,
  "started_at": "2026-10-13T10:05:00Z",
  "ended_at": null,
  "readable_sample": 9200,
  "n_control": 0,
  "n_treatment": 0,
  "readable_at": "2026-11-24"
}
  • primary_metric: one of:

    • tap_rate: slates with at least one tap over slates served;
    • second_item_rate: after the first tap, a tap on another item by the same session or unit within 30 minutes;
    • join_rate: community surfaces only (400 metric_not_applicable elsewhere): a community_joined for a community in the slate within 7 days;
    • engaged_after_tap: an item_engaged or item_completed for a tapped item within 7 days.

    Surface metrics include fallback_rate (safety-net fallbacks plus abandoned slates), client_abandoned_rate and unmeasured_share.

  • stratify_by: none (default), the split is a plain hash of the unit; persona draws the arm within each persona stratum, and users without a persona, including non_profiled traffic, are one stratum. strata_shares optionally sets the treatment share per persona stratum (a stratum not listed uses treatment_share).

  • relative_change: the change you want to be able to detect, 0.2 = 20%.

  • readable_sample: randomised units (users, or sessions) needed before the result is readable.

  • readable_at: the projected date that sample is reached.

See Run a holdout.

GET /experiments#

curl
curl -sS https://api.patterna.nl/v1/experiments \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"

Returns an array of experiments in the shape above.

POST /experiments/{id}/end#

Ends a running holdout. From then on the surface logs arm none; the ledger keeps reading the experiment's window [started_at, ended_at).

curl
curl -sS -X POST https://api.patterna.nl/v1/experiments/exp_home_articles_01/end \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"

Response 200: { "ok": true }. A surface has at most one running experiment; creating a second answers 409 experiment_running.

Proof#

GET /proof/ledger#

curl -sS "https://api.patterna.nl/v1/proof/ledger?surface=home_articles&from=2026-09-01&to=2026-09-30" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"
JSON
[
  {
    "period_start": "2026-09-01",
    "period_end": "2026-09-30",
    "surface": "home_articles",
    "metric": "tap_rate",
    "control_rate": 0.042,
    "treatment_rate": 0.052,
    "difference": 0.01,
    "ci_low": 0.004,
    "ci_high": 0.016,
    "n_control": 4810,
    "n_treatment": 4795,
    "readable": true,
    "revenue_cents_control": null,
    "revenue_cents_treatment": null,
    "export_uri": null
  }
]
  • difference: treatment minus control, per impression.
  • ci_low, ci_high: the 95% interval, clustered on the randomised unit.
  • n_control, n_treatment: units (users, or sessions in non_profiled), not impressions.
  • readable: true only when each arm has reached the experiment's readable_sample and the interval exists. The panel draws intervals that are not readable dashed.
  • With by=block (every block) or block=<name> the ledger returns one row per surface and block, over the slates reported seen in that block (a seen interaction with block) and the taps in it. Those rows are descriptive: the whole-slate row decides.
  • revenue_cents_*: reserved for a last-touch revenue diagnostic. Not computed yet: the fields are null.

A row covers the requested period (default: the last 28 days) intersected with the experiment's window, so repeated calls with a growing to give cumulative results. bucket=week returns disjoint weekly rows per arm instead. Responses are cached for 45 seconds (shared across engine instances when Redis is configured). See Read a ledger row.

POST /proof/export#

Exports impressions and interactions: impressions.csv (request id, time, surface, mode, user or session id, arm, flow and policy version, the slate, explored, shadow) and interactions.csv (request id, item, kind, position, displayed position, type, time). It does not carry the clustering unit, measured, not_shown, tester, fallback, blocks, the experiment window or the events join_rate needs, so it is a cross-check of the tap counts, not yet a full recomputation of a ledger row.

curl
curl -sS -X POST https://api.patterna.nl/v1/proof/export \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "from": "2026-09-01", "to": "2026-09-30", "surface": "home_articles" }'
JSON
{ "job_id": "job_01HZX7Q2R5S9" }

Response 202. Poll GET /proof/exports/{job_id} for status, uri and the file list. Not built yet: the S3 export target; exports land in a local directory on the engine for now.

Connectors#

GET /connectors#

curl
curl -sS https://api.patterna.nl/v1/connectors \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"
JSON
[
  {
    "source": "posthog",
    "started_at": "2026-10-13T09:45:00Z",
    "finished_at": "2026-10-13T09:45:12Z",
    "status": "ok",
    "rows_in": 2140,
    "rows_mapped": 1980,
    "fields_dropped": { "content_title": 812, "content_slug": 812, "$ip": 2140 },
    "error": null
  }
]

fields_dropped counts every field the mapper removed, by name.

Keys#

POST /keys#

curl -sS -X POST https://api.patterna.nl/v1/keys \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "backend-serve", "scope": "serve", "environment": "production" }'

Response 201:

JSON
{
  "id": "key_01HZX8A1B2C3",
  "name": "backend-serve",
  "scope": "serve",
  "prefix": "pk_s_Xb3kQ",
  "environment": "production",
  "created_at": "2026-10-13T10:10:00Z",
  "revoked_at": null,
  "secret": "<shown once>"
}

secret is returned only here (it starts with pk_s_, pk_i_ or pk_a_ by scope). Store it in your secret manager at once. An admin key can mint only serve and ingest keys (403 forbidden_scope otherwise); admin keys are minted by an owner in the panel.

GET /keys#

curl
curl -sS https://api.patterna.nl/v1/keys \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"

Returns keys with their prefix only, never the secret.

DELETE /keys/{id}#

curl
curl -sS -X DELETE https://api.patterna.nl/v1/keys/key_01HZX8A1B2C3 \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"

Response 204. The key stops working at once. See Rotate keys and the signing secret.

Audit#

GET /audit#

curl
curl -sS https://api.patterna.nl/v1/audit \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"
JSON
[
  {
    "id": 4182,
    "actor": "[email protected]",
    "action": "flow.activate",
    "target": "home_articles/4",
    "diff": { "active_flow_version": [3, 4] },
    "at": "2026-10-13T10:12:00Z"
  }
]

Page with cursor. actor is null for system actions. Actions include surface.create, surface.update, flow.create, flow.activate, experiment.create, experiment.end, user.delete, user.export, proof.export, settings.update, retention.update, signing_secret.rotate, taxonomy.replace, catalog.enrich, catalog.bulk, approval.request and the workspace.* and report.* actions.

Health#

GET /health#

No key needed.

curl
curl -sS https://api.patterna.nl/v1/health
JSON
{
  "status": "ok",
  "postgres": "ok",
  "redis": "not configured",
  "version": "see /health"
}

redis is not configured today. Redis is optional: when REDIS_URL is set, engine instances share the 45-second cache of the ledger, surface metrics and users summary; without it each instance keeps its own. Nothing else needs Redis. The response also carries handover (true on the temporary copy during a deploy). See Status.

Settings: how the learners read this tenant#

PATCH /settings takes learn: { "history_days": 365 } (owners, staff or an admin key): how far back the nightly behaviour learners (the user model, item similarity, tag affinity) read for this tenant, 30 to 730 days. A film or book library wants a year or two; a feed keeps the defaults (180, 365 and 90 days, or the deployment's PATTERNA_LEARN_HISTORY_DAYS). null removes the setting. It takes effect at the next nightly run. GET /settings returns it as learn.history_days (null when the deployment default applies).

Shell
curl -s -X PATCH "$ENGINE/v1/settings" -H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"learn": {"history_days": 365}}'

Out of range answers 400 learn.history_days must be 30 to 730.

Signing secret#

POST /settings/signing-secret/rotate (owners, staff or an admin key) replaces the tenant's event signing secret and returns the new one once: pss_ plus 64 hex characters. POST /settings/signing-secret creates the first secret when none exists and answers 200 with signing_secret, issued_at and note (409 signing_secret_exists otherwise). A secret issued before engine 0.2 has no pss_ prefix: rotate once to replace it. PATCH /settings also takes posthog_token and shopify_webhook_secret for the webhooks. How to sign with it: Rotate keys and the signing secret.

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