Admin scope
Users, surfaces, flows, experiments, the ledger, keys and the audit log.
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"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")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" }'// The SDK sends the admin key when configured, else the ingest key.
await patterna.setUserMode("u_mfrggzdfmztwq2lknnwg23tpobyxe", "non_profiled");// The SDK sends the admin key when configured, else the ingest key.
if err := pc.SetUserMode(ctx, "u_mfrggzdfmztwq2lknnwg23tpobyxe", patterna.ModeNonProfiled); err != nil {
return err
}r = admin.put("/users/u_mfrggzdfmztwq2lknnwg23tpobyxe/mode", json={"mode": "non_profiled"})
r.raise_for_status()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"
}'await patterna.setUserProfile("u_mfrggzdfmztwq2lknnwg23tpobyxe", {
mode: "personalized",
premium: false,
goals: ["beter-slapen", "meer-energie"],
activity_level: "moderate",
stress_level: "high",
wearable: "oura",
supplements_use: "some",
locale: "nl",
});premium := false
err := pc.SetUserProfile(ctx, "u_mfrggzdfmztwq2lknnwg23tpobyxe", patterna.UserProfile{
Mode: patterna.ModePersonalized,
Premium: &premium,
Goals: []string{"beter-slapen", "meer-energie"},
ActivityLevel: "moderate",
StressLevel: "high",
Wearable: "oura",
SupplementsUse: "some",
Locale: "nl",
})
if err != nil {
return err
}r = admin.put(
"/users/u_mfrggzdfmztwq2lknnwg23tpobyxe/profile",
json={
"mode": "personalized",
"premium": False,
"goals": ["beter-slapen", "meer-energie"],
"activity_level": "moderate",
"stress_level": "high",
"wearable": "oura",
"supplements_use": "some",
"locale": "nl",
},
)
r.raise_for_status()goals: tags from your taxonomy. Cohort mode uses these only.activity_level,stress_level:low,moderateorhigh.supplements_use:none,someorstack.
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"const deletion = await patterna.deleteUser("u_mfrggzdfmztwq2lknnwg23tpobyxe");
console.log(deletion.id, deletion.rows_deleted_at); // rows are gone; models_retrained_at starts nulldel, err := pc.DeleteUser(ctx, "u_mfrggzdfmztwq2lknnwg23tpobyxe")
if err != nil {
return err
}
fmt.Println(del.ID, del.RowsDeletedAt) // rows are gone; ModelsRetrainedAt starts nilr = admin.delete("/users/u_mfrggzdfmztwq2lknnwg23tpobyxe")
r.raise_for_status() # 202
deletion = r.json()
print(deletion["id"], deletion["requested_at"])Response 202:
{
"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 the202, the rows are gone.models_retrained_at:nulluntil 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.jsonimport { writeFile } from "node:fs/promises";
const data = await rest("PATTERNA_ADMIN_KEY", "GET", "/users/u_mfrggzdfmztwq2lknnwg23tpobyxe");
await writeFile("user-export.json", JSON.stringify(data, null, 2));var export map[string]any
if err := rest(ctx, "PATTERNA_ADMIN_KEY", "GET", "/users/u_mfrggzdfmztwq2lknnwg23tpobyxe", nil, &export); err != nil {
return err
}
b, err := json.MarshalIndent(export, "", " ")
if err != nil {
return err
}
if err := os.WriteFile("user-export.json", b, 0o600); err != nil {
return err
}r = admin.get("/users/u_mfrggzdfmztwq2lknnwg23tpobyxe")
r.raise_for_status()
with open("user-export.json", "wb") as f:
f.write(r.content)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"const traces = await rest<Array<{ request_id: string; slate: unknown[] }>>(
"PATTERNA_ADMIN_KEY",
"GET",
"/users/u_mfrggzdfmztwq2lknnwg23tpobyxe/trace?limit=1",
);
console.log(JSON.stringify(traces, null, 2));var traces []map[string]any
if err := rest(ctx, "PATTERNA_ADMIN_KEY", "GET", "/users/u_mfrggzdfmztwq2lknnwg23tpobyxe/trace?limit=1", nil, &traces); err != nil {
return err
}
fmt.Println(traces)r = admin.get("/users/u_mfrggzdfmztwq2lknnwg23tpobyxe/trace", params={"limit": 1})
r.raise_for_status()
for trace in r.json():
print(trace["request_id"], trace["arm"], [s["item_id"] for s in trace["slate"]])[
{
"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,dismissornone.
Surfaces#
GET /surfaces#
curl -sS https://api.patterna.nl/v1/surfaces \
-H "Authorization: Bearer $PATTERNA_ADMIN_KEY"[
{
"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,liveoroff. A request to anoffsurface gets409 surface_off. See Surfaces and flows.fallback: the surface's editorial list:kindnewest,curated(withitem_ids) orpopular(7-day trending on this surface, all locales together), and optionallyby_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 flaggedfallback. On an anchored page control serves, in order, the request'scontext.control_item_ids, elseby_anchorfor 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_sourceon the answer says which). See Run a holdout.tap_rate_7d:nullwith no impressions.
POST /surfaces#
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 -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 -sS https://api.patterna.nl/v1/surfaces/home_articles/flows \
-H "Authorization: Bearer $PATTERNA_ADMIN_KEY"[
{
"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:nullfor 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 answers400 invalid_reason_template. See Reason keys.filters.localesis accepted and stored but not read by the engine;filters.diversityis the only diversity setting. Every field: Flow config reference.
POST /surfaces/{key}/flows#
Creates a new, inactive version.
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" }'await rest("PATTERNA_ADMIN_KEY", "POST", "/surfaces/home_articles/flows/4/activate", {
note: "Item-to-item looked better in simulate",
});note := map[string]string{"note": "Item-to-item looked better in simulate"}
if err := rest(ctx, "PATTERNA_ADMIN_KEY", "POST", "/surfaces/home_articles/flows/4/activate", note, nil); err != nil {
return err
}r = admin.post(
"/surfaces/home_articles/flows/4/activate",
json={"note": "Item-to-item looked better in simulate"},
)
r.raise_for_status()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 -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" }
}
}'{
"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, withci_lowandci_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
}'const experiment = await rest<{ id: string; readable_sample: number; readable_at: string | null }>(
"PATTERNA_ADMIN_KEY",
"POST",
"/experiments",
{
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,
},
);
console.log(experiment?.id, experiment?.readable_sample, experiment?.readable_at);var experiment struct {
ID string `json:"id"`
ReadableSample int `json:"readable_sample"`
ReadableAt *string `json:"readable_at"`
}
err := rest(ctx, "PATTERNA_ADMIN_KEY", "POST", "/experiments", map[string]any{
"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,
}, &experiment)
if err != nil {
return err
}
fmt.Println(experiment.ID, experiment.ReadableSample, experiment.ReadableAt)r = admin.post(
"/experiments",
json={
"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,
},
)
r.raise_for_status() # 201
experiment = r.json()
print(experiment["id"], experiment["readable_sample"], experiment["readable_at"])Response 201:
{
"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_applicableelsewhere): acommunity_joinedfor a community in the slate within 7 days;engaged_after_tap: anitem_engagedoritem_completedfor a tapped item within 7 days.
Surface metrics include
fallback_rate(safety-net fallbacks plus abandoned slates),client_abandoned_rateandunmeasured_share.stratify_by:none(default), the split is a plain hash of the unit;personadraws the arm within each persona stratum, and users without a persona, includingnon_profiledtraffic, are one stratum.strata_sharesoptionally sets the treatment share per persona stratum (a stratum not listed usestreatment_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 -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 -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"type LedgerRow = {
period_end: string;
difference: number;
ci_low: number;
ci_high: number;
readable: boolean;
};
const rows = await rest<LedgerRow[]>(
"PATTERNA_ADMIN_KEY",
"GET",
"/proof/ledger?surface=home_articles&from=2026-09-01&to=2026-09-30",
);
for (const row of rows ?? []) {
console.log(row.period_end, row.difference, [row.ci_low, row.ci_high], row.readable ? "readable" : "not readable yet");
}var rows []struct {
PeriodEnd string `json:"period_end"`
Difference float64 `json:"difference"`
CILow float64 `json:"ci_low"`
CIHigh float64 `json:"ci_high"`
Readable bool `json:"readable"`
}
if err := rest(ctx, "PATTERNA_ADMIN_KEY", "GET", "/proof/ledger?surface=home_articles&from=2026-09-01&to=2026-09-30", nil, &rows); err != nil {
return err
}
for _, row := range rows {
fmt.Println(row.PeriodEnd, row.Difference, row.CILow, row.CIHigh, row.Readable)
}r = admin.get(
"/proof/ledger",
params={"surface": "home_articles", "from": "2026-09-01", "to": "2026-09-30"},
)
r.raise_for_status()
for row in r.json():
print(row["period_end"], row["difference"], (row["ci_low"], row["ci_high"]), row["readable"])[
{
"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 innon_profiled), not impressions.readable:trueonly when each arm has reached the experiment'sreadable_sampleand the interval exists. The panel draws intervals that are not readable dashed.- With
by=block(every block) orblock=<name>the ledger returns one row per surface and block, over the slates reported seen in that block (aseeninteraction withblock) 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 arenull.
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 -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" }'{ "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 -sS https://api.patterna.nl/v1/connectors \
-H "Authorization: Bearer $PATTERNA_ADMIN_KEY"[
{
"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" }'const key = await rest<{ id: string; prefix: string; secret: string }>("PATTERNA_ADMIN_KEY", "POST", "/keys", {
name: "backend-serve",
scope: "serve",
environment: "production",
});
// key.secret is returned once: put it in your secret store now and never log it.
console.log(key?.id, key?.prefix);var key struct {
ID string `json:"id"`
Prefix string `json:"prefix"`
Secret string `json:"secret"` // returned once: store it now, never log it
}
err := rest(ctx, "PATTERNA_ADMIN_KEY", "POST", "/keys", map[string]string{
"name": "backend-serve",
"scope": "serve",
"environment": "production",
}, &key)
if err != nil {
return err
}
fmt.Println(key.ID, key.Prefix)r = admin.post(
"/keys",
json={"name": "backend-serve", "scope": "serve", "environment": "production"},
)
r.raise_for_status() # 201
key = r.json()
# key["secret"] is returned once: store it now and never log it.
print(key["id"], key["prefix"])Response 201:
{
"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 -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 -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 -sS https://api.patterna.nl/v1/audit \
-H "Authorization: Bearer $PATTERNA_ADMIN_KEY"[
{
"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 -sS https://api.patterna.nl/v1/health{
"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).
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.