# Admin scope

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

Source: https://staging.patterna.nl/docs/api-admin

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.

```curl
# 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"
```

```ts
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;
}
```

```go
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)
}
```

```python
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}`](#delete-usersuserid) when the account goes. Both keep impressions unlinked for the ledger. Only deletion also removes the opt-out marker.

```curl
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" }'
```

```ts
// The SDK sends the admin key when configured, else the ingest key.
await patterna.setUserMode("u_mfrggzdfmztwq2lknnwg23tpobyxe", "non_profiled");
```

```go
// 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
}
```

```python
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](https://staging.patterna.nl/docs/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
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"
  }'
```

```ts
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",
});
```

```go
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
}
```

```python
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`, `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
curl -sS -X DELETE https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"
```

```ts
const deletion = await patterna.deleteUser("u_mfrggzdfmztwq2lknnwg23tpobyxe");
console.log(deletion.id, deletion.rows_deleted_at); // rows are gone; models_retrained_at starts null
```

```go
del, err := pc.DeleteUser(ctx, "u_mfrggzdfmztwq2lknnwg23tpobyxe")
if err != nil {
	return err
}
fmt.Println(del.ID, del.RowsDeletedAt) // rows are gone; ModelsRetrainedAt starts nil
```

```python
r = admin.delete("/users/u_mfrggzdfmztwq2lknnwg23tpobyxe")
r.raise_for_status()  # 202
deletion = r.json()
print(deletion["id"], deletion["requested_at"])
```

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](https://staging.patterna.nl/docs/handle-a-deletion-request).

### GET /users/{user_id}

Exports every row about the user as one JSON object.

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

```ts
import { 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));
```

```go
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
}
```

```python
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
curl -sS "https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe/trace?limit=1" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"
```

```ts
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));
```

```go
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)
```

```python
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"]])
```

```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](https://staging.patterna.nl/docs/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](https://staging.patterna.nl/docs/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](https://staging.patterna.nl/docs/run-a-holdout#what-the-control-arm-sees).
- `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": "editor@example.com",
    "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](https://staging.patterna.nl/docs/reference-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](https://staging.patterna.nl/docs/reference-flow-config).

### 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](https://staging.patterna.nl/docs/reference-flow-config).

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

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

```curl
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" }'
```

```ts
await rest("PATTERNA_ADMIN_KEY", "POST", "/surfaces/home_articles/flows/4/activate", {
  note: "Item-to-item looked better in simulate",
});
```

```go
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
}
```

```python
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](https://staging.patterna.nl/docs/roll-back-a-flow). Every field of the config is in the [Flow config reference](https://staging.patterna.nl/docs/reference-flow-config).

### 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](https://staging.patterna.nl/docs/the-twin).

## Experiments

### POST /experiments

Starts a holdout on a surface.

```curl
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
  }'
```

```ts
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);
```

```go
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)
```

```python
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`:

```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](https://staging.patterna.nl/docs/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
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"
```

```ts
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");
}
```

```go
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)
}
```

```python
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"])
```

```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](https://staging.patterna.nl/docs/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
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" }'
```

```ts
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);
```

```go
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)
```

```python
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`:

```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](https://staging.patterna.nl/docs/rotate-keys-and-signing-secret).

## Audit

### GET /audit

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

```json
[
  {
    "id": 4182,
    "actor": "editor@example.com",
    "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](https://staging.patterna.nl/docs/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).

```bash
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](https://staging.patterna.nl/docs/rotate-keys-and-signing-secret).
