# Patterna documentation Source: https://staging.patterna.nl/docs · Contract: https://staging.patterna.nl/docs/openapi.yaml (binding: if a page disagrees, the page is wrong) · Index: https://staging.patterna.nl/docs/llms.txt Every page of the docs in one file, in navigation order (38 pages). Each page starts with a line `---`, then `# ` and `URL: <absolute url>`; cut on those lines to keep only the sections you need. Links inside pages are absolute. The sections and their pages: - Guides: Go live in six steps <https://staging.patterna.nl/docs/go-live-in-six-steps>; Run a holdout <https://staging.patterna.nl/docs/run-a-holdout>; Read a ledger row <https://staging.patterna.nl/docs/read-a-ledger-row>; Fix a tag and approve a health claim <https://staging.patterna.nl/docs/fix-a-tag>; Roll back a flow <https://staging.patterna.nl/docs/roll-back-a-flow>; Handle a deletion request <https://staging.patterna.nl/docs/handle-a-deletion-request>; Rotate keys and the signing secret <https://staging.patterna.nl/docs/rotate-keys-and-signing-secret>; Integrate with your AI agent <https://staging.patterna.nl/docs/agents/integrate-with-your-ai-agent> - Concepts: What Patterna is <https://staging.patterna.nl/docs/what-is-patterna>; What the engine uses, and what it refuses <https://staging.patterna.nl/docs/signals>; Consent modes <https://staging.patterna.nl/docs/consent-modes>; Proof and the ledger <https://staging.patterna.nl/docs/proof-and-the-ledger>; Exploration and propensities <https://staging.patterna.nl/docs/exploration-and-propensities>; Timeouts and what counts in the ledger <https://staging.patterna.nl/docs/timeouts-and-the-ledger>; The twin <https://staging.patterna.nl/docs/the-twin>; Reasons <https://staging.patterna.nl/docs/reasons>; Surfaces and flows <https://staging.patterna.nl/docs/surfaces-and-flows>; Data we never store <https://staging.patterna.nl/docs/data-we-never-store>; Identity across connectors <https://staging.patterna.nl/docs/identity-across-connectors> - Connectors: Backend API <https://staging.patterna.nl/docs/connector-backend-api>; SDK TypeScript <https://staging.patterna.nl/docs/connector-sdk-typescript>; SDK Go <https://staging.patterna.nl/docs/connector-sdk-go>; PostHog <https://staging.patterna.nl/docs/connector-posthog>; Google Analytics 4 <https://staging.patterna.nl/docs/connector-ga4>; Shopify <https://staging.patterna.nl/docs/connector-shopify>; Quiz CSV <https://staging.patterna.nl/docs/connector-quiz-csv> - API: API overview <https://staging.patterna.nl/docs/api-overview>; Serve scope <https://staging.patterna.nl/docs/api-serve>; Ingest scope <https://staging.patterna.nl/docs/api-ingest>; Admin scope <https://staging.patterna.nl/docs/api-admin> - Reference: Event types <https://staging.patterna.nl/docs/reference-event-types>; Flow config reference <https://staging.patterna.nl/docs/reference-flow-config>; Reason keys <https://staging.patterna.nl/docs/reference-reason-keys>; Error codes <https://staging.patterna.nl/docs/reference-error-codes>; Glossary <https://staging.patterna.nl/docs/glossary>; Changelog <https://staging.patterna.nl/docs/changelog>; Status <https://staging.patterna.nl/docs/status>; llms.txt <https://staging.patterna.nl/docs/agents/llms> --- # Go live in six steps URL: https://staging.patterna.nl/docs/go-live-in-six-steps > The setup checklist in six steps, from connecting a data source to a live surface with a holdout and the first readable ledger row. Six steps take a surface from nothing to live with a holdout. The Overview screen keeps this checklist for your workspace and shows the state of each step. Each step links to the screen that completes it. Want the API calls first? [Integrate with your AI agent](https://staging.patterna.nl/docs/agents/integrate-with-your-ai-agent) lists them in order: keys, surface, catalog, one recommend call, interactions, trace. The six steps are Patterna's side. Your side (mapping consent, ids, deletion, hydrating items into your UI, reporting from the app) is the larger part of the work; plan weeks, not an afternoon. The result of step six is the first readable ledger row: the difference between the two arms, with an interval, on a sample large enough to read. ## 1. Connect a data source **What.** Give Patterna the events it learns from. Pick one or more sources: your backend through the API, an SDK, PostHog, Google Analytics 4, Shopify or a quiz CSV. **Where.** Connectors. Each card opens a setup guide with your webhook URL filled in. Create the scoped key for the connector on the same screen: ingest keys start with `pk_i_`, serve keys with `pk_s_`, admin keys with `pk_a_`. A key is shown once. **Done when.** The card shows a last run, rows in and rows mapped, and no errors. "Test connection" passes. See [Backend API](https://staging.patterna.nl/docs/connector-backend-api), [PostHog](https://staging.patterna.nl/docs/connector-posthog) and the other connector guides. ## 2. Import and enrich the catalog **What.** Send every item you may recommend: documents, products, communities, events, tips. Then let the enrichment job tag them against your taxonomy. **Where.** Catalog. Items arrive through `PUT /catalog/items` (500 per call), an importer, or for a Shopify shop the storefront sync below. Enrichment runs hourly on items whose text changed. **Done when.** The Catalog summary shows your item counts by kind, enrichment status `done` for items with text, and no health-claim tags waiting for approval on items you plan to show. See [Fix a tag and approve a health claim](https://staging.patterna.nl/docs/fix-a-tag). ```curl curl -sS -X PUT "https://api.patterna.nl/v1/catalog/items" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \ -H "Content-Type: application/json" \ -d '{ "items": [ { "external_id": "doc_1042", "kind": "document", "collection": "recipes", "title": "Magnesium-rich breakfast bowl", "locale": "nl", "status": "published", "premium": false, "attributes": { "format": "recipe", "reading_time_min": 4 } } ] }' ``` ### Products from your shop If your shop runs on Shopify, you do not need an Admin API token to get its products in. Every Shopify store publishes its catalog at `https://<your-shop>/products.json`; the storefront sync reads that public feed and upserts each product as an item of kind `product`: title, description (for tagging), the shop's tags, product type, price, sale price, stock and the first image. Send us your shop address; on our side it is one command, and it goes on your connector schedule (`shopify_storefront`, `every_minutes`; hourly if you ask for it), so the feed is re-read on that interval and each run shows on the Connectors card. Between two runs a product that sold out stays `available: true` until the next run, your own `PUT /catalog/items` upsert, or the Admin webhook `products/update` if you set that up; pick the interval for the freshness you need: ```sh python -m patterna_jobs shopify-storefront --tenant your-tenant --shop-url https://your-shop.example ``` It prints how many products it read and wrote. A product that left the feed is archived, never deleted, because old slates may still refer to it. The item's `external_id` is the Shopify product id, the same key the Admin connector uses, so you can switch to the Admin API later without duplicates. If your app shows product cards from your own CMS, map your CMS product to that Shopify id. The feed stops at 10,000 products; a larger shop needs the [Admin connector](https://staging.patterna.nl/docs/connector-shopify). **Order history, as aggregates only.** To give "bought together" a head start, export your orders from Shopify (Orders → Export, CSV) and load the file: ```sh python -m patterna_jobs orders-csv --tenant your-tenant --csv orders_export.csv ``` The reader keeps three columns, the order name, the line-item name and the SKU, and nothing about the customer: names, emails and addresses in the export are never read. It writes pairs of products bought together, with their lift over all orders in the file, and only pairs seen in at least two orders. The file is not copied anywhere. The nightly jobs blend these pairs into each product's neighbours. Ongoing orders still arrive as `order_placed` events (webhook or your backend). ### Attributes worth sending The engine reads a few editorial attributes per item. Send them in `attributes` on `PUT /catalog/items`, from your shop sync or your CMS. None of them is about a person. | Attribute | Send it for | What it does | |---|---|---| | `format` | every item: `text`, `video`, `audio`, `recipe`, `workout`, `tool`, `product` | `format_fit` matches it to what each user engages with | | `duration_min` or `reading_time_min` | articles, videos, workouts | `time_fit` places short items in the day and long ones in the evening until the item has its own time profile | | `available` (or `in_stock`) | products | `false` is never served, in both arms | | `expires_at` | offers, seasonal content | never served after this time | | `starts_at` | events | a past event is never served | | `repurchase_days` | products | a buyer is not offered the product again inside this window; small for consumables, large for durable goods | | `price_cents`, `currency`, `image_url`, `url` | products | shown on the panel's catalog cards; not ranked on | | `difficulty`, `author` | where you have them | stored with the item; not ranked on yet | The storefront sync fills `available`, the price fields, `image_url`, `url` and `format: "product"` for you. Everything the engine reads, and what it refuses: [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals). ## 3. Create a surface **What.** A surface is one placement in your app, such as Home articles. It has a key, the item kinds it may show, a slot count, an editorial list (`fallback`) and an exploration share. It needs an active flow version that says how to rank. **Where.** Surfaces → new surface. Then Flows to check the first flow version. **Done when.** The surface card exists with status `shadow` and an active flow version. See [Surfaces and flows](https://staging.patterna.nl/docs/surfaces-and-flows). ## 4. Go shadow **What.** Your backend calls Patterna for the surface on every render, exactly as it will in production, and discards the answer: the app still shows your current list. Patterna logs every slate as an impression with arm `none`, so you can check latency, the fallback rate and the reasons before anyone sees a slate. Your feature flag controls what is shown, not whether Patterna is called: a backend that skips the call while the flag is off produces no shadow data. Nothing changes for users. **Where.** Surfaces → the surface → lifecycle control. Status `shadow`. **Done when.** Impressions arrive in the live request stream, the fallback rate is low, and p99 latency sits inside the budget (60 ms cached, 250 ms live). The fallback rate includes slates your client gave up on, because the SDKs report the abandonment. Check a few slates in the phone preview: each slot has a reason. ## 5. Add testers **What.** Show Patterna's slates to a small tester group. Check on a device that reasons render and that seen and click events arrive. **Where.** Surfaces → the surface → testers list (Patterna user ids, or session ids in `non_profiled` mode), then status `testers`. Or let your backend mark its own staff with the `X-Patterna-Tester: 1` header. **Done when.** A tester's seen and click events show up in Audiences → trace lookup under the right request id, and the fallback rate stays low. ## 6. Go live with a holdout **What.** Set the surface to `live` and start a holdout on it. Half the users get Patterna's slate, half get the surface's editorial list. From here the ledger counts. Treatment slates are explored, control slates never are. Slates outside any holdout (arm `none`) are explored too, so exploration may already run before this step. **Where.** Surfaces → status `live`. Proof → Experiments → start a holdout. See [Run a holdout](https://staging.patterna.nl/docs/run-a-holdout). **Done when.** The experiment shows users in both arms and a projected "readable on" date. A row turns readable when each arm reaches the experiment's `readable_sample`, from the power calculation. See [Read a ledger row](https://staging.patterna.nl/docs/read-a-ledger-row). ## Worked example: a content app A content app takes an article list on Home and a "more to read" block under each article live this way: 1. In the panel, it creates `home_articles` and `more_to_read` in `shadow`, backfills the catalog from its backend, and deploys the backend with its own `recommendations` feature flag off for everyone. With the flag off the backend still calls Patterna on every render and shows its own list, so the shadow impressions exist. Nothing changes for users. 2. It turns the flag on for its tester roster and moves both surfaces to `testers`. On iOS and Android it checks that reasons render, that seen and click reach the trace view by request id, and the fallback rate. 3. It moves the surfaces to `live` with a 50/50 holdout and makes the flag public. The ledger reads from here. 4. It watches the fallback rate per surface, the p99 of its own recommendations endpoint, and the opt-in rate of its personalization consent. ## Roll back at any step Your side can always stop Patterna without waiting for us. Keep two switches in your backend: a feature flag (instant, no deploy) and an empty Patterna URL in your config (redeploy). Both serve your own lists. Inside Patterna, set the surface to `off` (requests then get `409 surface_off`) or roll back the flow. See [Roll back a flow](https://staging.patterna.nl/docs/roll-back-a-flow). --- # Run a holdout URL: https://staging.patterna.nl/docs/run-a-holdout > Start a holdout on a surface, size it with the readable sample, keep it valid until it can be read, and end it. A holdout splits users into two arms on one surface. Treatment gets Patterna's slate. Control gets the surface's editorial list. The ledger reports the difference per served slate. Nothing else counts as proof. ## Start one in the panel 1. Open **Proof → Experiments** and choose **Start a holdout**. 2. Pick the surface. It should be `live`. 3. Fill in the fields below. The panel computes the readable sample and the projected date as you type, with a power curve. 4. Start. The experiment appears in the list with users per arm. | Field | What it means | Default | |---|---|---| | `surface` | The surface key, e.g. `home_articles` | required | | `name` | A name you will recognise in the history | required | | `primary_metric` | `tap_rate`, `second_item_rate`, `join_rate` (community surfaces only), `engaged_after_tap` or `purchase_rate` (product surfaces only), defined in [Proof and the ledger](https://staging.patterna.nl/docs/proof-and-the-ledger) | required | | `treatment_share` | Share of units in treatment, above 0 and at most 1 | `0.5` | | `stratify_by` | `none` (default): the split is a plain hash of the unit. `persona` draws the arm within each persona stratum; users without a persona, including `non_profiled` traffic, form their own stratum | `none` | | `baseline_rate` | Today's rate of the metric, e.g. `0.042`: your own analytics for the block you replace, or, for a new block, the surface's control rate after a week in `testers` or `live` (Proof → the surface shows it). Without it the row uses the observed control rate and a 20% change | none | | `relative_change` | The change you want to be able to detect, e.g. `0.2` for +20% | none | Baseline and change to detect set the **readable sample**: how many units per arm the test needs before the interval means something. With today's traffic Patterna projects **`readable_at`**, the date that sample is reached. A small change to detect needs a large sample. If the date is too far away, test for a larger change or pick a busier surface. A ledger row is `readable` only when each arm has reached `readable_sample`. One experiment per surface runs at a time. Starting a second answers `409 experiment_running`. ## Start one through the API ```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 pilot holdout", "primary_metric": "tap_rate", "treatment_share": 0.5, "baseline_rate": 0.042, "relative_change": 0.2 }' ``` The response is the experiment with `id`, `started_at`, `readable_sample` and `readable_at`. List all experiments with `GET https://api.patterna.nl/v1/experiments`. `join_rate` on a surface without community items is refused with `400 metric_not_applicable`. ## How units are assigned - **Sticky.** The arm comes from a hash of the experiment id and the user id, and is stored, so a user stays in the same arm for the whole experiment. - **Sessions in `non_profiled` mode.** The arm comes from a hash of the session id, so opted-out users are covered too. When your app rotates session ids, the same person can land in both arms over a day, and the interval is clustered on sessions, not people. To prevent that, send `assignment_unit`: your backend sends an HMAC-SHA256 of its own user id (64 hex characters), Patterna uses it only to pick the arm and the `unit_hash`, and never stores it raw. See [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors). - **Same UI in both arms.** Only the order and choice of items differ. Do not give one arm a different card or label. ## What the control arm sees Control gets the surface's editorial list: newest items, a curated list of ids, or the 7-day popular list. It is logged with policy `editorial`, propensity 1 and a reason for every slot. It is not a fallback and does not count in the fallback rate. On an anchored surface such as "more to read" under an article, one list for everyone would compare anchored ranking against a list unrelated to the page, so send your own list for this page in `context.control_item_ids` (for example the article's pairs-with products, or the editor's related articles), or set the surface's `fallback.by_anchor` (an object from anchor item id to a list of ids). In order, control serves `context.control_item_ids`, else `fallback.by_anchor` for the anchor, else the surface's own list, with the anchor, `exclude_item_ids`, joined communities, the candidate kinds, premium and locale applied, and newest items filling the rest. The answer's `control_source` says which. That is the comparison you want: Patterna against what you would show without it. When treatment ends up shorter than requested after its filters, it is filled from the same editorial list, and the slate says how many items with `fill_count` (each filled slot is logged with `fill: true`, propensity 1). ## Do not re-sort on the device A surface under an experiment must not be re-sorted by the app. The ledger and the off-policy estimator read the position Patterna served. If the app moves items around, the arms are no longer compared on what was served. The exception is a surface with `device_resort: true`, as long as every interaction carries `displayed_position` (else `422 displayed_position_required`). ## Report honestly - Report seen and taps for both arms the same way. A bug that loses one arm's events shows up as SRM, or worse, as a fake lift. - When your backend gives up waiting and shows its own list, or hides a block it cannot fill, the slate still counts in its arm with no taps. The SDKs report this as abandonment; do the same from your own client. - When the answer says `shown: false` (fewer items than the surface's `min_slots`), it carries no items: hide the block and report nothing, not even an abandonment (abandon answers `409 not_shown`). Such slates count in their arm as served with zero success. - When a user did not allow usage analytics and you will not report anything, send `context.measured: false` so those slates stay out of the ledger in both arms. See [Timeouts and what counts in the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger). ## Watch for SRM A sample-ratio mismatch (SRM) means the arms are not the size you asked for. For a 50/50 test, 5,000 against 4,000 users is a red flag. It usually points to a bug: one arm's events get lost, or the app fails to render one arm. Patterna checks every ledger row. A row with an SRM p-value below 0.001 is flagged and should not be read. Fix the cause before you trust the numbers. ## End a holdout End it from **Proof → Experiments**, or: ```curl curl -sS -X POST "https://api.patterna.nl/v1/experiments/$EXPERIMENT_ID/end" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" ``` The experiment keeps its history and its ledger rows; the ledger window closes at `ended_at`. From then on the surface's slates are logged with arm `none`. A common choice after a clear result is a new experiment with a permanent 10% control (`treatment_share: 0.9`), so the ledger keeps counting. ## Next - [Read a ledger row](https://staging.patterna.nl/docs/read-a-ledger-row) - [Proof and the ledger](https://staging.patterna.nl/docs/proof-and-the-ledger) --- # Read a ledger row URL: https://staging.patterna.nl/docs/read-a-ledger-row > A field-by-field walkthrough of one ledger row: rates, difference, interval, slates and units, readable flag, SRM and revenue. A ledger row answers one question for one surface and one period: how much better or worse did treatment do than control on the primary metric? Read it in this order: is it readable, is the SRM p-value clear, then the difference and its interval. ## An example row ```json { "period_start": "2026-07-20", "period_end": "2026-10-11", "surface": "home_articles", "metric": "tap_rate", "control_rate": 0.042, "treatment_rate": 0.052, "difference": 0.010, "ci_low": 0.004, "ci_high": 0.016, "n_control": 14800, "n_treatment": 15020, "n_users_control": 1480, "n_users_treatment": 1502, "srm_p": 0.61, "readable": true, "revenue_cents_control": null, "revenue_cents_treatment": null, "export_uri": null } ``` The numbers are an example, not a result. ## Field by field | Field | What it says | |---|---| | `period_start`, `period_end` | The period you asked for. Only slates inside the experiment's window count. | | `surface` | The surface key. | | `metric` | The primary metric: `tap_rate`, `second_item_rate`, `join_rate` (community surfaces) or `engaged_after_tap`. | | `control_rate` | Slates with at least one tap over slates served, in the control arm (the editorial list). Here 4.2%. | | `treatment_rate` | The same for Patterna's slate. Here 5.2%. | | `difference` | Treatment minus control: +1.0 percentage point. | | `ci_low`, `ci_high` | The 95% interval around the difference: +0.4 to +1.6 points. `null` while an arm has fewer than two units. | | `n_control`, `n_treatment` | Slates served in each arm. | | `n_users_control`, `n_users_treatment` | Randomised units in each arm: users, or sessions in `non_profiled` mode. | | `srm_p` | The sample-ratio-mismatch p-value. Below 0.001 the row carries a `note` and must not be read. | | `readable` | `true` when each arm has reached the experiment's `readable_sample` (from the power calculation) and the interval exists. | | `revenue_cents_control`, `revenue_cents_treatment` | Reserved for revenue attributed by last touch. Not computed yet: always `null`. | | `export_uri` | Link to the export for this period, once one has been made. | | `note` | Present when there is no interval yet or when SRM is flagged. | ## How to read it **The interval decides, not the point.** If the whole interval is above zero, treatment did better. If it crosses zero, the data cannot tell the arms apart yet. Here +0.4 to +1.6 is above zero: Patterna's slate got more tapped slates than the editorial list. **The metric is per slate, the interval is per unit.** One user sees many slates, and their taps are not independent. So the interval is clustered on the randomised unit with the delta method. That is why the row carries both slates (`n_*`) and units (`n_users_*`). See [Proof and the ledger](https://staging.patterna.nl/docs/proof-and-the-ledger). **Every served slate counts.** The ledger is intention to treat: slates the user never scrolled to, and slates the app gave up on, count as served with no tap, and so do slates answered with `shown: false`. Only slates requested with `context.measured: false` are left out, in both arms. See [Timeouts and what counts in the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger). **`readable: false` means wait** until each arm reaches the experiment's `readable_sample`; the panel shows the interval dashed amber with the projected "readable on" date until then. **An SRM flag means stop.** If the arm sizes differ from the split you asked for more than chance allows, do not read the row. Find the cause first. See [Run a holdout](https://staging.patterna.nl/docs/run-a-holdout). **Revenue is not computed yet.** When it is, it will be a last-touch diagnostic, never proof. Retention and conversion are reported, not proven, until the user base is large enough. **Synthetic rows never count.** Example data from the twin is flagged `synthetic` and excluded from the ledger. The "Real / Example" switch never mixes them. ## Check it yourself A row is computed from the logged impressions and the interactions reported on them. The export below gives you both, so you can check the slate and tap counts. It does not yet carry the clustering unit, `measured`, `not_shown`, `tester`, `fallback`, blocks or the experiment window, so it cannot reproduce a row's interval on its own; that is an open item on our side. ```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-07-20", "to": "2026-10-11", "surface": "home_articles" }' ``` The answer is `202` with a `job_id`. Poll `GET https://api.patterna.nl/v1/proof/exports/{job_id}` until it is ready, then download it. In the panel, use **Proof → Export**: pick the period, wait for the file, download it. The note there spells out the arithmetic. Fetch the rows themselves with: ```curl curl -sS "https://api.patterna.nl/v1/proof/ledger?surface=home_articles&from=2026-07-20&to=2026-10-11" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" ``` Without `from` and `to` the ledger covers the last 28 days. ## Weekly rows `GET https://api.patterna.nl/v1/proof/ledger?surface=home_articles&bucket=week` returns one row per ISO week and arm: `week_start`, `arm`, `impressions`, `taps`, `rate` and a Wilson interval (`lo`, `hi`). The weeks are disjoint, not cumulative. They show the trend; the summary row above is the result. ## Rows per block When one slate is shown in two blocks of a page and the interactions carry `block` (for example `top` and `end`), ask the ledger with `by=block` (every block) or `block=<name>`: it returns one row per surface and block, over the slates reported seen in that block and the taps in it. Those rows are descriptive; the whole-slate row decides. --- # Fix a tag and approve a health claim URL: https://staging.patterna.nl/docs/fix-a-tag > Edit an item's tags in Catalog and approve LLM-proposed health-claim tags under the two-person rule. Tags decide which items reach which users. The enrichment job proposes them; you have the last word. A tag you set or approve is never overwritten by the job. ## Fix a tag 1. Open **Catalog** and find the item. Search, or filter by kind, collection, status, tag kind or enrichment. 2. Open the item. Its tags are grouped by kind, each with a weight. 3. Remove a wrong tag, add a missing one, or change a weight. The tag picker offers the tags of your taxonomy. 4. Save. The change is written to the audit log with your name. Tag kinds and their colours on the chips: | Kind | What it describes | |---|---| | `topic` | What the item is about | | `goal` | The user goal it serves | | `format` | Recipe, workout, article and so on | | `audience` | Who it is written for | | `phase` | Which phase it fits | | `claim` | A health claim the item makes | ## What the enrichment job may and may not do - It tags only against your taxonomy. It cannot invent a tag. - It writes its tags with source `llm`. Editor tags and imported tags are never touched. - It re-tags an item only when the item's text changed. - An item it could not tag is marked `failed` and kept for review. ## Approve a health claim A health-claim tag says the item claims a health effect, such as "improves sleep". Those tags arrive with `approved = false` and wait for an editor. They show in the Catalog summary as "claims awaiting approval". 1. Open **Catalog** and filter on claims awaiting approval. 2. Open the item and read the text the claim is based on. 3. Approve the claim tag if the item supports it. Remove it if it does not. 4. A second person approves as well. Health claims follow the two-person rule: one person requests, another approves. The request, approvers and outcome go to the audit log. Once an editor has approved a claim tag on an item, the job keeps it approved on later runs. A tag is a health claim when the taxonomy marks it with `health_claim: true` (`GET` and `PUT https://api.patterna.nl/v1/taxonomy`), or when it has the `claim:` prefix, for example `claim:verbetert-slaap`. Bulk actions never approve a health-claim tag: those always wait for a person. ## Many items at once Select several items in the table, then choose **Approve tags**, **Add tag**, **Re-enrich** or **Archive**. Each bulk action writes one audit note. ## Through the API Your backend can set editor tags directly. Use the tag slugs of your taxonomy. Pass the item's `kind` as a query parameter; without it the call looks for a `document`. ```curl curl -sS -X PUT "https://api.patterna.nl/v1/catalog/items/doc_1042/tags?kind=document" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \ -H "Content-Type: application/json" \ -d '{ "tags": [ { "tag": "magnesium", "weight": 1, "approved": true }, { "tag": "claim:verbetert-slaap", "weight": 1, "approved": false } ] }' ``` `weight` defaults to `1` and `approved` to `true`. The call sets the item's editor tags; send the full list you want. This call does **not** go through the two-person rule: a claim tag sent with `approved: true` is approved at once. Send claim tags with `"approved": false` and approve them in the panel, so a second person signs off. ## Check the result Open the item again. The tag chips show the new state and the enrichment history shows who changed what. If you want to know how good the job's tags are overall, use the gold set in **Catalog**: build a sample, fill it in, upload it, and read the precision and recall per tag kind. --- # Roll back a flow URL: https://staging.patterna.nl/docs/roll-back-a-flow > Roll a surface back by activating an earlier flow version with a note, or switch Patterna off on your side instantly. A flow is the recipe of a surface: generators, filters, ranker, exploration and reasons. Every save creates a new version. Only one version is active per surface. Rolling back means activating an earlier version. Nothing is deleted, so you can roll forward again the same way. ## In the panel 1. Open **Flows** and pick the surface. 2. Open the version list. The active version is marked, with who activated it and when. 3. Compare the active version with the one before it. Versions diff side by side. 4. Choose **Roll back** on the earlier version. 5. Write a short note: what went wrong and what you saw. The note is required and goes to the audit log. 6. Confirm. On a `live` surface with the tenant's two-person rule on, you request the activation and a second person approves it; approving applies it. Until then the current version keeps serving. The rollback shows as a marker on every time chart of the surface, with your note on hover. ## Through the API List the versions of a surface: ```curl curl -sS "https://api.patterna.nl/v1/surfaces/home_articles/flows" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" ``` Each entry has `version`, `config`, `note`, `created_at`, `activated_at` and `created_by`. Pick the version you want back, then activate it: ```curl curl -sS -X POST "https://api.patterna.nl/v1/surfaces/home_articles/flows/3/activate" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \ -H "Content-Type: application/json" \ -d '{ "note": "Roll back to v3: fallback rate rose after v4" }' ``` The engine accepts the call with or without a body; the `note` goes into the audit log and the notification. The panel always sends one and refuses an empty one, so every activation from the panel carries its reason. Do the same from scripts. On a `live` surface with the two-person rule on, the call answers `409 approval_required`; request an approval instead (`POST https://api.patterna.nl/v1/approvals`). ## Check that it worked - **Surfaces** shows the version you activated as the active flow version. - The live request stream shows new slates with the old version. - **Governance → audit log** has the activation with your note. ## When to roll back, and when to do something else - A new version performs worse or behaves oddly: roll back. - The surface itself must stop: set the surface to `off` in **Surfaces**; requests then get `409 surface_off` and your backend shows its own list. That is a status change, not a rollback. - Before activating a new version next time, use **Simulate** in the flow editor. It replays the version on logged traffic and shows the SNIPS estimate with its effective sample, without changing anything. ## Rollback on your side You can always stop Patterna from your own backend, without the panel. Keep two switches: - **Feature flag off.** A flag in your own dashboard. Instant, no deploy. The app gets your own lists. - **Empty URL.** An empty Patterna URL in your backend's config, then a redeploy. The backend no longer calls Patterna and no catalog sync runs. Both serve the customer's own lists, which is the same thing that happens on a timeout. --- # Handle a deletion request URL: https://staging.patterna.nl/docs/handle-a-deletion-request > Delete or export one user's data by random id and follow both timestamps until rows and models are clean. When a user deletes their account or asks for their data, your backend makes one call. Patterna knows the user only by the random id you minted, so you look that id up on your side first. ## Setup for the code examples Every example below comes in four tabs. The TypeScript and Go tabs use the SDKs for serve and ingest calls. The SDKs have admin methods too (`applyFlow`, `setSurfaceStatus`, `ledger` and so on), but these tabs call the admin REST API through the small `rest` helper defined here so every request is visible. The Python tab calls the REST API with httpx; the Python SDK (`patterna-sdk`, not on PyPI yet: install it from the public repository payam-h/patterna-sdk) 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. # Serve keys start with pk_s_, ingest keys with pk_i_, admin keys with pk_a_. export PATTERNA_SERVE_KEY="pk_s_paste-the-serve-key" export PATTERNA_INGEST_KEY="pk_i_paste-the-ingest-key" export PATTERNA_ADMIN_KEY="pk_a_paste-the-admin-key" ``` ```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") ``` ## Delete a user Deletion takes the ingest key, the same key your backend already holds. An admin key works too. ```curl curl -sS -X DELETE "https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" ``` ```ts const deletion = await patterna.deleteUser("u_mfrggzdfmztwq2lknnwg23tpobyxe"); console.log(deletion.id, deletion.rows_deleted_at); // rows are gone; models_retrained_at is set after the nightly retrain ``` ```go del, err := pc.DeleteUser(ctx, "u_mfrggzdfmztwq2lknnwg23tpobyxe") if err != nil { return err } fmt.Println(del.ID, del.RowsDeletedAt) // rows are gone; ModelsRetrainedAt is set after the nightly retrain ``` ```python r = ingest.delete("/users/u_mfrggzdfmztwq2lknnwg23tpobyxe") r.raise_for_status() # 202 deletion = r.json() print(deletion["id"], deletion["rows_deleted_at"]) ``` The answer is `202 Accepted` with the deletion request. The rows are already gone when it arrives: ```json { "id": "6f1c2b9e-3d4a-4f0e-9a51-2c7d8e1b0a44", "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe", "requested_at": "2026-10-13T09:12:00Z", "rows_deleted_at": "2026-10-13T09:12:00Z", "models_retrained_at": null, "stored_as": "sha256" } ``` `stored_as: "sha256"`: the deletion record keeps a salted hash of the id, not the id, so the record of a deletion is not itself personal data. The SDKs wrap this call: `deleteUser(userId)` in TypeScript, `DeleteUser(ctx, id)` in Go. ## What happens next Deletion is synchronous. In one transaction, before the answer, the engine: - deletes the user row, the holdout assignments, the opt-out marker, the user's events and precomputed shortlist; - unlinks the user's impressions: the user id is removed and the logged candidate list emptied. The slates stay in the ledger under 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 the hash 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 after the fact. `rows_deleted_at` is therefore set in the answer. One timestamp is left: - **`models_retrained_at`.** Model artifacts learned from the user's data are cleared through the retrain schedule. The nightly learning run (02:00 UTC) stamps this field on every open request once it has retrained without the deleted rows. **Governance → deletion requests** shows every request with both timestamps. Audiences → trace lookup shows the status for a single id. ## Export a user's data ```curl curl -sS "https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" ``` ```ts const data = await rest("PATTERNA_ADMIN_KEY", "GET", "/users/u_mfrggzdfmztwq2lknnwg23tpobyxe"); console.log(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, _ := json.MarshalIndent(export, "", " ") fmt.Println(string(b)) ``` ```python r = admin.get("/users/u_mfrggzdfmztwq2lknnwg23tpobyxe") r.raise_for_status() print(r.json()) ``` The answer is every row Patterna holds about that id, as JSON. Hand it to the user together with your own export. ## Consent revoked: an opt-out, not a deletion When a user withdraws consent for personalized recommendations but keeps the account, set their mode to `non_profiled`: ```curl curl -sS -X PUT "https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe/mode" \ -H "Authorization: Bearer $PATTERNA_INGEST_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 = ingest.put("/users/u_mfrggzdfmztwq2lknnwg23tpobyxe/mode", json={"mode": "non_profiled"}) r.raise_for_status() ``` Switching to `non_profiled` is an opt-out, with 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 (`PUT /users/{id}/mode` `non_profiled`) | Deletion (`DELETE /users/{id}`) | |---|---|---| | User row and profile | deleted in the request | deleted in the request | | Events, holdout assignments, shortlist | deleted by the hourly `deletions` job | deleted in the request | | Impressions | kept, unlinked by the hourly job | kept, unlinked in the request | | Interactions | kept with their slate | kept with their slate | | Serving | at once: profiled requests for the id are served as `non_profiled` | the id is unknown; a new profile needs `mode` | | What Patterna keeps about the id | a salted hash, as the opt-out marker serve honours | a salted hash in the deletion record; the opt-out marker is removed | | Models | forget at the next nightly retrain | forget at the next nightly retrain (`models_retrained_at`) | From then on the user is served on a rotating session id only. See [Consent modes](https://staging.patterna.nl/docs/consent-modes). ## The pattern on your side This pattern keeps deletion reliable without keeping personal data: - **Keep the mapping on your side.** A table maps your account id to the random Patterna id. Patterna never sees your account id or an email. - **Tell Patterna first, then delete the mapping.** Look up the Patterna id, call delete, then remove the account and its mapping row. - **Never block the user's deletion.** If the call fails, store only the random id in a retry table and delete the account anyway. Once the mapping row is gone the random id points to nobody. - **Retry until confirmed.** A loop (every 10 minutes is plenty) resends pending deletions and removes each id once Patterna answers. - **Rotate on revoke.** After a successful switch to `non_profiled`, delete the mapping row. A later opt-in mints a fresh id that cannot be joined to the old history. - **One path for every deletion.** Staff tools and retention jobs that delete users call the same erase function. ## Which key | Call | Key | |---|---| | `DELETE /users/{user_id}` | ingest (`pk_i_…`) or admin | | `PUT /users/{user_id}/mode`, `PUT /users/{user_id}/profile` | ingest or admin | | `GET /users/{user_id}` (export), `GET /users/{user_id}/trace` | admin (`pk_a_…`) | A customer backend needs no admin key for consent and deletion. Both SDKs send the admin key when one is configured and the ingest key otherwise; configure only the ingest key on a backend. --- # Rotate keys and the signing secret URL: https://staging.patterna.nl/docs/rotate-keys-and-signing-secret > Replace a scoped API key without downtime, sign events with the pss_ secret string (with a test vector), and rotate the secret. Rotate a key in four steps: create the new one, deploy it, check it is used, revoke the old one. Two keys of the same scope can be active at the same time, so there is no gap. ## 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 (0.2) 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") ``` ## How keys work - Every key has one scope, visible in its prefix: `pk_s_` (serve), `pk_i_` (ingest), `pk_a_` (admin). A serve or ingest key only works on the endpoints of its scope (`401 wrong_scope` elsewhere); an admin key works everywhere, so keep it off request paths. - The tenant comes from the key, never from the request body. - Patterna stores a SHA-256 hash of the key, not the key. The full secret is shown once, when you create it. - Lists show the key's first ten characters only, such as `pk_s_Xb3kQ`, with its name, scope, environment and last use. - `environment` is a label for your own bookkeeping. It does not isolate data: a test key reads and writes the same tenant. Use a separate staging tenant for tests. ## 1. Create the new key In **Governance → API keys** choose **Create key**, pick the scope and environment, and copy the secret. Or through the API: ```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 2026-10", "scope": "serve", "environment": "production" }' ``` ```ts const key = await rest<{ id: string; prefix: string; secret: string }>("PATTERNA_ADMIN_KEY", "POST", "/keys", { name: "backend serve 2026-10", 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 2026-10", "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 2026-10", "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"]) ``` The answer holds the key's `id`, `prefix` and, this one time, `secret`. Put the secret straight into your secret store. If you lose it, create another key. ## 2. Deploy it Replace the old value in your backend's environment, for example `PATTERNA_SERVE_KEY`, and deploy. Keys belong on a backend only. An app talks to your backend proxy, never to Patterna with a key. ## 3. Check it is used In **Governance → API keys** the new key shows a recent "last used" time. The old key's last use stops moving once every instance runs the new value. You can also list keys: ```curl curl -sS "https://api.patterna.nl/v1/keys" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" ``` ```ts const keys = await rest<Array<{ id: string; name: string; scope: string; prefix: string; revoked_at: string | null }>>( "PATTERNA_ADMIN_KEY", "GET", "/keys", ); console.table(keys); ``` ```go var keys []struct { ID string `json:"id"` Name string `json:"name"` Scope string `json:"scope"` Prefix string `json:"prefix"` RevokedAt *string `json:"revoked_at"` } if err := rest(ctx, "PATTERNA_ADMIN_KEY", "GET", "/keys", nil, &keys); err != nil { return err } for _, k := range keys { fmt.Println(k.ID, k.Name, k.Scope, k.Prefix, k.RevokedAt) } ``` ```python r = admin.get("/keys") r.raise_for_status() for k in r.json(): print(k["id"], k["name"], k["scope"], k["prefix"], k["revoked_at"]) ``` ## 4. Revoke the old key ```curl curl -sS -X DELETE "https://api.patterna.nl/v1/keys/$OLD_KEY_ID" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" ``` ```ts await rest("PATTERNA_ADMIN_KEY", "DELETE", `/keys/${process.env.OLD_KEY_ID}`); // 204 ``` ```go if err := rest(ctx, "PATTERNA_ADMIN_KEY", "DELETE", "/keys/"+os.Getenv("OLD_KEY_ID"), nil, nil); err != nil { return err } ``` ```python r = admin.delete(f"/keys/{os.environ['OLD_KEY_ID']}") r.raise_for_status() # 204 ``` Set `OLD_KEY_ID` to the `id` from the list first. The answer is `204 No Content`. From then on the old key gets `401`. Revoking is audited. If a key leaked, skip the waiting: revoke it first, then create and deploy the new one. In the meantime your backend serves its own lists, as it does on any failed serve call. ## The signing secret You do not need the signing secret to send events. `POST /events` takes an ingest key (or admin), and every event sent with such a key is trusted. There is no separate SDK key scope. The secret is for one case: you want a second check that an event really came from your backend, for example because it passes through a queue or a relay. A `signature` on an event is optional. When it is present, the engine always verifies it, and a wrong signature rejects that event (the rest of the batch is stored). A signed event for a tenant that has no secret yet is rejected with `no_signing_secret`. Rules for the secret: - It lives on your backend, next to your keys. Never in an app bundle: a secret in an app is a public secret. - It is `pss_` followed by 64 hex characters (since engine 0.2). A secret from before that has no prefix and no longer verifies: rotate it once. ## Sign an event 1. Take the event as you will send it and remove `signature`. 2. Serialise it as canonical JSON: keys sorted at every level, no whitespace, `<`, `>` and `&` not escaped, U+2028 and U+2029 escaped, no trailing newline. Keep numbers integers where you can; the engine re-encodes numbers the way Go does. 3. Compute HMAC-SHA256 over those bytes with the key below. 4. Put the lowercase hex digest in `signature`. The key is the `pss_…` secret string itself, its UTF-8 bytes, nothing decoded. This is what all three SDKs do. Test vector. Check your code before you sign anything real; the canonical body is `{"event_id":"evt_1","occurred_at":"2026-10-13T09:00:00Z","props":{"item_id":"doc_42","kind":"document","position":0},"request_id":"req_1","session_id":"s_1","type":"rec_click"}`. | Secret | Key | Signature | |---|---|---| | `pss_000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f` | the string's UTF-8 bytes | `8fefcd355112cecfce0cde75bdc7566d7d6620918e6b6109bfbd7878e317d815` | If you get `3e3a9cbc22f2101a…`, you hex-decoded the part after `pss_`; that was the key for secrets before engine 0.2. ```curl # Shell check only. jq + openssl match the canonical form ONLY when every number is an # integer and no string contains U+2028 or U+2029. event.json is the event as you will send it. # The key is the whole pss_ secret string. jq -jcS 'del(.signature)' event.json \ | openssl dgst -sha256 -hmac "$PATTERNA_SIGNING_SECRET" | awk '{print $NF}' ``` ```ts import { signEvent } from "patterna-sdk"; // Keys the HMAC with the pss_ secret string. const signed = await signEvent(event, process.env.PATTERNA_SIGNING_SECRET!); ``` ```go // The secret is the pss_ string, used byte for byte. signed, err := patterna.SignEvent(event, os.Getenv("PATTERNA_SIGNING_SECRET")) if err != nil { return err } fmt.Println(signed.Signature) ``` ```python import hashlib import hmac import json def canonical_json(event: dict) -> bytes: # The event minus "signature"; leave absent fields out instead of sending None. body = {k: v for k, v in event.items() if k != "signature"} text = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False) # The canonical form escapes U+2028 and U+2029; json.dumps leaves them raw. text = text.replace("\u2028", "\\u2028").replace("\u2029", "\\u2029") return text.encode("utf-8") def sign_event(event: dict, secret: str) -> dict: key = secret.encode("utf-8") # the pss_ string, byte for byte sig = hmac.new(key, canonical_json(event), hashlib.sha256).hexdigest() return {**event, "signature": sig} event = { "event_id": "evt_1", "type": "rec_click", "occurred_at": "2026-10-13T09:00:00Z", "session_id": "s_1", "request_id": "req_1", "props": {"item_id": "doc_42", "kind": "document", "position": 0}, } secret = "pss_000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" assert sign_event(event, secret)["signature"] == "8fefcd355112cecfce0cde75bdc7566d7d6620918e6b6109bfbd7878e317d815" ``` ## Get or rotate the secret - **Rotate:** `POST /settings/signing-secret/rotate` (an owner in the panel, staff, or an admin key). The answer holds the new secret once. The old secret stops verifying at that moment, and events signed with it are rejected. So: stop signing (send unsigned for a minute), rotate, deploy the new secret, sign again. Rotate also creates a first secret when there is none. - **First secret:** `POST /settings/signing-secret` creates the first secret when the tenant has none and answers `200` with `signing_secret`, `issued_at` and a `note`, once. When one exists it answers `409 signing_secret_exists`; rotate instead. ```curl curl -sS -X POST https://api.patterna.nl/v1/settings/signing-secret/rotate \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" # {"signing_secret":"pss_<64 hex characters, shown once>","issued_at":"…","note":"…"} ``` The rotation is in the audit log as `signing_secret.rotate`. --- # Integrate with your AI agent URL: https://staging.patterna.nl/docs/agents/integrate-with-your-ai-agent > Step-by-step brief for a coding agent: create keys, load the catalog, set consent, recommend, report interactions, send events and verify with the trace. This page is written to be handed to a coding agent as is. It lists the calls in order, what a correct answer looks like after each one, and the mistakes to avoid. A human can follow it the same way. Run every call against `https://api.patterna.nl/v1` and stop at the first failed check. ## Rules the agent must follow 1. **Never send personal data.** No email, name, IP, device id, free text, coordinates or health values, anywhere. User ids are random ids you mint, never an email or an identity-provider subject like `auth0|...`. 2. **`non_profiled` requests carry no `user_id`.** They carry a `session_id` only. Sending both is a privacy bug, not a style issue. 3. **Impressions are logged server-side.** Every `POST /recommendations` writes an impression row before it answers. Never call it to "test" from a loop, never retry it, and never call it for a list you will not show (use `"shadow": true` for that). 4. **Keys stay on the backend.** No Patterna key and no signing secret in an app bundle or in client-side code. 5. **Report the served position and the kind.** Use the `position` from the slate (positions start at 0: `items[0]` is position `0`), even if the app re-sorts on the device, and the item's `kind` from the slate. 6. **Quote the request id.** Every response carries `X-Request-Id`. Log it with every failure. ## Prerequisites - An `admin` key for the tenant, created once by a person in the panel (Connectors or Governance). The secret is shown once. It starts with `pk_a_`; serve keys start with `pk_s_`, ingest keys with `pk_i_`. - A shell with `curl` and `jq`. - These variables: ```sh export PATTERNA_API="https://api.patterna.nl/v1" export PATTERNA_ADMIN_KEY="paste-the-admin-secret-here" ``` - A random user id to test with. Mint it; never derive it from an account id or email: ```sh export TEST_USER="u_$(openssl rand -hex 16)" echo "$TEST_USER" ``` ## Step 1. Create a serve key and an ingest key ```curl curl -sS -X POST "$PATTERNA_API/keys" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "backend serve", "scope": "serve", "environment": "production"}' ``` ```curl curl -sS -X POST "$PATTERNA_API/keys" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "backend ingest", "scope": "ingest", "environment": "production"}' ``` **Check.** Status `201`. The body has `scope` as requested, a `prefix`, `revoked_at: null` and a `secret`. The secret is returned only here. Store both: ```sh export PATTERNA_SERVE_KEY="paste-the-serve-secret" export PATTERNA_INGEST_KEY="paste-the-ingest-secret" ``` ## Step 2. Create a surface in shadow Skip this if the surface already exists (`GET /surfaces`). In `shadow` your backend calls Patterna on every render exactly as it will in production, the engine logs the impression, and your app shows its own list instead of the answer. A feature flag in your backend decides what is shown, not whether Patterna is called: with the flag off, call Patterna and discard the slate, so shadow produces impressions you can check. ```curl curl -sS -X POST "$PATTERNA_API/surfaces" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \ -H "Content-Type: application/json" \ -d '{ "key": "home_articles", "name": "Home articles", "item_kinds": ["document"], "slots": 6, "fallback": {"kind": "newest"}, "exploration_share": 0.2, "status": "shadow" }' ``` **Check.** Status `201`, `status` is `shadow`. Give it a first flow and activate it with a note: ```curl curl -sS -X POST "$PATTERNA_API/surfaces/home_articles/flows" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \ -H "Content-Type: application/json" \ -d '{ "note": "First flow", "config": { "generators": [ {"kind": "trending", "weight": 0.5}, {"kind": "content_affinity", "weight": 0.5} ], "ranker": {"kind": "linear"}, "exploration": {"kind": "plackett_luce", "temperature": 0.7, "share": 0.2} } }' ``` **Check.** Status `201`, the body has a `version` and `activated_at: null`. Use that version: ```curl curl -sS -X POST "$PATTERNA_API/surfaces/home_articles/flows/1/activate" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \ -H "Content-Type: application/json" \ -d '{"note": "First flow for shadow traffic"}' ``` **Check.** Status `200`. `GET /surfaces` shows `active_flow_version: 1` for `home_articles`. The note is optional and lands in the audit log. If the tenant's two-person rule is on and the surface is live, activation answers `409 approval_required`; a person approves it in the panel. ## Step 3. Load the catalog Send up to 500 items per call. Send only editorial fields. `text` is used for tagging and never returned. ```curl curl -sS -X PUT "$PATTERNA_API/catalog/items" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \ -H "Content-Type: application/json" \ -d '{ "items": [ { "external_id": "doc_1042", "kind": "document", "collection": "recipes", "title": "Magnesium-rich breakfast bowl", "locale": "nl", "status": "published", "premium": false, "published_at": "2026-09-30T08:00:00Z", "text": "A breakfast bowl with oats, seeds and cocoa." }, { "external_id": "doc_1043", "kind": "document", "collection": "insights", "title": "Sleep and magnesium", "locale": "nl", "status": "published", "premium": true, "published_at": "2026-10-02T08:00:00Z" } ] }' ``` **Check.** Status `200`, `upserted` equals the number of items sent. The call is idempotent: running it again changes nothing. Then: ```curl curl -sS "$PATTERNA_API/catalog/items?kind=document&limit=10" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" ``` **Check.** Both ids appear in `items`. ## Step 4. Set the user's mode and profile Do this only for a user who opted in. For everyone else skip to step 5 with `non_profiled`. ```curl curl -sS -X PUT "$PATTERNA_API/users/$TEST_USER/mode" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \ -H "Content-Type: application/json" \ -d '{"mode": "personalized"}' ``` ```curl curl -sS -X PUT "$PATTERNA_API/users/$TEST_USER/profile" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "personalized", "premium": false, "goals": ["better-sleep"], "activity_level": "moderate", "stress_level": "high", "locale": "nl" }' ``` **Check.** Both return `200`. `goals` must be tags from the tenant taxonomy; `better-sleep` is an example. Profile fields are declared attributes only, never health values. These calls take the ingest key: your backend owns consent. An admin key works too. ## Step 5. Ask for a slate Personalized: ```curl curl -sS -X POST "$PATTERNA_API/recommendations" \ -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \ -H "Content-Type: application/json" \ -d "{ \"surface\": \"home_articles\", \"mode\": \"personalized\", \"user_id\": \"$TEST_USER\", \"context\": {\"locale\": \"nl\", \"platform\": \"ios\", \"hour_bucket\": 8}, \"slots\": 6 }" | tee /tmp/slate.json ``` Non-profiled (no `user_id`, a `session_id` instead): ```curl curl -sS -X POST "$PATTERNA_API/recommendations" \ -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \ -H "Content-Type: application/json" \ -d '{ "surface": "home_articles", "mode": "non_profiled", "session_id": "s_7f3a9c2e41d84b0f", "context": {"locale": "nl", "platform": "ios"} }' ``` **Check.** Status `200`. The body has a non-empty `request_id`, `surface`, `arm` (`control`, `treatment` or `none`), `policy_version`, and `items`. Every item has `item_id`, `kind`, `position`, `reason_key` and a `reason` map with the locale you need. `position` counts from 0. `fallback: true` means the safety net answered (budget or storage trouble, or no active flow); that is valid, not an error. A `control` slate is the surface's editorial list with `fallback: false`. Error codes to expect: `404 unknown_surface` (wrong key), `409 surface_off`, `422 mode_conflict` (a `user_id` in `non_profiled`), `422 no_candidates` (no published item of the surface's kinds and locale), `400` for any unknown field. Budget: call this with a 150 ms timeout from your backend and show your own list on timeout. Do not retry. Send a UUID you mint as `X-Request-Id` and your remaining budget as `X-Deadline-Ms` (the engine's own budget is 120 ms), and after a timeout call `POST /recommendations/{request_id}/abandon` (the SDKs do all three for you). The engine refuses interactions for items that were not in the slate (`422 unknown_item`). A slate with `shown: false` is already logged as not shown: render nothing and report nothing for it. ## Step 6. Report what the user saw and did Echo the `request_id`, the served `position` and the item's `kind`: ```curl REQUEST_ID=$(jq -r .request_id /tmp/slate.json) ITEM_ID=$(jq -r '.items[0].item_id' /tmp/slate.json) ITEM_KIND=$(jq -r '.items[0].kind' /tmp/slate.json) curl -sS -X POST "$PATTERNA_API/interactions" \ -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \ -H "Content-Type: application/json" \ -d "{ \"request_id\": \"$REQUEST_ID\", \"interactions\": [ {\"item_id\": \"$ITEM_ID\", \"kind\": \"$ITEM_KIND\", \"position\": 0, \"type\": \"seen\", \"occurred_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}, {\"item_id\": \"$ITEM_ID\", \"kind\": \"$ITEM_KIND\", \"position\": 0, \"type\": \"click\", \"occurred_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"} ] }" ``` **Check.** Status `202`. Up to 50 interactions per call. A `404 unknown_request` means the `request_id` has no impression. Send `kind` from the slate for your own logs; the engine ignores it and takes the kind and the served position from the logged slate. Never report interactions for a fallback list your own backend produced: it has no request id. ## Step 7. Send events Events from your backend with the ingest key are trusted and marked `signed`; no signature needed. `event_id` is the idempotency key; derive it from the source id. ```curl curl -sS -X POST "$PATTERNA_API/events" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \ -H "Content-Type: application/json" \ -d "{ \"events\": [ { \"event_id\": \"completed:doc_1042:$TEST_USER:2026-10-13\", \"type\": \"item_completed\", \"occurred_at\": \"2026-10-13T09:30:00Z\", \"user_id\": \"$TEST_USER\", \"source\": \"backend\", \"context\": {\"locale\": \"nl\", \"platform\": \"ios\"}, \"props\": {\"item_id\": \"doc_1042\", \"collection\": \"recipes\"} } ] }" ``` **Check.** Status `202` with `accepted: 1`, `duplicates: 0`, `rejected: []`. Send the same call again: `accepted: 0`, `duplicates: 1`. `dropped_fields` lists props or context keys outside the allowlist; they were not stored. Remove them, do not rename them. An event in `rejected` has a `reason` such as `unknown type` or `user_id or session_id malformed`. ## Step 8. Verify with the trace ```curl curl -sS "$PATTERNA_API/users/$TEST_USER/trace?limit=5" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" ``` **Check.** The newest trace has the `request_id` from step 5, `mode: "personalized"`, the `arm`, and a `slate` where the item you clicked has a `propensity`, `reason_key`, `generator` and `outcome: "click"`. If the outcome is `none`, the interaction did not match: compare `request_id`, `item_id` and `position`. ## Step 9. Clean up the test user ```curl curl -sS -X DELETE "$PATTERNA_API/users/$TEST_USER" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" ``` **Check.** Status `202` with `rows_deleted_at` already set: the rows are gone when the call returns. `models_retrained_at` stays `null` until the nightly retrain. `GET /users/$TEST_USER` with the admin key now returns no profile and no events. ## Pitfalls | Symptom | Cause | Fix | |---|---|---| | `consent_violation` from an SDK, `422 mode_conflict`, or a `400` | `non_profiled` with a `user_id`, or without a `session_id` | Send `session_id` only in `non_profiled` mode | | `consent_required` | `mode` missing | Always send the mode you resolved from the user's consent | | `ValidationError` on a user id | an email or `provider|id` passed as user id | Mint a random id and keep the mapping on your side | | `dropped_fields` not empty in the events answer | a prop outside the per-type allowlist, or a context key other than `locale`, `app_version`, `platform`, `hour_bucket`, `weekday`, `region`, `subscription_tier`, `tenure_days` | Remove the field | | `422 pii_rejected` on a catalog upsert | an item attribute named `email`, `name` or `ip` | Remove the attribute | | `422 no_candidates` | no published item of the surface's kinds and locale survives the filters | Load the catalog with that locale; show your own list meanwhile | | `400` with "unknown field" | a field the engine does not know (bodies are decoded strictly) | Remove it, or upgrade the engine | | Ledger shows more impressions than users saw | `recommend` called for lists that were never shown, or retried | Call once per render; use `"shadow": true` for previews | | An event in `rejected` with `invalid signature` | a secret from before engine 0.2 (no `pss_` prefix), or a key other than the secret string | rotate once and sign with the `pss_` string as is. Or send without a signature | | Interactions not in the trace | wrong `request_id`, or the displayed position sent as `position` (positions start at 0) | Echo the slate's `request_id` and served `position`; put the displayed one in `displayed_position` | | `401` (`unauthorized` or `wrong_scope`) | missing, revoked or wrongly scoped key | serve key (`pk_s_`) for `/recommendations` and `/interactions`, ingest key (`pk_i_`) for `/events`, `/catalog` and the user mode, profile and delete calls, admin key (`pk_a_`) for the rest | | `429 rate_limited` | the key's rate limit (serve 200/s, ingest 50/s) | back off; never retry `recommend` | ## Signatures Events sent with the ingest key need no signature, and there is no separate SDK key scope. A signature is optional; if you send one it is verified, and a wrong one rejects that event. It is lowercase hex HMAC-SHA256 over the event without `signature`, serialised as JSON with keys sorted in ASCII order, no whitespace, HTML characters unescaped, U+2028 and U+2029 escaped, numbers as integers, **keyed with the `pss_` secret string itself**. Check your code against the test vectors in [Rotate keys and the signing secret](https://staging.patterna.nl/docs/rotate-keys-and-signing-secret#sign-an-event). Sign on the backend only. ## Where to go next - Per-endpoint examples in curl, TypeScript, Go and Python: [Serve scope](https://staging.patterna.nl/docs/api-serve), [Ingest scope](https://staging.patterna.nl/docs/api-ingest), [Admin scope](https://staging.patterna.nl/docs/api-admin). - SDKs with timeouts, fallback and the offline queue: [SDK TypeScript](https://staging.patterna.nl/docs/connector-sdk-typescript), [SDK Go](https://staging.patterna.nl/docs/connector-sdk-go). - The machine-readable index: [llms.txt](https://staging.patterna.nl/docs/agents/llms). --- # What Patterna is URL: https://staging.patterna.nl/docs/what-is-patterna > Patterna is a multi-tenant recommendation service that proves its lift with a holdout, gives a reason for every slot and works without a user id. Patterna is a multi-tenant recommendation service. It answers one question for you: what did the recommendations earn, and why was each item shown? Your app asks for a list for a placement. Patterna returns item ids, a reason per slot and a request id. Your backend shows the items and reports what the user saw, tapped or dismissed. ## Three claims, each with a mechanism | Claim | Mechanism | |---|---| | **Proof.** You see what recommendations add over your current list. | Every surface runs with a sticky holdout. The ledger reports the difference between the arms per impression with a 95% interval. Nothing else counts as proof. | | **A reason for every slot.** | The winning generator leaves a template key, but only when what it says is true; otherwise the slot says "Recommended". Your app shows the Dutch or English sentence. Nothing is recommended without a reason the user could read. | | **Works when the user says no.** | Three consent modes. In `non_profiled` mode Patterna stores no user id and still learns a global policy from context only. | ## How a request flows ``` request(tenant, surface, mode, user_or_session, context) → consent mode gate (non_profiled: no user lookup) → generators (trending per surface, curated, item-to-item, session items, content affinity, persona and user models, surface-specific, time of day, format) → filters (status, locale, expiry and stock, premium policy, seen, dismissed, completed, bought, per-collection caps, freshness, the relevance floor) → ranker (linear; learned_linear or gbdt fitted nightly from taps) → exploration (Plackett-Luce sampling on a share of treatment and no-holdout requests, exact per-position propensities; caps and filters.diversity applied inside the sampler) → slate: items with reason keys; impression logged with arm, policy and propensities ``` Budgets: p99 60 ms from cache, 250 ms live, measured at the gateway. The engine's own budget is 120 ms by default; if Patterna cannot rank in time, it returns the surface's editorial list flagged `fallback: true`. Your backend falls back on top with its own list after a 150 ms timeout. Your app never depends on Patterna being up. See [Timeouts and what counts in the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger). ## What runs where | Part | Does | |---|---| | Engine (one Go binary) | API gateway with scoped keys, rate limits and request ids; catalog; ingest; serve; proof (holdouts, ledger); admin (surfaces, flows, audit, deletion and export) | | Jobs (Python) | Learning and evaluation, LLM tagging and reason drafts, connectors (PostHog, GA4, Shopify, quiz CSV), the simulator, the monthly ledger. Jobs write models; serve never trains. | | Postgres 17 + pgvector | Everything, with row-level security per tenant | | Panel | This control panel: Proof, Surfaces, Flows, Audiences, Catalog, Connectors, Governance | Redis is optional: with `REDIS_URL` set, several engine instances share their cached aggregate answers; without it each instance caches on its own. Exports go to the engine's export directory; an S3 target is not built. See [Status](https://staging.patterna.nl/docs/status). ## What stays with you - **Identity.** You mint a random id per user. The mapping to your accounts stays on your side. Patterna never sees an email, a name or your identity provider's subject. See [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors). - **Hydration.** Patterna returns ids. Your backend loads the items and applies premium redaction, locale and status. - **Sensitive context.** Context that must not leave the device stays there. Do not send health measurements or anything you would not show in a reason. If a placement must react to such context, keep that logic in the app, or leave the placement out of recommendations altogether. ## Security in one paragraph Every tenant table has row-level security, with a cross-tenant test in CI. API keys are scoped (`serve` keys start with `pk_s_`, `ingest` with `pk_i_`, `admin` with `pk_a_`) and stored hashed. Events are accepted only with an ingest or admin key, so from your backend; a signature, when present, is verified. The panel has MFA, roles and an audit log. Deletion and export reach the model artifacts through the retrain schedule. LLM calls go through EU endpoints with zero retention, pinned model versions and a daily cost ceiling per tenant. Details: [Data we never store](https://staging.patterna.nl/docs/data-we-never-store). ## Sized for small data Patterna is built for catalogs of hundreds to a few thousand items and hundreds to thousands of daily users. Postgres holds everything. Extra stores (ClickHouse, Qdrant, a message bus) come only when a measured trigger fires, not by default. ## Read next - [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals) - [Consent modes](https://staging.patterna.nl/docs/consent-modes) - [Proof and the ledger](https://staging.patterna.nl/docs/proof-and-the-ledger) - [Go live in six steps](https://staging.patterna.nl/docs/go-live-in-six-steps) --- # What the engine uses, and what it refuses URL: https://staging.patterna.nl/docs/signals > The full list of signals: request context, catalog attributes, events with their weights, the nightly artifacts, what is refused, and the rule for what a reason may say. This page lists every input Patterna's recommender reads, in the form it accepts it, and where each one acts. Anything not on this page is not used. The proof rules hold for all of it: every served slot keeps its exact logged propensity, caps live inside the sampler, and the control arm never reads a personal signal. Where a signal acts: | Code | Meaning | |---|---| | **G** | a generator score (it proposes and scores items) | | **F** | a filter or a cap (it keeps items out) | | **R** | a feature of the nightly `gbdt` ranker (read when a flow uses `ranker.kind: gbdt`) | | **L** | a ledger metric | | **E** | the reason text | ## 1. Request context Sent per call in `context` on [POST /recommendations](https://staging.patterna.nl/docs/api-serve). It is stored only with that request's impression, never on the user. | Field | Form | Acts in | Notes | |---|---|---|---| | `locale` | `nl`, `en`; `nl-NL` counts as `nl` | F | matches the item's locales; a locale no item carries widens to all items | | `platform` | `ios`, `android`, `web` | R | | | `app_version` | a version string | logged | stored with the impression; not ranked on | | `hour_bucket` | 0 to 23, from the device clock | G `time_fit`, R | with the weekday it picks one of eight time buckets: weekday or weekend × morning (06–11), day (12–17), evening (18–22), night | | `weekday` | 0 (Monday) to 6, from the device clock | G `time_fit`, R | when absent the engine uses its own clock in UTC, so send it | | `region` | one of the region names you configured (a city or a province) | G `region_match` | never coordinates; a name outside your list is `400 unknown_region` | | `subscription_tier` | `free`, `premium` or a tier you define | F | for `free`, an item that showed this user a paywall in the last 7 days is left out. The premium cap follows `context.premium` or the profile, not this field | | `tenure_days` | days since the account was created | logged | bucketed by the engine to new (<7), settled (<90) and long, and stored with the impression as that bucket; not a ranker feature yet. You may send it already rounded (0, 7 or 90); the exact day count is never stored | | `anchor_item_id` | an external id of any kind | G `item_to_item`, E | resolved against the whole catalog: an article can anchor a product list | | `session_items` | up to 20 item ids this session just saw or tapped | G `session_items`, G `vector_retrieval` | merged with what the session reported through `/interactions` and its signed events; works in every mode. An interaction is reflected in the next slate of the session: with Redis the engine keeps the session's last two hours and its running content vector there and reads them first; without Redis, or with Redis down, it reads the log | | `member_of` | community ids | F | joined communities are never offered | | `control_item_ids` | your editorial list for this page | control arm | logged as `control_source: request` | | `measured`, `deadline_ms` | proof and timing controls | | see [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger) | A bad value for `weekday`, `subscription_tier` or `tenure_days` answers `400`. The TypeScript, Go and Python clients (SDK 0.4.1) carry typed fields for them. ## 2. Catalog attributes Sent in `attributes` on [PUT /catalog/items](https://staging.patterna.nl/docs/api-ingest). They are editorial data about the item, never about a person. Unknown keys are kept for the trace and ignored. | Attribute | Form | Acts in | |---|---|---| | `format` | `text`, `video`, `audio`, `recipe`, `workout`, `tool`, `product` | G `format_fit` (against the user's learned format preference) | | `duration_min` or `reading_time_min` | number | G `time_fit` pairs short items with the day and long ones with the evening and the weekend until the item has its own time profile | | `difficulty` | `easy`, `medium`, `hard` | read into the item model; not ranked on yet | | `author` | string | read into the item model; not ranked on yet | | `available` or `in_stock` | boolean (products) | F: `false` is never served, in both arms (`filters.hide_unavailable`, on by default) | | `expires_at` | RFC 3339 | F: never served after it, in both arms | | `starts_at` | RFC 3339 (events) | F: a past event is never served, in both arms | | `repurchase_days` | integer (products) | F: a product the user bought is not offered again inside this many days; set a small number for consumables | | `region` (or the item's `region` field) | one of your regions | G `region_match` | | `phase_fit` | a list of phases | returned only when the tenant turned `phase_tags` on; it never ranks | Kept for the panel and your own cards, not ranked on: `price_cents`, `compare_at_cents`, `currency`, `categories[]`, `image_url`, `url`, `product_type`. ## 3. Events Sent with [POST /events](https://staging.patterna.nl/docs/api-ingest). The weights are the nightly affinity learner's defaults. They are fixed in the learner today, not set per tenant. Every weight decays with a 30-day half-life, and the learners read slates of arm `treatment` and `none` only, never control. The full field list: [Event types](https://staging.patterna.nl/docs/reference-event-types). | Event | Teaches | Weight | Also acts in | |---|---|---|---| | `rec_click`, `rec_dismiss` (or the SDK's interaction report) | what was taken or turned away on a slate | click +1.0, dismiss −1.0 | L `tap_rate`; F: a dismissed item stays out for `exclude_dismissed_days` (30) | | `rec_seen` | what was shown | | L | | `item_viewed` | weak interest | +0.2 | G `trending` (cold start) | | `item_engaged` | real interest | +0.6 when 20 s or more, or 60 % scrolled | G `trending` counts an engaged view three times; R `engaged_rate`; the user's format preference | | `item_completed` | strong interest | +1.5 | F: stays out for `exclude_consumed_days` (30, personalized); the user's format preference | | `item_pinned`, `item_unpinned` | explicit preference | +2.0 / −1.0 | F: unpinned counts as dismissed | | `community_joined`, `community_left` | membership | +1.5 / −1.0 | F: joined communities are never offered; L `join_rate` | | `community_post_created`, `community_reply`, `community_reaction` | active membership | +1.5 on the community, +0.5 on its tags | R `activity_rate` per community | | `community_post_viewed` | weak interest in a community | +0.2 | | | `search_performed` with `clicked_item_id` | intent right now | +0.8 on the clicked item | the query arrives only as a hash | | `push_opened` | content that pulls people back | +1.0 | R `pullback_rate` per item | | `product_viewed` | shop interest | +0.5 | | | `order_placed` | a purchase | +2.0 per product | L `purchase_rate`; F: the repurchase window; co-purchase pairs for product neighbours | | `paywall_viewed` | a free user hit the wall | | F: not offered to that free user again for `paywall_cooldown_days` (7) | | `subscription_started`, `subscription_cancelled` | a tier change | | | | `event_rsvp`, `event_attended` | event interest | +1.0 on `event_rsvp` | | | `coach_product_shown`, `coach_product_tapped` | assistant outcomes | | reported, no holdout | | `consent_changed`, `user_deleted` | identity lifecycle | | the opt-out and deletion paths | ## 4. What the nightly jobs learn from them | Artifact | From | Used by | |---|---|---| | item features: `engaged_rate`, `pullback_rate`, `activity_rate`, `purchase_rate` | events and impressions | R | | item time profile: tap rate in each of the eight time buckets, smoothed toward the surface's own rate | impressions with their context and the taps on them | G `time_fit` | | the user's format preference and active time buckets (personalized only) | `item_engaged`, `item_completed`, taps | G `format_fit` | | purchases per user and product | `order_placed` | F: the repurchase window; deleted with the user | | embeddings and item neighbours | item text, embedded by a local multilingual model on Patterna's own host | G `item_to_item`, cold start | | the vector index over the embeddings (HNSW, cosine) | the same embeddings, indexed in the database | G `vector_retrieval`: the k items nearest to what the person looked at join the candidates per request; a new item counts from the night it was embedded | | the person's 200 nearest users (user ids and similarities only, never items) | the same matrix factorisation as `user_bayes`, or shared items for people without a row | G `user_to_user`; deleted with the user | | the next-item model (a small neural network over the order in which people open items, exported to ONNX; one per tenant, replaced only when it beats the plain path rule on held-out data) | the same positives the collaborative learners read, each person's or session's items in order | G `sequence_model` | | product co-purchase pairs | `order_placed`, or a shop's orders export read as aggregates | product neighbours | | generator posteriors per surface | logged contributions and taps | the Thompson mixture when `ranker.bandit` is on | ## 5. Refused on purpose Patterna does not accept: cycle phase or symptoms, mood, conversations with an assistant, precise location, names, email addresses, IP addresses, free-text search queries (only their hash), and anything tied to a user id in `non_profiled` mode. An event that carries a field like this is rejected with `pii_rejected` and the field's name, never stored with the field quietly removed. The catalog importers do not read health or conversation tables. See [Data we never store](https://staging.patterna.nl/docs/data-we-never-store). ## 6. How a flow turns them on Each signal is opt-in per surface through its flow ([Flow config reference](https://staging.patterna.nl/docs/reference-flow-config)): ```json { "generators": [ { "kind": "item_to_item", "weight": 0.5 }, { "kind": "session_items", "weight": 0.3 }, { "kind": "time_fit", "weight": 0.15 }, { "kind": "format_fit", "weight": 0.15 }, { "kind": "trending", "weight": 0.2 }, { "kind": "surface_specific", "weight": 0.1 } ], "filters": { "diversity": 0.2, "exclude_dismissed_days": 30, "paywall_cooldown_days": 7, "region_boost": 0.3 }, "ranker": { "kind": "gbdt", "bandit": false } } ``` Every generator is optional. A surface without the signal gets zero from it and still serves. The personal exclusions (dismissed, completed, paywalled, bought) are on by default and relaxed when nothing else would be left; the hard rules (expired, past events, out of stock) are never relaxed and apply in both arms. Simulate and the ledger read the logged contributions, so adding a signal never changes what was already proven. ## 7. What a reason may say A slot's reason names the generator that won it, and only when that is true: - the winner's own signal must be positive for the item (a similarity, an affinity or a model score above zero); - "trending" needs this surface's own log; before it has one, the slot says "popular"; - "people like you" needs a real persona, not the cold-start one; - "new" means published in the last 30 days. Otherwise the slot gets the reason key `recommended` ("Aanbevolen" / "Recommended") and nothing more. `time_fit`, `format_fit` and `region_match` never name a reason: "because it is 9 pm" is not a reason a person would accept. `curated` is a boost by rank (the first item +2), so an editor's list of three items never drowns a catalog of five hundred. See [Reasons](https://staging.patterna.nl/docs/reasons). --- # Consent modes URL: https://staging.patterna.nl/docs/consent-modes > The three consent modes personalized, cohort and non_profiled, and what each stores, learns from and never stores. Every request to Patterna carries a mode. The mode decides what Patterna may store about the person and which signals it may learn from. Your backend picks the mode from the consent the user gave you. Patterna does not ask the user anything itself. ## The three modes at a glance | | `personalized` | `cohort` | `non_profiled` | |---|---|---|---| | Identifier sent | your random per-tenant `user_id` | your random per-tenant `user_id` | a rotating `session_id` only | | User row in Patterna | yes | yes | no | | Ranking uses | the user's own behaviour, declared attributes, context | declared goals and context | context only | | Seen filter | from the user's history | from the impression log only | no user history | | Events used for learning | yes | no: learning jobs exclude cohort users' events | stored with a session id only, for the global policy | | Holdout arm | sticky per user | sticky per user | from a hash of the session id | | Shown in the ledger | yes | yes | yes | ## personalized The user agreed to recommendations based on what they read and do. Patterna keeps a user row with the random id, the mode, a persona and declared attributes (goals, activity level, stress level, wearable, supplement use, premium flag, locale). It learns a per-user model on top of the per-persona one. What it never stores, in any mode: email, name, IP, device id, free text, coordinates, or any health value. See [Data we never store](https://staging.patterna.nl/docs/data-we-never-store). ## cohort The user agreed to recommendations based on what they told you, not on what they do. Patterna ranks from declared goals only. There is no behavioural affinity and no seen-filter history beyond the impression log. The learning jobs leave cohort users' events out. Cohort is a choice you offer. If you do not offer it, nothing maps to cohort and a missing consent means `non_profiled`. Cohort ranking reads the goals you send with `PUT /users/{user_id}/profile`. Send the mode with the profile for a new user: a profile without `mode` keeps an existing user's mode, but for an id Patterna does not know it is refused with `422 mode_required` (and for a user who opted out with `422 mode_conflict`), so a profile write can never create consent by accident. ## non_profiled The user said no, or never said yes. The request must carry a `session_id` and no `user_id`. Patterna never touches the users table for it. It still serves a good list: a global policy learned from context only (`locale`, `app_version`, `platform`, `hour_bucket`). The holdout still covers these users. The arm comes from a hash of the session id, so the ledger includes people who opted out. Rotate the session id. Mint 128 random bits and rotate them, for example when the app returns to the foreground after 30 minutes. It is not a device id and is never persisted. Because the session id rotates, one person can land in both holdout arms over a day. Send an `assignment_unit` on `non_profiled` requests (a 64-hex hash you compute from your own user id) used only to keep that person in one arm; it is never stored raw and never used for ranking. See [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors#assignment-unit). ## Mapping your consent records to a mode Patterna has three modes; you decide which of your consent records maps to which. Write the mapping down once and use it in every place that calls Patterna: the request path, the consent-change handler and any import. A worked example, from a health app that already asks two consent questions: | Your consent record | Mode | |---|---| | `personalized_offers` granted ("Allow personalized offers and recommendations") | `personalized` | | `analytics` granted, `personalized_offers` not granted | `cohort` | | neither granted, or no consent row at all | `non_profiled` | Rules that keep it honest: - **Missing means no.** A user without a consent row is `non_profiled`. Never treat silence as a yes. - **Personalization needs a consent that names it.** Analytics consent alone is not a basis for profiling on behaviour; that is why it maps to `cohort` (declared goals only), not to `personalized`. - **One mapping everywhere.** The request path, the consent-change handler and any one-off import from your database use the same table. ### What a dedicated `recommendations` consent would add You can also add a new consent type that asks only about recommendations. Compared with reusing an existing offers consent, it gives: - a purpose that names recommendations only, separate from marketing offers, which is a cleaner basis for profiling; - a toggle you can explain in one sentence next to the lists it controls. The cost: nobody has given it yet. Everyone starts `non_profiled` until they answer, so you need a prompt after onboarding and an opt-in rate to watch. The choice belongs to your privacy owner; Patterna works the same either way. ## Measured lists and analytics consent Interaction reporting (seen, tap, dismiss) follows the user's **analytics** consent: if a user did not allow usage analytics, your app does not report what they saw or tapped. That is independent of the mode, which governs what Patterna may store and rank on. If you stop reporting for those users without telling Patterna, their slates still count as served with zero taps in both arms. The difference between arms stays unbiased but is diluted, and the tap rate is meaningless as an absolute number. Send `context.measured: false` on the recommendation request for a user who did not allow analytics. Serving is unchanged. Unmeasured slates are left out of the ledger, the power curve and learning, in both arms. Consent is given before the arm is drawn, so leaving them out is valid. `measured` defaults to `true`. ## Changing the mode Tell Patterna when consent changes. The call takes the ingest key: ```curl curl -sS -X PUT "https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe/mode" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \ -H "Content-Type: application/json" \ -d '{"mode": "non_profiled"}' ``` Switching to `non_profiled` is an opt-out, with 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. After the call, forget your mapping for this user. A later opt-in then mints a fresh id that cannot be joined to the old history. **Opt-out versus deletion.** Both leave impressions in the ledger, unlinked, and neither deletes them. An opt-out keeps a hashed marker so serve stays non-profiled for that id. A deletion (`DELETE /users/{user_id}`) also removes that marker and keeps only a hashed record that a deletion happened. See [Handle a deletion request](https://staging.patterna.nl/docs/handle-a-deletion-request). The endpoint accepts the ingest key (and the admin key). Both SDKs send the admin key when you configure one and the ingest key otherwise. ## The SDKs enforce the mode Both SDKs refuse a request before anything is sent when: - the mode is missing or unknown (`consent_required`); - a `non_profiled` request carries a `user_id` or lacks a `session_id` (`consent_violation`); - a user id looks like an email or an identity-provider subject such as `auth0|...`. These are bugs in the caller. `withFallback` and `RecommendWithFallback` rethrow them instead of hiding them behind the fallback list, because a consent bug is a privacy bug. The engine checks too: a `non_profiled` request with a `user_id` gets `422 mode_conflict`, and a `personalized` or `cohort` request without one gets `400`. ## Choosing the default Set the default mode in Settings → Consent. A safe default is `non_profiled`: everyone starts there and personalization begins per user at opt-in. That makes the opt-in rate a number to watch in Audiences. ## Read next - [Data we never store](https://staging.patterna.nl/docs/data-we-never-store) - [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors) - [Handle a deletion request](https://staging.patterna.nl/docs/handle-a-deletion-request) --- # Proof and the ledger URL: https://staging.patterna.nl/docs/proof-and-the-ledger > How the sticky holdout, intention to treat, user-clustered intervals, the SRM check and the readable flag make the ledger the only proof. Proof in Patterna means one thing: on a surface, users who get recommendations are compared with users who get your editorial list, and the difference has an interval. Dashboards of clicks, revenue attributed by last touch, or offline scores are useful. They are not proof. ## The holdout A surface gets a holdout when you start an experiment on it. While it runs there are two arms. - **Treatment** gets Patterna's ranked slate. - **Control** gets the surface's editorial list: the newest items, a curated list of ids, or the 7-day popular list, logged with policy `editorial`. It is the comparison, not a fallback, and it never counts in the fallback rate. On an anchored surface (a page about one item, such as "more to read" under an article) one list for everyone would compare Patterna with a list that has nothing to do with the page, so control is anchored. In order, control serves the ids you send in `context.control_item_ids` (your own list for this page, for example the article's pairs-with products or the editor's related articles), else the surface's list for this anchor from `fallback.by_anchor`, else the surface's own list. The engine applies the same rules as in treatment (the anchor is never served; `exclude_item_ids`, joined communities, candidate kinds, premium and locale apply) and fills from newest items when the list is short. The answer's `control_source` (`request`, `by_anchor` or `surface`) says which list control served. The holdout then compares Patterna with what your editor would have shown on that page. Do not open a holdout on an anchored surface before you send `control_item_ids` or have set `fallback.by_anchor`. Without a running experiment every slate is logged with arm `none`. It counts in the surface metrics, never in the ledger. Rules: - **Sticky.** A user stays in one arm for the whole experiment: the arm comes from a hash of the experiment and the user id, and is stored. In `non_profiled` mode it comes from a hash of the session id, so a new session can land in the other arm, unless you send `assignment_unit` on `non_profiled` requests: 64 hex characters, an HMAC-SHA256 your backend computes from its own user id, so a person keeps one arm across sessions without Patterna storing who she is. See [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors). - **Stratification.** Without `stratify_by` (default `none`) the arm is a plain hash of the unit. With `stratify_by: "persona"` the arm is drawn within each persona, so both arms get the same mix of personas; `strata_shares` can set the treatment share per persona stratum. Units without a persona (including `non_profiled` traffic) form their own stratum. - **Same UI.** Both arms see the same placement and the same card design. Only the ordering differs. - **Both arms are logged.** Control slates are logged with propensity 1 and a reason, so "every slate is logged" and "a reason for every slot" hold in both arms. - **No re-sorting.** A surface under an experiment must not be re-sorted on the device, unless the surface has `device_resort: true`. During a pilot the split is usually 50/50. ## What counts: intention to treat Every served slate counts in its arm, seen or not. A slate the app gave up on stays in its arm with no taps; dropping it would flatter whichever arm is slower. A fallback slate (timeout, storage trouble) is logged under the arm the unit was assigned to, with `fallback: true`. The ledger reads only impressions inside the experiment's window, from `started_at` to `ended_at`, intersected with the period you ask for. One kind of slate is left out: slates requested with `context.measured: false` (the user did not allow usage analytics, so nothing will be reported). Consent decides that before the arm is drawn, so leaving these slates out of both arms keeps the comparison fair. Slates answered with `shown: false` (fewer items than the surface's `min_slots` passed the request's rules) also count in their arm as served with zero success, like a slate the app gave up on. They are answered with no items. `min_slots` is counted before the arm, so both arms hide alike. (Engine 0.2 left these slates out, and since control is padded to full length and treatment could lose items to its filters, that exclusion biased the ledger.) A treatment slate that falls short is filled from the editorial list, like control, and reports how many items it filled in `fill_count`. Details and a table of every case: [Timeouts and what counts in the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger). ## What is measured | Metric | Meaning | Status | |---|---|---| | `tap_rate` | slates with at least one tap, over slates served | built | | `second_item_rate` | slates where, after the first tap, the same session or unit taps another item within 30 minutes, over slates served | built | | `join_rate` | slates followed by `community_joined` for a community in the slate within 7 days, over slates served. Community surfaces only; elsewhere `400 metric_not_applicable` | built | | `engaged_after_tap` | slates where a tapped item gets `item_engaged` or `item_completed` within 7 days, over slates served | built | When interactions carry a `block` (one slate shown in two places on a page), the ledger can also give a row per block (`by=block`), so the top of a page and its end can be looked at separately. Those rows are descriptive: the whole-slate row decides. Retention and conversion are reported, not proven, until the user base is large enough to detect a change in them. Revenue is not computed yet; when it is, it will be a last-touch diagnostic line, never the result. ## The ledger The ledger is the record per surface. Each row holds the control rate, the treatment rate, their difference (treatment minus control), a 95% interval, the slates and the units per arm, the SRM p-value and a flag that says whether the row is readable. Synthetic rows are excluded. Every impression also records the model versions that ranked it (`model_versions`), visible in the trace and the export. The jobs write a snapshot for the previous month on the 1st at 04:00 UTC; the panel also shows weekly rows per arm. Walkthrough: [Read a ledger row](https://staging.patterna.nl/docs/read-a-ledger-row). ## Why slates are not units The metric is per slate, but the randomised unit is the user (or the session in `non_profiled` mode). One user produces many slates, and those are not independent: a user who taps a lot taps in both her first and her tenth slate. If you treat every slate as a separate observation, you pretend to have far more evidence than you do, and the interval comes out too narrow. So Patterna clusters the interval on the randomised unit. Per arm, with `y` the tapped slates and `x` the served slates of each unit, the rate is `R = Σy / Σx`. Its variance comes from the delta method over units: ``` Var(R) ≈ ( s_y² − 2·R·s_xy + R²·s_x² ) / ( n · x̄² ) ``` with `n` units, `x̄` the mean slates per unit, and `s` the sample variances and covariance across units. The interval of the difference is `difference ± 1.96 · √(Var_treatment + Var_control)`. The unit is stored with each impression as `unit_hash`, a SHA-256 of a secret salt Patterna holds per tenant and the user id. It survives a deletion request, so unit counts and intervals do not change after the fact. The salt is set once per tenant and never rotated: rotating it would split every unit, so it is never changed inside an experiment. It cannot be reversed, and someone who holds a user id cannot check whether a row belongs to it. ## Sample ratio mismatch (SRM) If you asked for 50/50 and got 5,000 users in one arm and 4,600 in the other, something is broken: assignment, logging, or a filter that drops one arm more often. The ledger tests the unit counts per arm against the planned share. A row with an SRM p-value below 0.001 is flagged with a note and should not be read, however good the difference looks. Fix the cause first. The flag is on the row; SRM alerts to the notification centre, email or Slack are not written yet. ## Readable When you start an experiment with a baseline rate and the change you want to detect, Patterna computes the units per arm needed to see that change at a two-sided 5% level with 80% power (`readable_sample`) and projects `readable_at` from current traffic. A row is flagged `readable` when each arm has reached the experiment's `readable_sample` and the interval exists. Do not stop an experiment early because a dashed number looks good. ## Check it yourself Export a period from Proof → Export, or call the API: ```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"}' ``` The export holds impressions and interactions for the period, so you can check the slate and tap counts. It does not yet carry the clustering unit (`unit_hash`), `measured`, `not_shown`, `tester`, `fallback`, blocks or the experiment window, so it cannot reproduce a ledger row with its interval on its own; that is an open item on our side. Exports are written by the engine to its export directory; an S3 target is not built yet. ## Before going live: the replay report Proof starts after launch, but you can get an estimate before. Given 90 days of events and the catalog, the replay report shows what the first flow would have done against the current ordering, with a SNIPS estimate and its effective sample size. It is an estimate, not proof. See [Exploration and propensities](https://staging.patterna.nl/docs/exploration-and-propensities). ## Read next - [Timeouts and what counts in the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger) - [Run a holdout](https://staging.patterna.nl/docs/run-a-holdout) - [Read a ledger row](https://staging.patterna.nl/docs/read-a-ledger-row) --- # Exploration and propensities URL: https://staging.patterna.nl/docs/exploration-and-propensities > Plackett-Luce exploration with exact per-position propensities, caps inside the sampler, and SNIPS estimates of a flow on logged traffic. On a share of requests Patterna does not show its top list in fixed order. It samples the order instead and logs, for every slot, the exact probability that the item landed there. That probability is the propensity. With propensities in the log you can estimate how a different flow would have done on the same traffic, without shipping it. ## Why explore at all If a surface always shows the same top six, the log only tells you how those six do in those positions. A new flow that would put item 9 first cannot be evaluated: item 9 was never shown first. Exploration gives every eligible item a known, non-zero chance at each position. That overlap is what makes offline evaluation possible. ## The settings Exploration is part of the flow config: ```json { "exploration": { "kind": "plackett_luce", "temperature": 0.7, "share": 0.2 } } ``` | Field | Default | Meaning | |---|---|---| | `kind` | `none` | `plackett_luce` or `none` | | `temperature` | 0.7 (used when 0 or less) | lower stays closer to the ranker's order, higher spreads the chances | | `share` | the surface's `exploration_share` (0.2) | the share of eligible requests that are sampled, 0 to 0.5; the rest get the deterministic top-k | When the flow's `share` is 0, the surface's `exploration_share` applies. A share above 0.5 is refused when you save the flow. ## Which slates are explored Exploration runs on slates in the **treatment** arm of a running holdout and on slates outside any holdout (arm `none`: no experiment, `shadow`, `testers`). A **control** slate is never explored: it gets the editorial list. So a surface collects propensity-logged data before its first holdout. What learns from those slates: the slate learners (the persona model, the learned linear ranker, the gbdt ranker and the SNIPS gate) train on slates in arm `treatment` and arm `none`, never on control, and only on measured, shown, non-shadow slates. Their feedback is what you report with `POST /v1/interactions` (seen, taps, dismissals), joined to the slate by `request_id`, plus signed `item_completed` events for an item served under that request (counted as a tap). Positions filled from the editorial list (`fill: true`) are never used as ranking evidence. ## Plackett-Luce sampling On an explored request the ranker's scores turn into weights. Patterna fills the slate one position at a time: 1. Draw one item from the eligible pool, with probability proportional to its weight. 2. Record that probability as the propensity for this position. 3. Remove the item from the pool and repeat for the next position. Each logged propensity is exact: it is the probability over the pool the item was actually drawn from. The engine's tests check that empirical frequencies match the stated probabilities. On requests that are not explored, the slate is the deterministic top-k. ## Why caps live inside the sampler Flows can cap items per collection (`per_collection_cap`, 0 = no cap) and premium items (`premium_policy: cap` with `premium_cap`, default 2). There are two ways to enforce a cap: - **After sampling:** draw six, then drop the fourth recipe and pull up the next item. The item now at position 4 was never drawn at position 4. Its logged propensity is wrong, and every estimate built on it is biased. - **Inside the sampler:** before each draw, remove items that would break a cap from the pool. The propensity is the probability over the pool that remained. It stays exact. Patterna does the second. Diversity works the same way: `filters.diversity` (0 to 1, 0 = off) lowers an item's score at each position by that factor times its highest tag similarity to the items already picked, before the draw. Nothing re-ranks after sampling. `PropensityAt` in the engine recomputes the same number for any logged slate, which is what the simulate endpoint uses. `filters.diversity` is the only diversity setting. An old top-level `diversity` object is dropped on save. ## SNIPS: estimating a flow on logged traffic Self-normalised inverse propensity scoring (SNIPS) answers: if the candidate flow had served these requests, what would the metric have been? For each logged slot `i` with outcome `r_i` (a tap, say), weight it by how much more or less likely the candidate flow is to put that item there than the logging flow was: ``` w_i = p_candidate(item at position | request) / p_logged(item at position | request) SNIPS = Σ w_i · r_i / Σ w_i ESS = (Σ w_i)² / Σ w_i² ``` The effective sample size (ESS) tells you how much traffic the estimate really rests on. If the candidate flow differs a lot from what was logged, a few slots get huge weights, ESS collapses, and the interval widens. Treat a small ESS as "not enough evidence", not as a result. Run it from Flows → Simulate, or: ```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": "content_affinity", "weight": 0.6 }, { "kind": "trending", "weight": 0.4 } ], "ranker": { "kind": "linear" }, "exploration": { "kind": "plackett_luce", "temperature": 0.7, "share": 0.2 } } }' ``` The answer holds `current_policy`, `candidate_policy`, `ci_low`, `ci_high`, `impressions_used` and `effective_sample_size`, plus the twin curve when the tenant has one. Nothing changes on the surface. SNIPS is an estimate. It tells you which flows are worth a holdout. Only the holdout is proof. See [Proof and the ledger](https://staging.patterna.nl/docs/proof-and-the-ledger). ## Positions must be the served ones Propensities belong to the position Patterna served, counted from 0. Always report the served `position`. A surface under a holdout must not be re-sorted on the device unless it has `device_resort: true`: the ledger and the estimator read the served position. With `device_resort: true` on a surface the app may re-sort with its own rule and must send `displayed_position` on every interaction (else `422 displayed_position_required`); the estimators use it to model where the user actually looked. See [Surfaces and flows](https://staging.patterna.nl/docs/surfaces-and-flows). ## Read next - [The twin](https://staging.patterna.nl/docs/the-twin) - [Flow config reference](https://staging.patterna.nl/docs/reference-flow-config) - [Surfaces and flows](https://staging.patterna.nl/docs/surfaces-and-flows) --- # Timeouts and what counts in the ledger URL: https://staging.patterna.nl/docs/timeouts-and-the-ledger > The serve budgets (150 ms in the SDK, 120 ms in the engine), the deadline and abandonment, and a table of which slates count in the ledger and the fallback rate. Short version: send your deadline, report every slate you gave up on, and never retry a recommendation. The ledger counts every slate Patterna served in the arm it was served in, whether or not your user saw it. The SDKs send the deadline and report abandonment for you. ## The budgets | Budget | Value | Where | |---|---|---| | SDK timeout for `Recommend` | 150 ms | Go, TypeScript and Python SDKs. `Recommend` is never retried, because every call logs an impression. Other calls retry once inside a 5 s budget. | | SDK deadline sent to the engine | 120 ms | `X-Deadline-Ms`, inside the 150 ms timeout (`RecommendDeadline` in Go). | | Engine serve budget | 120 ms by default | `SERVE_BUDGET` on the engine. Past it the engine answers with the editorial list, flagged `fallback: true`. | | Target p99, from cache | 60 ms | measured at the gateway | | Target p99, live | 250 ms | measured at the gateway; see the SLO | The engine answers within its own budget or your deadline minus a tenth for the network, whichever is shorter. Because both are below the SDK's 150 ms, a slate the engine logs as served is a slate your client received. ## Deadline and abandonment **A deadline.** The client sends the time it has left, in milliseconds, as the `X-Deadline-Ms` header or the `deadline_ms` body field (10 to 10,000; when both are sent, the shorter wins). The engine falls back itself rather than finishing a slate nobody will wait for. ```curl curl -sS -X POST "https://api.patterna.nl/v1/recommendations" \ -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \ -H "Content-Type: application/json" \ -H "X-Request-Id: 6f1c2a9e-3b7d-4c55-9a21-0d8e4f7b1c33" \ -H "X-Deadline-Ms: 120" \ -d '{"surface": "more_to_read", "mode": "non_profiled", "session_id": "s_8c1f…", "context": {"locale": "nl", "anchor_item_id": "doc_1042"}}' ``` **Abandon.** When your client gives up anyway (network trouble, a `5xx`, or it hid a block it had received), it tells Patterna: ```curl curl -sS -X POST "https://api.patterna.nl/v1/recommendations/6f1c2a9e-3b7d-4c55-9a21-0d8e4f7b1c33/abandon" \ -H "Authorization: Bearer $PATTERNA_SERVE_KEY" ``` The answer is `200` with `request_id`, `arm`, `fallback: true` and `client_abandoned: true`. The call is idempotent. The impression keeps its arm (intention to treat) and counts in the fallback rate. A client-minted UUID in `X-Request-Id` becomes the slate's request id, so the client knows the id even when no answer arrives. The SDKs mint it, and `RecommendWithFallback` reports the abandonment itself on a timeout, a network error or a `5xx` (`Abandon` / `AbandonSync` for your own cases). Do not call abandon for a slate the engine answered with `shown: false`. The engine has already logged it as not shown. ## What counts where | Case | Arm | In the ledger? | In the fallback rate? | |---|---|---|---| | Ranked slate, user saw it | treatment, or `none` without a holdout | yes, if treatment | no | | Ranked slate, user never scrolled to it | treatment or `none` | yes, if treatment, with no taps | no | | Control slate (your `context.control_item_ids`, the surface's `fallback.by_anchor` list for the anchor, or the surface's list) | control | yes | no: control is not a fallback | | Engine fallback (past its budget, storage trouble, no active flow) | the assigned arm | yes, in that arm | yes | | Client gave up and reported abandon | the assigned arm | yes, with no taps | yes (`client_abandoned`) | | Client gave up and reported nothing | the assigned arm | yes, with no taps | no: this is an under-count, so report it | | Client hid the block after hydration and reported abandon | the assigned arm | yes, with no taps | yes (`client_abandoned`) | | Slate shorter than the surface's `min_slots`, answered with `shown: false` and no items | the assigned arm | yes, with no success, in both arms (intention to treat) | no | | Request refused (`4xx`) | none: no impression | no | no | | Surface in `shadow`, or `"shadow": true` | `none` | no | no | | Surface in `testers` | `none` | no | yes, if flagged | | No running holdout | `none` | no | yes, if flagged | | Outside the experiment's window | as logged | no | yes, if flagged | | `context.measured: false` | the assigned arm | no, in either arm | yes, if flagged | | Synthetic example data | as generated | never | never | One definition: the fallback rate is fallback slates (the engine's safety-net fallbacks plus slates the client abandoned) over non-shadow slates served. The overview's 7-day figure counts every non-shadow slate; a surface's page leaves `shown: false` slates out of both counts, because they carried no items. Next to it the surface metrics report `client_abandoned_rate` and `unmeasured_share` (slates requested with `context.measured: false`). A `shown: false` slate counts in its arm (since engine 0.3; engine 0.2 left such slates out, and because control's list is always padded to full length and treatment's could fall short, that exclusion depended on the arm). `min_slots` is counted before the arm, from the items that pass the request's rules, so both arms hide alike. A treatment slate that comes out short is filled from the editorial list, the same list control uses; the answer and the impression say how many positions were filled in `fill_count`, and each filled slot in the logged slate (trace, export) carries `fill: true` with propensity 1. ## Why every served slate counts Intention to treat means: once a unit is assigned to an arm, every slate served to it counts in that arm, whatever happened next. The alternative, counting only slates that were seen, sounds cleaner but is biased. If Patterna's ranking is slower than the editorial list, more treatment slates time out, and dropping them removes exactly the cases where treatment did worse. The ledger would then flatter the slower arm. Counting them as served with no taps charges the cost to the arm that caused it. The same holds for a block hidden because it had too few items: whether it is hidden by the engine (`shown: false`) or by your app (abandon), it stays in its arm. Unmeasured slates are different: whether a user allowed analytics is decided before the arm can have any effect, and it is decided the same way in both arms. Leaving them out keeps the comparison fair and makes the rates mean something. ## What to do in your client 1. Use `RecommendWithFallback` (or the same pattern): your own list on a timeout, `5xx`, `429` or an open breaker. 2. Never retry `Recommend`. A retry logs a second impression for what the user sees as one list, and the first one counts as served with no taps. 3. With the SDKs: nothing more. They send the deadline and report abandonment. With your own client: mint `X-Request-Id`, send `X-Deadline-Ms` (or `deadline_ms`), and call abandon when you give up. 4. Report seen and taps with the slate's `request_id` and the served `position` (from 0), for both arms the same way. The engine takes kind and position from the logged slate, refuses items that were not in it (`422 unknown_item`) and accepts an optional `block` name when one slate is shown in two blocks (the ledger can then give a row per block with `by=block`). 5. If the user did not allow usage analytics and you will not report, send `context.measured: false`. 6. Set `min_slots` on the surface (`PATCH /surfaces/{key}`) to the smallest list worth showing, usually 1, and hide the block when the answer says `shown: false`. Never call abandon for such a slate. If you hide a block for another reason (hydration dropped items), report it with abandon. ## Read next - [Proof and the ledger](https://staging.patterna.nl/docs/proof-and-the-ledger) - [Run a holdout](https://staging.patterna.nl/docs/run-a-holdout) - [Surfaces and flows](https://staging.patterna.nl/docs/surfaces-and-flows) --- # The twin URL: https://staging.patterna.nl/docs/the-twin > The twin is a tenant simulator for example data and what-if curves at three truth scales; it is never proof. The twin is a simulator that behaves like your tenant: your catalog, your surfaces, simulated users and their responses. It has two jobs. It fills the panel with example data before you have real traffic, and it draws a what-if curve when you simulate a flow. It never produces proof. ## What it is - **A simulator owned by the jobs.** It runs next to learning and enrichment, not in the serve path. - **The source of example data.** When a tenant has no real traffic yet, every chart and number in the panel shows the twin's output with an amber "example data" badge. The "Real / Example" switch in the top bar flips the whole workspace. Real views never mix in synthetic rows. - **A curve in Simulate.** `POST /surfaces/{key}/simulate` returns `twin_curve` when the tenant has a twin: one point per week with a `control` and a `treatment` value. The panel draws it at three truth scales. Every row the twin produces is flagged `synthetic: true` with source `synthetic`. The API exposes `include_synthetic` so the panel can ask for them on purpose. ## What it is not - **Not proof.** Synthetic rows are excluded from the ledger. A twin curve that shows a lift says the flow could work under the twin's assumptions, nothing more. - **Not a forecast.** The twin's users are simulated. Their behaviour comes from assumptions, not from your users' future. - **Not a replacement for SNIPS.** SNIPS re-weights traffic that really happened. The twin invents traffic. When both are available, trust SNIPS with a large effective sample over the twin, and the holdout over both. - **Not a copy of your users.** It holds no real user ids and no real events. ## Truth scale The twin needs an assumption about how strongly simulated users respond to better ranking. That assumption is the truth scale. The panel draws the twin curve at three truth scales so you see a range instead of one line. If the flow only wins at the most optimistic scale, it is a weak candidate. If it wins at all three, it is worth a holdout. The three scales are 1.0, 0.5 and 0.25. At 1.0 the simulated audience has as much persona structure as the twin's hidden truth model assumes; 0.5 and 0.25 shrink that structure, so personalization has less to find. The twin is calibrated on real distributions only (declared goals and attributes, catalog structure, observed activity), never on invented behaviour. The simulate answer carries the lift at each scale in `lift_by_truth_scale`. ## Status The simulator is built in the jobs. `twin_weekly` refreshes the curve per surface every day at 05:00 UTC and stores it as a `twin` policy; simulate reads the latest one. A surface without items, or a tenant where the job has not run yet, gets no `twin_curve`. ## Read next - [Exploration and propensities](https://staging.patterna.nl/docs/exploration-and-propensities) - [Proof and the ledger](https://staging.patterna.nl/docs/proof-and-the-ledger) --- # Reasons URL: https://staging.patterna.nl/docs/reasons > Every slot carries a reason key and a sentence per locale, said only when it is true; overrides by reason key or generator kind, placeholders, pairs_with, more_about_tag and recommended. Every item Patterna returns comes with a reason the user could read. The reason is not decoration. It is a promise that the system knows why it chose the item, and it is the first thing a user, an editor or a regulator asks. ## How a reason is made 1. Several generators score the candidates (trending, curated, item-to-item, session items, content affinity, persona model, user model, surface-specific, and the silent `time_fit` and `format_fit`). 2. The ranker adds the scores up. For each slot, the generator with the largest contribution is the **winner**. `time_fit`, `format_fit` and the region boost never win: they move scores, but nothing they could say would be a reason a person accepts. 3. The winner maps to a **reason key**, for example `item_to_item` with an anchor → `similar_to_read`, but only when the reason is true (next section). Otherwise the key is `recommended`. 4. Serve looks up the sentence for that reason key: your override from the flow's `reasons` map if there is one, otherwise Patterna's default. It returns the sentence in every locale it has. A slot in the response looks like this: ```json { "item_id": "doc_4821", "kind": "document", "position": 0, "reason_key": "similar_to_read", "reason": { "nl": "Omdat je dit net las", "en": "Because you just read this" }, "generator": "item_to_item", "score": 0.82 } ``` Positions start at 0. Your app shows `reason[locale]` as one line under the title. If the user's locale is missing from the map, fall back to your default locale. ## The truthfulness rule A slot names its winner only when what the sentence says is true for that item: - **The winner's own signal is positive.** The raw similarity, affinity or model score for the item must be above zero. An item can win a slot on a z-score while its raw signal is zero or negative (it was merely the least bad); then there is nothing true to say about it. - **"Trending" needs this surface's own log.** Before the surface has one, trending runs on a tenant-wide popularity prior, and the slot says `popular`, not "widely read this week". - **"People like you" needs a real persona.** A user scored with the cold-start persona gets `recommended`. - **"New" means new.** `surface_specific` says `new` only for an item published in the last 30 days. When the rule fails, the slot carries `reason_key: "recommended"` with "Aanbevolen" / "Recommended" and nothing more. That is a correct answer, not an error: show it like any other reason, or hide the reason line for this key if your design prefers. Expect more `recommended` slots on a new surface and for new users; their share falls as the log and the personas grow. ## Reason keys and defaults | Winner | Reason key | Default (nl / en) | |---|---|---| | `item_to_item` with an anchor | `similar_to_read` | Omdat je dit net las / Because you just read this | | `item_to_item` without an anchor | `similar` | Lijkt op wat je leest / Similar to what you read | | `content_affinity` on declared goals | `matches_goals` | Past bij je doelen / Matches your goals | | `content_affinity` on behaviour | `more_about_tag` | Meer over dit onderwerp / More about this topic (a template such as "Meer over {tag}" names the tag) | | `item_to_item` with an anchor of another kind that pairs: a recipe or a workout with a product, a community with a document | `pairs_with` | Past bij dit recept / Pairs with this recipe; Past bij deze workout / Pairs with this workout; Gelezen in deze community / Read in this community | | `item_to_item` with any other cross-kind anchor | `more_about_tag` | as above | | `session_items` | `similar` | Lijkt op wat je leest / Similar to what you read | | `persona_bayes`, `user_bayes`, `user_to_user` | `people_like_you` | Populair bij leden zoals jij / Popular with members like you | | `trending` on this surface's own log | `trending` | Deze week veel gelezen / Widely read this week | | `trending` on the tenant-wide prior | `popular` | Populair / Popular | | `curated` | `editors_pick` | Gekozen door de redactie / Chosen by the editors | | `surface_specific`, item under 30 days | `new` | Nieuw / New | | any winner whose signal is not positive; a cold persona; an old item under `surface_specific` | `recommended` | Aanbevolen / Recommended | The editorial list (the control arm, and the fallback) carries `editors_pick` for a curated list, `popular` for the 7-day popular list and `new` for the newest list. The full table is in [Reason keys](https://staging.patterna.nl/docs/reference-reason-keys). `trending` is the smoothed 7-day tap rate of each item on this surface, all locales together (the request's locale decides which items are candidates). It is not computed per persona or segment. `pairs_with` is said only where it is true: a product under a recipe (a document in a collection whose name contains recipe, recept, maaltijd or meal), a product under a workout (workout, training, oefening, exercise), or a document under a community. Each pairing has its own default text. Any other cross-kind anchor, such as a product under an ordinary article, says `more_about_tag`. `context.anchor_item_id` is looked up in the whole catalog, whatever its kind. ## Overrides Overrides live in the flow config under `reasons`, keyed by reason key or by generator kind, then by locale: ```json { "reasons": { "similar_to_read": { "nl": "Omdat je dit net las", "en": "Because you just read this" }, "trending": { "nl": "Veel gelezen deze week", "en": "Read a lot this week" }, "content_affinity": { "nl": "Meer over {tag}", "en": "More about {tag}" } } } ``` **Precedence.** For each slot serve takes the reason-key template if there is one, else the template of the winning generator, else the default. An override replaces the whole locale map for that key. If you override `trending` with only `nl`, the `en` default is gone for that key, so always give every locale you serve. Because overrides are part of the flow, changing a reason creates a new flow version. You edit them per locale in the flow editor. The change is versioned, audited and can be rolled back like any flow change. ## Placeholders - `{anchor_title}`: the anchor item's title in the request locale. - `{tag}`: the winning tag's label per locale, from the taxonomy. Phase tags and health claims are never named. - `{segment}`: the user's persona label (taxonomy kind `segment`). When a placeholder cannot be filled for a locale, that locale gets the default text instead, so a user never sees a literal `{tag}`. ## Validation `POST /surfaces/{key}/flows` refuses an unknown reason key or generator key, an unknown locale or an unknown placeholder with `400 invalid_reason_template`, so a typo never reaches a user. ## Drafts from the jobs The `reasons` job drafts missing sentences with the LLM and saves them as a **new, inactive** flow version with a note that starts with `REVIEW NEEDED`, plus an audit entry. It never overwrites a text and never activates anything. The job keys its drafts by generator kind. Serve reads generator-keyed templates, so the drafts work as they are. ## Locales Reasons come back as a map per locale, today `nl` and `en`. The app picks the sentence to show. The request's `context.locale` filters items; it does not choose the reason. ## Rules for good reasons - **True.** The sentence must describe what the generator actually used. "Because you read X" only from item-to-item with an anchor; "Pairs with this recipe" only under a recipe. The engine enforces the rule above for its own keys; your overrides must keep to it too. - **Short.** One line under a title. - **No health values.** A reason never states a measurement, a symptom or a cycle phase. - **Plain.** No superlatives, no "just for you". - **Approved.** Have an editor sign off every sentence per locale before activation. You may choose not to render the reason on a placement. Patterna still returns and logs it. ## Read next - [Reason keys](https://staging.patterna.nl/docs/reference-reason-keys) - [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals) - [Flow config reference](https://staging.patterna.nl/docs/reference-flow-config) - [Surfaces and flows](https://staging.patterna.nl/docs/surfaces-and-flows) --- # Surfaces and flows URL: https://staging.patterna.nl/docs/surfaces-and-flows > Surfaces move through shadow, testers, live and off; flows are versioned configs that you activate and roll back by activating an older version. A **surface** is one placement in your app that shows recommendations, such as the article list on Home. A **flow** is the recipe that fills it: which generators run, which filters apply, which ranker and how much exploration. A surface has one active flow version at a time. You change a surface's behaviour by activating another flow version, never by editing the active one. ## Surfaces | Field | Meaning | |---|---| | `key` | stable id used in API calls, e.g. `home_articles` | | `name` | label in the panel | | `item_kinds` | which kinds may appear: `document`, `product`, `community`, `event`, `tip` | | `slots` | how many items a slate has; default 6. A request may ask for 1 to 50 with `slots`. | | `fallback` | the editorial list: `{ "kind": "newest" }` (default), `{ "kind": "curated", "item_ids": [...] }` or `{ "kind": "popular" }` (7-day popular). Newest fills any gap. Optional `by_anchor`: an object from an anchor item id to the editor's list for that page, for example `{ "kind": "newest", "by_anchor": { "article-12": ["p-1", "p-2"] } }`, see below | | `exploration_share` | default share of explored requests when the flow sets none; default 0.2, at most 0.5 | | `status` | `shadow` (default), `testers`, `live` or `off` | | `min_slots` | set with `PATCH /surfaces/{key}`: 0 to 50, 0 = always shown. A slate shorter than this is answered with `shown: false`. Use 1 to hide a block that has nothing to show | | `device_resort` | set with `PATCH /surfaces/{key}`: the app may re-sort the slate on the device, see below | The editorial list has two jobs, and the flag on the slate tells them apart: - **Control.** In a running holdout, the control arm gets the editorial list, logged with policy `editorial` and `fallback: false`. That is the comparison, not a failure, and it never counts in the fallback rate. On an anchored surface the list can follow the page. In order, control serves: your own list for this page from `context.control_item_ids` (logged `control_source: request`), else the surface's list for this anchor from `fallback.by_anchor` (`control_source: by_anchor`), else the surface's own list (`control_source: surface`). Every source obeys the request's rules: the anchor is never served, and `exclude_item_ids`, joined communities, candidate kinds, the premium policy and locale apply. Newest items that pass the rules fill the rest. `context.control_item_ids` needs no setting on the surface: send it on every request to the surface, in both arms, since you do not know the arm in advance; the treatment arm uses it only to fill a short slate. - **Fallback.** When a request that should have been ranked times out or hits storage trouble, or the surface has no active flow, Patterna answers with the same list and `fallback: true`. That is the safety net, and it counts in the fallback rate. ## The lifecycle ``` shadow ──▶ testers ──▶ live ──▶ off ``` | Status | What Patterna returns | Arm | What you check | |---|---|---|---| | `shadow` | the ranked slate; your app keeps showing your own list | `none` | requests arrive, latency, fallback rate, slates look sane in the preview | | `testers` | the ranked slate for ids on the testers list, the editorial list for everyone else | `none` | reasons render on device, seen and click reach the trace view by request id | | `live` | the ranked slate; with a running holdout, control gets the editorial list | `treatment`/`control` during a holdout, else `none` | the ledger, SRM, fallback rate | | `off` | nothing: `409 surface_off` | | | The testers list holds Patterna user ids, or session ids in `non_profiled` mode. Your backend can also send the header `X-Patterna-Tester: 1` for its own staff, so your roster decides and nobody needs to look up random ids. A request can also set `"shadow": true`. Patterna then logs and returns the slate with arm `none`, marked as not shown. The panel's preview uses this. `off` is enforced by the engine: requests get `409 surface_off`, which your backend treats as "show your own list". Keep your own feature flag as well: it is the instant switch that needs no call at all. ## Flows A flow config has these parts: ```json { "generators": [ { "kind": "content_affinity", "weight": 0.5 }, { "kind": "trending", "weight": 0.3 }, { "kind": "curated", "weight": 0.2 } ], "filters": { "exclude_seen_days": 7, "premium_policy": "cap", "premium_cap": 2, "per_collection_cap": 3, "freshness_days": null, "diversity": 0.2 }, "ranker": { "kind": "linear" }, "exploration": { "kind": "plackett_luce", "temperature": 0.7, "share": 0.2 }, "reasons": { "trending": { "nl": "Veel gelezen deze week", "en": "Read a lot this week" } } } ``` - **Generators** score the candidates: `trending` (the 7-day tap rate on this surface, all locales together), `curated`, `item_to_item`, `session_items`, `content_affinity`, `persona_bayes`, `user_bayes`, `user_to_user`, `surface_specific`, and the silent `time_fit` and `format_fit`. Each score is normalised across the candidates and multiplied by the weight. - **Filters** remove what must not show (expired, past events, out of stock, already seen, premium beyond the policy, too old), leave out what this person turned away or finished (dismissed, completed, paywalled, bought) and cap a slate (per collection, premium). `relevance_floor` hides an anchored block when nothing is related to the page. See [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals). `filters.diversity` (0 to 1) is the one diversity setting: a penalty on items similar to the ones already picked, applied inside the sampler so propensities stay exact. The panel shows it on the ranker node. - **Ranker** is `linear` (the configured weights), `learned_linear` (per-generator weights the jobs fit nightly from logged taps) or `gbdt` (gradient-boosted trees the jobs fit nightly, evaluated in the engine). `lambdamart` is refused. - **Exploration** runs on treatment slates and on slates outside a holdout (arm `none`); control is never explored. See [Exploration and propensities](https://staging.patterna.nl/docs/exploration-and-propensities). - **Reasons** are keyed by reason key or generator kind, with placeholders: see [Reasons](https://staging.patterna.nl/docs/reasons). Every field, default and valid range is in the [Flow config reference](https://staging.patterna.nl/docs/reference-flow-config). In the panel the flow is a node canvas; the JSON view shows the same config. ## One slate, several blocks Sometimes one page shows the same kind of recommendation twice, for example one product near the top of an article and a carousel of six at the end. Ask for **one** slate of 7 and render position 0 in the first block and positions 1 to 6 in the second. Then the two blocks never repeat each other, and the propensities stay in one impression. Report each item with its slate `position` (0 to 6), whichever block it was in, and send the optional `block` name (up to 32 characters, for example `top` or `end`) on each interaction, so you can tell the blocks apart in the trace. The ledger can also give a row per block (`by=block`), over the slates reported seen in that block; the whole-slate row decides. Two settings help here: - **`min_slots`** on the surface. When fewer items than this pass the request's rules (exclusions, the anchor, joined communities, candidate kinds, premium, freshness; counted before the arm, so both arms hide alike), the answer carries `shown: false` and no items: hide the block. No recommendations means no block, so 1 is the usual value; 0 means always shown. The slate is logged `not_shown`, stays in its arm and counts as served with zero success in both arms (intention to treat). Never report it as abandoned: it is already counted, and abandon answers `409 not_shown`. - **`context.measured`** on the request (default `true`). Set it to `false` when the user did not allow usage analytics and your app will not report seen and taps. Such slates are served as usual but left out of the ledger, power and learning in both arms. See [Timeouts and what counts in the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger). ## Re-sorting on the device A surface under a holdout must not be re-sorted by your app unless the surface allows it: the ledger and the estimators read the position Patterna served. Set `device_resort: true` on the surface (`PATCH /surfaces/{key}`). Your app may then re-sort the slate with a rule of its own, and must send `displayed_position` on every interaction; without it the interaction is refused with `422 displayed_position_required`. The served position comes from the logged slate. The ledger stays intention to treat on served slates: both arms are re-sorted by the same rule, so the comparison holds. The estimators use `displayed_position`. ## Versions, activation and rollback - **Saving creates a version.** Every save is a new, numbered, inactive version. Nothing changes on the surface. - **Simulate first.** Run the version on logged traffic to see a SNIPS estimate and the twin curve. - **Activate.** Activation is audited. A note is optional in the API and required in the panel. On a `live` surface with the two-person rule on, a direct activation answers `409 approval_required` and a second person approves the request. - **Rollback is activation.** The previous versions stay. Rolling back means activating an older version, one click in Flows. ```curl curl -sS "https://api.patterna.nl/v1/surfaces/home_articles/flows" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" ``` ```curl curl -sS -X POST "https://api.patterna.nl/v1/surfaces/home_articles/flows/3/activate" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \ -H "Content-Type: application/json" \ -d '{"note": "Back to v3: v4 raised the fallback rate"}' ``` Every slate records its `policy_version` and `flow_version`, so the ledger and the trace view always know which recipe served which request. ## Validation Saving a flow (`POST /surfaces/{key}/flows`) is refused with `400 bad_request` when: - there is no generator, or a generator kind is unknown; - `ranker.kind` is not `linear`, `learned_linear` or `gbdt`; - `filters.diversity` is outside 0 to 1; - `exploration.kind` is not `none` or `plackett_luce`, or `exploration.share` is outside 0 to 0.5. Unknown reason keys, locales and placeholders are refused with `400 invalid_reason_template`. The flow editor shows the same checks on the canvas, plus nodes that cannot be reached. ## Read next - [Flow config reference](https://staging.patterna.nl/docs/reference-flow-config) - [Roll back a flow](https://staging.patterna.nl/docs/roll-back-a-flow) - [Go live in six steps](https://staging.patterna.nl/docs/go-live-in-six-steps) --- # Data we never store URL: https://staging.patterna.nl/docs/data-we-never-store > The PII Patterna never stores, the allowlists that enforce it, retention per data class, and how deletion and export work. Patterna is built so that personal data cannot enter, rather than being cleaned up later. Ranking works on random ids, item ids, positions and a little context. Anything else is rejected at the door or dropped by the connector before a row is written. ## Never stored, in any mode - email addresses - names - IP addresses - device ids - free text written by users (search queries, coach questions, community posts) - coordinates - any health value (measurements, symptoms, cycle phase, any Cyclus value) Your identity provider's subject (such as `auth0|...`) never reaches Patterna either. You mint a random id per user and keep the mapping on your side. How that works across the backend, PostHog, GA4 and Shopify: [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors). ## How it is enforced | Layer | Rule | |---|---| | SDKs | Refuse email-shaped and `provider|id`-shaped user ids, and a user id on a `non_profiled` request, before anything is sent. The id is never put in the error message. | | Event envelope | `context` accepts only `locale`, `app_version`, `platform`, `hour_bucket`, `weekday`, `region`, `subscription_tier`, `tenure_days` (a coarse region name from your own list, never a location). Any other key is dropped and counted; a personal-data name rejects the event. | | Event props | An allowlist per event type. An event with a PII field name (such as `email` or `name`) in `props` or `context`, or an email or `provider|id` user id, is rejected on its own with code `pii_rejected` and the offending paths in `fields` (never the values); the rest of the batch is accepted. Any other field outside the allowlist is dropped before the row is written and counted in the response's `dropped_fields`. | | User ids | Ids containing `@` or whitespace, or longer than 128 characters, are refused: `400` on the user endpoints, and the event is rejected in an events batch. | | Catalog | Item attributes with `email`, `name` or `ip` keys are rejected with `422 pii_rejected`. Contact details in item text (emails, phone numbers) are scrubbed at upsert, before storage or any prompt. | | Connectors | Drop what they do not need before writing. PostHog keeps no raw distinct id: an id Patterna already minted (`u_…`) is kept, any other is replaced by a keyed hash under the tenant's `PATTERNA_ID_SECRET`, a secret Patterna holds (so make the distinct id your own random Patterna id, never the identity-provider subject) and drops person properties, `$set`, IP and every `$` property except OS, app version and locale. GA4 uses `user_pseudo_id` only as an input to hash session and event ids. Shopify queries select no customer fields. | Where users type, Patterna gets a derivative, never the text: `search_performed` carries a `query_hash`, `coach_question` carries a `topic_cluster`, and `order_placed` carries an `order_id_hash`. Member-written names of communities and events are imported with an empty title by default. The panel shows them as "Member community c_...". ## Item text To tag an item, the jobs need its body text. The text is stored only until enrichment is done: - The catalog keeps the body in a separate table and marks the item for enrichment. - After tagging and embedding, the text row is deleted. What remains is the embedding and the tags. - If tagging fails, the text is kept for review and removed by retention after 30 days. - The text is never returned by the API. ## LLM calls Every LLM call passes a data-class gate: `editorial`, `aggregate` or `user_derived`. Today only editorial text is sent (published catalog content, the taxonomy, flow templates). For `user_derived` data, prompts and answers are kept as hashes only, and OpenRouter is not cleared for that class. Calls go through EU endpoints with zero retention and pinned model versions. Audit rows of LLM calls live 30 days. ## Retention Each data class has a retention period, editable in Governance: events, impressions, interactions and the audit log. The jobs apply it daily at 03:00 UTC. Fixed periods: LLM audit rows 30 days, LLM cache 180 days unused, texts of failed items 30 days. Raw source payloads: none are stored, by the engine or the jobs. Only the allowed fields of each event are kept. ## Deletion Send a deletion for the random id: ```curl curl -sS -X DELETE "https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" ``` ```json { "id": "6f1c2b9e-3d4a-4f0e-9a51-2c7d8e1b0a44", "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe", "requested_at": "2026-10-13T09:12:00Z", "rows_deleted_at": "2026-10-13T09:12:00Z", "models_retrained_at": null, "stored_as": "sha256" } ``` 1. **Rows, immediately.** In one transaction before the answer, the engine deletes the user row, holdout assignments, opt-out marker, events and precomputed shortlist, and unlinks the user's impressions (user id removed, candidate list emptied). `rows_deleted_at` is set in the answer. The slates keep only a salted `unit_hash`, so the ledger does not change after the fact. 2. **Models, overnight.** The nightly learning run retrains on data without the user and stamps `models_retrained_at`. That is how deletion reaches model artifacts. The deletion record stores a salted hash of the id, not the id. Governance shows both timestamps for every request. Switching a user to `non_profiled` is an opt-out: the request deletes the user row, keeps only a salted hash of the id as the opt-out marker and files a deletion request holding only that hash; the hourly job then unlinks the user's impressions (interactions stay with their slate) and deletes the user's events, assignments and shortlist. The call takes the ingest key. Step by step: [Handle a deletion request](https://staging.patterna.nl/docs/handle-a-deletion-request). ## Export Export everything Patterna holds about one user (admin key): ```curl curl -sS "https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" ``` You hold the mapping from your account to the random id, so you answer the access request; Patterna supplies the rows. ## Isolation between tenants Every tenant table uses row-level security. Each connection sets the tenant before it touches data, and a cross-tenant test runs in CI. The tenant always comes from the API key, never from the request body. ## Read next - [Consent modes](https://staging.patterna.nl/docs/consent-modes) - [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors) - [Handle a deletion request](https://staging.patterna.nl/docs/handle-a-deletion-request) --- # Identity across connectors URL: https://staging.patterna.nl/docs/identity-across-connectors > How user ids stay random across the backend, PostHog, GA4, Shopify and imports: minting, hashing, session ids, assignment_unit and deletion. Patterna never learns who a person is. It knows a random id that your backend minted, and only your backend can turn that id back into an account. This page shows how to keep it that way when events arrive from several places: your backend, PostHog, GA4, Shopify and one-off imports. ## The rule 1. **Your backend mints one random id per person per tenant**: 128 random bits, for example `u_` plus 26 base32 characters, or a UUIDv4. Never derive it from the email or the account id without a key only you hold. 2. **The mapping stays with you**: a table from your account id to the Patterna id. Patterna has no copy. 3. **Every connector sends that id, a keyed hash of an id, or no user id at all.** Never an email, a phone number, an account id or an identity-provider subject such as `auth0|…`. What enforces it: - The SDKs refuse email-shaped and `provider|id`-shaped user ids before anything is sent. - The engine refuses user ids containing `@` or whitespace, or longer than 128 characters. - The engine also rejects, per event, an event with a PII field name (`email`, `name`, …) or an email or `provider|id` user id (`pii_rejected`). Those checks catch accidents. They cannot catch an account id that merely looks random, so the rule above is yours to keep. ## Per connector | Source | Id it sends | What Patterna stores | What you must do | |---|---|---|---| | Your backend ([Backend API](https://staging.patterna.nl/docs/connector-backend-api), SDKs) | the Patterna id from your mapping table, or a session id in `non_profiled` mode | the Patterna id, or the session id with no user row | look up the id per request; never forward an id from the client | | [PostHog](https://staging.patterna.nl/docs/connector-posthog) webhook | PostHog's `distinct_id` | never the raw distinct id: an import-minted `u_…` is kept, anything else becomes a Patterna-minted id (see below) or is dropped | make the distinct id the Patterna id (`posthog.identify(patternaId)`) or a keyed hash; send only `personalized` users | | PostHog batch export | `distinct_id`, optionally hashed by the reader (`id_mapping: "hmac"`) | `u_` + a Patterna-minted hash, never the raw value | if Patterna must not be able to derive the id, hash before the export with your own key | | [GA4](https://staging.patterna.nl/docs/connector-ga4) | GA4 `user_id` (yours), `user_pseudo_id` (device) | `user_id` as is or as a Patterna-minted hash; the device id only as an input to derive session and event ids | set GA4 `user_id` to the Patterna id, or turn on hashing | | [Shopify](https://staging.patterna.nl/docs/connector-shopify) | no customer id; the `patterna_uid` or `patterna_sid` cart attribute if your storefront sets it (webhook `/webhooks/shopify/{tenant}`, HMAC only) | `order_id_hash`, product ids, value | forward orders from your backend with the Patterna id, or link through the cart attribute | | [Quiz CSV](https://staging.patterna.nl/docs/connector-quiz-csv) | the id column you name | tag affinities on users that already exist | export the file with the Patterna id column, never your account id | | One-off imports from your own database | your ids, replaced by Patterna-minted hashes during the import | the hashed ids; the mapping goes to you as a file | load the mapping file into your mapping table, then delete it | ## Ids that Patterna mints Where a connector replaces a source id with a hash (the PostHog webhook and batch reader, GA4 with hashing on, the one-off imports), Patterna mints the id itself: `u_` plus base32 of HMAC-SHA256 under the tenant's `PATTERNA_ID_SECRET`. **Patterna holds that secret, not you.** That is what lets PostHog events and imported history join up without a mapping step. The consequence: anyone with the secret can compute the Patterna id again from the source id (for example from a PostHog distinct id that is your account id). Patterna never stores the source id, but it could re-derive the link. If that is not acceptable for your data protection assessment, do not let Patterna hash: map the source id to your own random id, or hash it with your own key as below, before the data reaches Patterna. Imported history joins your live ids only if you load the mapping file into your mapping table. Ids your backend mints itself never pass through this secret. ## Hashing an external id with your own key When a connector only knows an id from another system (a PostHog distinct id, a GA4 user id, a Shopify customer id), turn it into a Patterna id with a keyed hash under a key you hold: ```text patterna_id = "u_" + base32( HMAC-SHA256(key, "user" + 0x1F + external_id) )[:26] ``` - The key is at least 32 random bytes and lives in your secret store. - The same external id always gives the same Patterna id, so events from several sources join up. - Without the key nobody can go from the external id to the Patterna id, or back. - Store the result in your mapping table too, so deletion can find it. Keep one scheme per tenant. If your backend already mints random ids, map the external id to that id in your backend rather than hashing it a second way. ## Sessions in `non_profiled` mode A `non_profiled` request carries a `session_id` and no `user_id`, and Patterna stores no user row. Mint the session id like a user id (128 random bits) and rotate it, for example when the app returns after 30 minutes in the background. It is not a device id and is never persisted on the device beyond the session. ## Assignment unit Rotating session ids have one cost: the holdout arm comes from the session id, so the same person can be in treatment in the morning and control in the afternoon, and the ledger's intervals are clustered on sessions, not people. The recommendation request takes an optional `assignment_unit` on the recommendation request: ```json { "surface": "more_to_read", "mode": "non_profiled", "session_id": "s_k3j2h4g5f6d7s8a9", "assignment_unit": "9c1f0e6d…64 hex characters…", "context": { "locale": "nl", "anchor_item_id": "doc_123" } } ``` - You compute it: lowercase hex HMAC-SHA256 of your own user id under a key you hold, per experiment or per tenant. It must be exactly 64 hex characters. It counts on `non_profiled` requests only and is ignored on the others, which already have a user id. The SDKs have a helper (`patterna.AssignmentUnit(secret, userID)` in Go, `assignmentUnit(secret, userId)` in TypeScript). - Patterna uses it **only** to draw the arm and as the ledger's unit. It is never stored raw (only the salted `unit_hash`), never joined to events and never used for ranking. - Without your key it cannot be linked to an account, and rotating the key per experiment stops it from linking two experiments. - The legal reasoning for your records: it serves measurement (keeping one person in one arm so the comparison is valid), not profiling. The request still ranks on context only. ## Deletion reaches every id you mapped On an account deletion, look up every Patterna id you hold for that account (the minted id, and any hashed ids from connectors), call `DELETE /users/{user_id}` for each with your ingest key, then delete the mapping rows. Session ids need no deletion: no user row exists for them, and once the account is gone they point to nobody. See [Handle a deletion request](https://staging.patterna.nl/docs/handle-a-deletion-request). ## Read next - [Consent modes](https://staging.patterna.nl/docs/consent-modes) - [Data we never store](https://staging.patterna.nl/docs/data-we-never-store) --- # Backend API URL: https://staging.patterna.nl/docs/connector-backend-api > Call Patterna directly from your backend: the three key scopes, a curl for each, and event signing. Your backend talks to Patterna over HTTPS with a scoped API key. The app never holds a key. This page covers the keys, one working call per scope, and event signing. ## Setup for the code examples Every example below comes in four tabs. The TypeScript and Go tabs use the SDKs for serve and ingest calls. The SDKs have admin methods too (`applyFlow`, `setSurfaceStatus`, `ledger` and so on), but these tabs call the admin REST API through the small `rest` helper defined here so every request is visible. The Python tab calls the REST API with httpx; the Python SDK (`patterna-sdk` 0.2.0, installed from the public repository [payam-h/patterna-sdk](https://github.com/payam-h/patterna-sdk); not on PyPI) has the same shape as the other two. The Go snippets run inside a function that has `ctx context.Context` and `pc *patterna.Client` from `newClient()` and returns an `error`; add `time` to the imports where a snippet uses it. ```curl # Keys come from Connectors or Governance. Each is shown once. # Serve keys start with pk_s_, ingest keys with pk_i_, admin keys with pk_a_. export PATTERNA_SERVE_KEY="pk_s_paste-the-serve-key" export PATTERNA_INGEST_KEY="pk_i_paste-the-ingest-key" export PATTERNA_ADMIN_KEY="pk_a_paste-the-admin-key" ``` ```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") ``` ## Keys and scopes Every key has one scope. Pick the smallest scope that does the job. | Scope | Prefix | What it may call | Typical holder | |---|---|---|---| | `serve` | `pk_s_` | `POST /recommendations`, `POST /interactions` | the backend code path that renders a list | | `ingest` | `pk_i_` | `POST /events`, `PUT /catalog/items`, item reads, status and tags, `PUT /users/{user_id}/mode`, `PUT /users/{user_id}/profile`, `DELETE /users/{user_id}`, the webhooks | the backend, connectors, catalog sync, consent and deletion | | `admin` | `pk_a_` | everything above, plus surfaces, flows, experiments, ledger, exports, keys, user export and trace, audit | the panel and staff tooling | An admin key passes every scope. A serve key on an ingest route (or the other way round) gets `401 wrong_scope`. - You create keys in **Connectors** or **Governance**. The secret is shown **once**. Store it in your secret manager straight away. - Patterna keeps only a SHA-256 hash of the key. Lists show the prefix (`pk_s_…`, `pk_i_…`, `pk_a_…`), never the secret. - A key has an environment label (`production` by default). The label is for your own bookkeeping and isolates nothing: a staging key on the production tenant writes production data. For testing, use a separate staging tenant on its own environment; ask us for one. - The tenant comes from the key, never from the request body. A `tenant_id` in a body is ignored. - Every request sends `Authorization: Bearer <key>`. Every response carries `X-Request-Id`. Log it; support asks for it. The three user calls (`mode`, `profile`, `DELETE`) take the ingest key, so your backend needs no admin key for consent and deletion. `GET /users/{user_id}` (export) and the trace need an admin key. ## serve: recommend and report Ask for a slate. The response holds a `request_id` you must echo back with the interactions. ```curl curl -sS -X POST "https://api.patterna.nl/v1/recommendations" \ -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \ -H "Content-Type: application/json" \ -d '{ "surface": "home_articles", "mode": "personalized", "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe", "context": { "locale": "nl", "platform": "ios" }, "slots": 6 }' ``` ```ts const slate = await patterna.recommend({ surface: "home_articles", mode: "personalized", user_id: "u_mfrggzdfmztwq2lknnwg23tpobyxe", context: { locale: "nl", platform: "ios" }, slots: 6, }); const requestId = slate.request_id; // echo it in /interactions ``` ```go slate, err := pc.Recommend(ctx, patterna.RecommendRequest{ Surface: "home_articles", Mode: patterna.ModePersonalized, UserID: "u_mfrggzdfmztwq2lknnwg23tpobyxe", Context: patterna.RecommendContext{Locale: "nl", Platform: "ios"}, Slots: 6, }) if err != nil { return err } requestID := slate.RequestID // echo it in /interactions ``` ```python r = serve.post( "/recommendations", json={ "surface": "home_articles", "mode": "personalized", "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe", "context": {"locale": "nl", "platform": "ios"}, "slots": 6, }, timeout=0.15, ) r.raise_for_status() request_id = r.json()["request_id"] # echo it in /interactions ``` In `non_profiled` mode, send `session_id` and leave out `user_id`. See [Consent modes](https://staging.patterna.nl/docs/consent-modes). Report what the user saw, tapped or dismissed. `position` is the slot Patterna served, counted from 0 (`items[0]` is position 0). Copy `kind` and `position` from the slate item. If the app showed the item somewhere else than its served slot, add `displayed_position` as well. The engine checks reports against the logged slate: `kind` and the served position are taken from the slate (what you send is ignored), an `item_id` that was not in the slate fails the call with `422 unknown_item` (`details.unknown_item_ids` lists them), and on a surface with `device_resort` on, every interaction needs `displayed_position` (`422 displayed_position_required`). When you render one slate in two blocks, send the optional `block` (up to 32 characters) with each interaction. The ledger can then also give a row per block (`by=block`). ```curl curl -sS -X POST "https://api.patterna.nl/v1/interactions" \ -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \ -H "Content-Type: application/json" \ -d '{ "request_id": "REQUEST_ID_FROM_THE_SLATE", "interactions": [ { "item_id": "doc_123", "kind": "document", "position": 0, "type": "seen", "occurred_at": "2026-10-13T09:00:00Z" }, { "item_id": "doc_123", "kind": "document", "position": 0, "type": "click", "occurred_at": "2026-10-13T09:00:04Z" } ] }' ``` ```ts await patterna.reportInteractions(requestId, [ { item_id: "doc_123", kind: "document", position: 0, type: "seen", occurred_at: "2026-10-13T09:00:00Z" }, { item_id: "doc_123", kind: "document", position: 0, type: "click", occurred_at: "2026-10-13T09:00:04Z" }, ]); ``` ```go at := func(s string) time.Time { t, _ := time.Parse(time.RFC3339, s); return t } // Fire-and-forget: queued and sent by the SDK's workers; Close drains the queue. err := pc.ReportInteractions(requestID, []patterna.Interaction{ {ItemID: "doc_123", Kind: "document", Position: 0, Type: patterna.InteractionSeen, OccurredAt: at("2026-10-13T09:00:00Z")}, {ItemID: "doc_123", Kind: "document", Position: 0, Type: patterna.InteractionClick, OccurredAt: at("2026-10-13T09:00:04Z")}, }) if err != nil { return err // ErrQueueFull when the queue is full } ``` ```python r = serve.post( "/interactions", json={ "request_id": request_id, "interactions": [ {"item_id": "doc_123", "kind": "document", "position": 0, "type": "seen", "occurred_at": "2026-10-13T09:00:00Z"}, {"item_id": "doc_123", "kind": "document", "position": 0, "type": "click", "occurred_at": "2026-10-13T09:00:04Z"}, ], }, ) r.raise_for_status() # 202 ``` Budget: Patterna answers in 60 ms p99 from cache and 250 ms live. Call it with a 150 ms timeout and show your own list when it does not answer. Never retry `/recommendations`: every call logs an impression. The engine's own budget is 120 ms by default. Send your own deadline as the `X-Deadline-Ms` header or a `deadline_ms` body field (10 to 10000; the engine answers within the smaller of its budget and your deadline minus a tenth for the network); the SDKs send 120 ms. When you give up on a slate anyway, or decide not to show one you received, call `POST /recommendations/{request_id}/abandon`: the slate is flagged `client_abandoned`, stays in its arm with zero taps and counts in the fallback rate. See [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger). A surface can set `min_slots` (`PATCH /surfaces/{key}`, 0 to 50). When fewer items pass the request's rules (counted before the arm, so both arms hide alike), the response carries `shown: false` and no items: hide the block, and do not call abandon for it (`409 not_shown`), because the engine has already logged it as not shown and counts it in its arm as served with zero taps (intention to treat). Set `min_slots` to 1 for "no recommendations means no block". Two request fields serve anchored and community surfaces, and two answer fields say what the engine did: - `context.control_item_ids` (up to 50): your own editorial list for this page, in order (for example the article's pairs-with products, or the editor's related articles). No surface setting is needed. The control arm serves it, with the anchor, `exclude_item_ids`, joined communities, kinds, premium and locale applied and newest items filling the rest. Without it control serves the surface's `fallback.by_anchor` list for the anchor, else the surface's own list. Send it in both arms; treatment uses it only to fill a short slate. - `context.member_of` (up to 500): the community ids the user already joined, for `non_profiled` requests. They are left out of the slate in every mode and are not stored; in `personalized` and `cohort` mode membership also comes from `community_joined` / `community_left` events. - `fill_count` on the slate: when a treatment slate comes up short after its filters, the engine fills it from the editorial list, like control, and says how many slots it filled (absent when 0; each filled slot is logged with `fill: true`). - `control_source` on editorial slates: `request`, `by_anchor` or `surface`, the list control served. What to do with an error from `/recommendations`: | Answer | Meaning | What to do | |---|---|---| | timeout, network error, 5xx, `429 rate_limited` | Patterna could not answer in time | show your own list | | `409 surface_off` | the surface is switched off in the panel | show your own list; this is expected while a surface is off | | `422 no_candidates` | no published item of the surface's kinds and locale is left after the filters | show your own list, then fix the catalog or the flow | | `400 bad_request` (also any unknown JSON field), `401 unauthorized`, `401 wrong_scope`, `404 unknown_surface`, `422 mode_conflict` | a bug in the call | fail loudly in your logs and alerts; showing your list is fine, hiding the bug is not | `422 mode_conflict` means a `non_profiled` request carried a `user_id`. All codes: [Error codes](https://staging.patterna.nl/docs/reference-error-codes). ## ingest: events and catalog Send canonical events, up to 500 per call. `event_id` is the idempotency key, so a resend is safe. ```curl curl -sS -X POST "https://api.patterna.nl/v1/events" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \ -H "Content-Type: application/json" \ -d '{ "events": [ { "event_id": "community_joined:c_42:u_mfrggzdfmztwq2lknnwg23tpobyxe", "type": "community_joined", "occurred_at": "2026-10-13T09:10:00Z", "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe", "source": "backend", "context": { "locale": "nl", "platform": "ios" }, "props": { "community_id": "c_42", "source": "community_page" } } ] }' ``` ```ts const result = await patterna.ingestEvents([ { event_id: "community_joined:c_42:u_mfrggzdfmztwq2lknnwg23tpobyxe", type: "community_joined", occurred_at: "2026-10-13T09:10:00Z", user_id: "u_mfrggzdfmztwq2lknnwg23tpobyxe", source: "backend", context: { locale: "nl", platform: "ios" }, props: { community_id: "c_42", source: "community_page" }, }, ]); console.log(result); ``` ```go at := func(s string) time.Time { t, _ := time.Parse(time.RFC3339, s); return t } res, err := pc.IngestEvents(ctx, []patterna.Event{{ EventID: "community_joined:c_42:u_mfrggzdfmztwq2lknnwg23tpobyxe", Type: "community_joined", OccurredAt: at("2026-10-13T09:10:00Z"), UserID: "u_mfrggzdfmztwq2lknnwg23tpobyxe", Source: "backend", Context: &patterna.EventContext{Locale: "nl", Platform: "ios"}, Props: map[string]any{"community_id": "c_42", "source": "community_page"}, }}) if err != nil { return err } fmt.Println(res.Accepted, res.Duplicates, res.Rejected) ``` ```python r = ingest.post( "/events", json={ "events": [ { "event_id": "community_joined:c_42:u_mfrggzdfmztwq2lknnwg23tpobyxe", "type": "community_joined", "occurred_at": "2026-10-13T09:10:00Z", "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe", "source": "backend", "context": {"locale": "nl", "platform": "ios"}, "props": {"community_id": "c_42", "source": "community_page"}, } ] }, ) r.raise_for_status() print(r.json()) ``` The answer is `202` with `accepted`, `duplicates`, `rejected` (event id and reason) and `dropped_fields`. A prop outside the event type's allowlist that is not personal data is dropped and counted in `dropped_fields` (field name → count); the event itself is accepted. Each event is judged alone and the call still answers `202`. Each `rejected` entry has `event_id`, `reason`, `code` and, for PII, `fields` (the paths, never the values). Codes: `invalid_event`, `pii_rejected` (a PII field name such as `email` or `name` in `props` or `context`, or an email or `provider|id` user id), `invalid_signature`, `no_signing_secret`, `unknown_request`, `unknown_item`, `displayed_position_required`. The response also counts `pii_rejected`. Unknown fields that are not PII are still dropped into `dropped_fields`. The batch never fails as a whole for one bad event. The event types are in [Event types](https://staging.patterna.nl/docs/reference-event-types). ```json { "accepted": 1, "duplicates": 0, "rejected": [], "dropped_fields": { "props.email": 1 } } ``` Upsert catalog items, up to 500 per call. Send editorial fields only. An attribute with an `email`, `name` or `ip` key fails the call with `422 pii_rejected`. ```curl curl -sS -X PUT "https://api.patterna.nl/v1/catalog/items" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \ -H "Content-Type: application/json" \ -d '{ "items": [ { "external_id": "doc_123", "kind": "document", "collection": "recipes", "title": "Magnesiumrijke havermout", "locale": "nl", "status": "published", "premium": false, "published_at": "2026-10-01T08:00:00Z", "text": "Body text for tagging. Stored only as embedding and tags, never returned." } ] }' ``` ```ts const result = await patterna.upsertItems([ { external_id: "doc_123", kind: "document", collection: "recipes", title: "Magnesiumrijke havermout", locale: "nl", status: "published", premium: false, published_at: "2026-10-01T08:00:00Z", text: "Body text for tagging. Stored only as embedding and tags, never returned.", }, ]); console.log(result.upserted, result.enrichment_queued); ``` ```go published, _ := time.Parse(time.RFC3339, "2026-10-01T08:00:00Z") res, err := pc.UpsertItems(ctx, []patterna.ItemUpsert{{ ExternalID: "doc_123", Kind: patterna.KindDocument, Collection: "recipes", Title: "Magnesiumrijke havermout", Locale: "nl", Status: patterna.StatusPublished, Premium: false, PublishedAt: &published, Text: "Body text for tagging. Stored only as embedding and tags, never returned.", }}) if err != nil { return err } fmt.Println(res.Upserted, res.EnrichmentQueued) ``` ```python r = ingest.put( "/catalog/items", json={ "items": [ { "external_id": "doc_123", "kind": "document", "collection": "recipes", "title": "Magnesiumrijke havermout", "locale": "nl", "status": "published", "premium": False, "published_at": "2026-10-01T08:00:00Z", "text": "Body text for tagging. Stored only as embedding and tags, never returned.", } ] }, ) r.raise_for_status() print(r.json()) ``` ### Keeping the catalog in sync - Call `PUT /catalog/items` from your content system on every create and edit, and `POST /catalog/items/{external_id}/status` with `archived` when an item is taken down. Do it detached from the editor's action: a Patterna failure must never fail the save. - Run a nightly reconcile that upserts every published item. Upserts are idempotent, so this catches lost calls. - `GET /catalog/items?changed_since=<RFC3339>&cursor=<cursor>` pages through what Patterna holds, so the reconcile can also find items you deleted, and `DELETE /catalog/items/{external_id}?kind=<kind>` removes an item for good. The listing includes archived items; deleted ones are gone. The delete answers `200` with `external_id`, `kind`, `deleted`, `tags_removed` and `references_removed`; slates already logged keep the id. ## admin: surfaces and the ledger ```curl curl -sS "https://api.patterna.nl/v1/surfaces" \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" ``` ```ts const surfaces = await rest<Array<{ key: string; status: string; active_flow_version: number | null }>>( "PATTERNA_ADMIN_KEY", "GET", "/surfaces", ); console.table(surfaces); ``` ```go var surfaces []struct { Key string `json:"key"` Status string `json:"status"` ActiveFlowVersion *int `json:"active_flow_version"` } if err := rest(ctx, "PATTERNA_ADMIN_KEY", "GET", "/surfaces", nil, &surfaces); err != nil { return err } fmt.Println(surfaces) ``` ```python r = admin.get("/surfaces") r.raise_for_status() for s in r.json(): print(s["key"], s["status"], s["active_flow_version"]) ``` ```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"]) ``` Most teams never need an admin key outside the panel. Keep it out of request paths. ## Signing events Events that affect ranking (clicks, joins, completions, orders) count for learning only when Patterna trusts their origin. The model is simple: - `POST /events` takes only an `ingest` key (or an admin key). There is no separate "SDK key" scope, and an app never holds a key. - Every event that arrives on an ingest key is trusted and stored as `signed`. Your backend vouches for it by holding the key. - A `signature` is optional. Add one when an event passed through a hop you do not fully trust before your backend forwarded it (an app, a queue). A signature that is present is always checked: a wrong one rejects that event (`invalid_signature`), even on an ingest key. A signed event on a tenant without a secret is rejected with `no_signing_secret`. - Sign on your backend. A signing secret inside an app bundle is a public secret. The signed form: 1. Take the event object as sent, minus the `signature` field. 2. Serialise it as JSON with object keys sorted at every level, no whitespace, HTML characters unescaped, U+2028 and U+2029 escaped. Keep numbers as integers. 3. `signature` = lowercase hex HMAC-SHA256 over those UTF-8 bytes. 4. The HMAC key is the secret as given: `pss_` plus 64 hex characters, used as the UTF-8 bytes of that whole string, byte for byte. Nothing to decode. This is what all three SDKs do. Since engine 0.2 secrets are `pss_` strings. A secret created before that is 64 hex characters without the prefix and no longer verifies: rotate it once (`POST /settings/signing-secret/rotate`) and sign with the new string. Most backends never sign at all: the ingest key is enough. Test vector (a fixture key, not a real secret): | | | |---|---| | secret | `pss_000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f` | | canonical body | `{"event_id":"evt_1","occurred_at":"2026-10-13T09:00:00Z","props":{"item_id":"doc_42","kind":"document","position":0},"request_id":"req_1","session_id":"s_1","type":"rec_click"}` | | signature | `8fefcd355112cecfce0cde75bdc7566d7d6620918e6b6109bfbd7878e317d815` | If you get `3e3a9cbc22f2101a…`, you hex-decoded the part after `pss_`; if you get `c53144ead01bfbeb…`, you dropped the `pss_` prefix. Both are wrong: key with the whole string. ```curl # Shell check only. jq + openssl match the canonical form ONLY when every number is an # integer and no string contains U+2028 or U+2029. event.json is the event as you will send it. # The key is the whole pss_ secret string. jq -jcS 'del(.signature)' event.json \ | openssl dgst -sha256 -hmac "$PATTERNA_SIGNING_SECRET" \ | awk '{print $NF}' ``` ```ts import { signEvent, type Event } from "patterna-sdk"; const event: Event = { event_id: "req_01HZX4T9W6Q2:doc_1842:click", type: "rec_click", occurred_at: "2026-10-13T08:12:09Z", session_id: "s_k3j2h4g5f6d7s8a9", request_id: "req_01HZX4T9W6Q2", surface: "home_articles", source: "sdk", props: { item_id: "doc_1842", kind: "document", position: 0 }, }; // Keys the HMAC with the pss_ secret string. const signed = await signEvent(event, process.env.PATTERNA_SIGNING_SECRET!); ``` ```go // SignEvent is exported by the Go SDK (signing.go); it uses the same canonical JSON as TypeScript. // The key is the pss_ secret string. secret := os.Getenv("PATTERNA_SIGNING_SECRET") signed, err := patterna.SignEvent(patterna.Event{ EventID: "req_01HZX4T9W6Q2:doc_1842:click", Type: "rec_click", OccurredAt: time.Date(2026, 10, 13, 8, 12, 9, 0, time.UTC), SessionID: "s_k3j2h4g5f6d7s8a9", RequestID: "req_01HZX4T9W6Q2", Surface: "home_articles", Source: "sdk", Props: map[string]any{"item_id": "doc_1842", "kind": "document", "position": 0}, }, secret) if err != nil { return err } fmt.Println(signed.Signature) ``` ```python import hashlib import hmac import json import os def canonical_json(event: dict) -> bytes: # The event minus "signature"; leave absent fields out instead of sending None. body = {k: v for k, v in event.items() if k != "signature"} text = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False) # The canonical form escapes U+2028 and U+2029; json.dumps leaves them raw. text = text.replace("\u2028", "\\u2028").replace("\u2029", "\\u2029") return text.encode("utf-8") def sign_event(event: dict, secret: str) -> dict: # The key is the pss_ secret string's UTF-8 bytes. sig = hmac.new(secret.encode("utf-8"), canonical_json(event), hashlib.sha256).hexdigest() return {**event, "signature": sig} signed = sign_event( { "event_id": "req_01HZX4T9W6Q2:doc_1842:click", "type": "rec_click", "occurred_at": "2026-10-13T08:12:09Z", "session_id": "s_k3j2h4g5f6d7s8a9", "request_id": "req_01HZX4T9W6Q2", "surface": "home_articles", "source": "sdk", "props": {"item_id": "doc_1842", "kind": "document", "position": 0}, # keep numbers integers }, os.environ["PATTERNA_SIGNING_SECRET"], ) ``` The shell tab matches the canonical form **only** for events whose numbers are integers and whose strings contain no U+2028 or U+2029. The Python tab escapes those two characters; keep numbers as integers and leave absent fields out instead of sending `null`. Check any signer you write yourself against the vector above. The signing secret: an owner rotates it with `POST /settings/signing-secret/rotate`, which returns the new secret once (and creates a first one when none exists). `POST /settings/signing-secret` creates the first secret on a tenant that has none: `200` with `signing_secret`, `issued_at` and `note`, or `409 signing_secret_exists`. See [Rotate keys and the signing secret](https://staging.patterna.nl/docs/rotate-keys-and-signing-secret). ## Next - [SDK TypeScript](https://staging.patterna.nl/docs/connector-sdk-typescript) and [SDK Go](https://staging.patterna.nl/docs/connector-sdk-go) wrap all of the above with timeouts, fallback and the consent gate. - [API overview](https://staging.patterna.nl/docs/api-overview) covers errors, idempotency and rate limits. The full reference is at https://staging.patterna.nl/docs/api. --- # SDK TypeScript URL: https://staging.patterna.nl/docs/connector-sdk-typescript > The TypeScript SDK for backends and Expo apps: client, backend proxy, offline queue, consent gate, fallback and signing. `patterna-sdk` is the TypeScript client. Use it in two places: on your backend, which holds the keys, and in your browser or Expo app, which talks to **your** backend proxy and never to Patterna with a key. It ships ESM and CJS with types, has zero runtime dependencies and needs only `fetch`, `AbortController`, `setTimeout` and, for signing, WebCrypto. It runs on Node 22+, Bun, Deno, edge runtimes and React Native. ## Install ```sh npm install patterna-sdk@0.4.1 ``` `patterna-sdk` 0.4.1 is on npm (unscoped: there is no `@patterna/sdk`), published from the public repository [payam-h/patterna-sdk](https://github.com/payam-h/patterna-sdk) (MIT), subdirectory `typescript/`, tag `v0.4.1`. Pin the version. It works with engine 0.3.1 (`engine_version` on `/health`). ## The client ```ts import { PatternaClient } from "patterna-sdk"; const patterna = new PatternaClient({ baseUrl: "https://api.patterna.nl/v1", keys: { serve: process.env.PATTERNA_SERVE_KEY, ingest: process.env.PATTERNA_INGEST_KEY }, }); ``` | Method | Key | Budget | Retried | |---|---|---|---| | `recommend(req)` | serve | 150 ms | never: each call logs an impression | | `reportInteractions(requestId, interactions)` | serve | 5 s | once | | `ingestEvents(events)` | ingest | 5 s | once (idempotent on `event_id`) | | `upsertItems(items)` | ingest | 5 s | once | | `setItemStatus(kind, id, { status, published_at })` | ingest | 5 s | once | | `setItemTags(kind, id, tags)` | ingest | 5 s | once | | `setUserMode(userId, mode)` | admin if set, else ingest | 5 s | once | | `setUserProfile(userId, profile)` | admin if set, else ingest | 5 s | once | | `deleteUser(userId)` | admin if set, else ingest | 5 s | once | Budgets are per attempt. Change them per client with `timeouts: { recommendMs, ingestMs }` or per call with `{ timeoutMs, signal }`. The single retry fires after 100 ms on 5xx and network errors only. Timeouts are not retried: the budget is already spent. The engine accepts the ingest key for `setUserMode`, `setUserProfile` and `deleteUser`, so a backend needs only a serve key (`pk_s_…`) and an ingest key (`pk_i_…`). Never configure the admin key (`pk_a_…`) in a request path. ## Errors Every failure is a `PatternaError` with `code`, `op`, `status`, `requestId`, `details` and a `transient` getter. The subclasses: | Class | When | |---|---| | `ApiError` | the API answered with an error; `code` is the API's, e.g. `no_candidates`, `surface_off`, `mode_conflict`, `pii_rejected`. All codes: [Error codes](https://staging.patterna.nl/docs/reference-error-codes) | | `TimeoutError` | the budget ran out | | `NetworkError` | no answer | | `ConsentError` | a consent rule was broken; nothing was sent | | `ValidationError` | bad input, such as an email used as a user id; nothing was sent | ```ts import { ApiError } from "patterna-sdk"; try { await patterna.ingestEvents(batch); } catch (err) { if (err instanceof ApiError) log.warn({ op: err.op, code: err.code, request_id: err.requestId }); else throw err; } ``` ## Consent gate The SDK refuses to send a request that breaks the user's consent mode. - `recommend` throws `ConsentError` (`consent_required`) when `mode` is missing or unknown. - A `non_profiled` request must carry a `session_id` and no `user_id`. The types forbid it, and at runtime the SDK throws `ConsentError` (`consent_violation`). - A user id must be the random per-tenant id you mint. Emails and identity-provider subjects (`auth0|...`) are refused with `ValidationError` everywhere a user id goes. The id never appears in the error message. - Event `context` accepts only `locale`, `app_version`, `platform`, `hour_bucket`, `weekday`, `region`, `subscription_tier`, `tenure_days`. See [Consent modes](https://staging.patterna.nl/docs/consent-modes). ## Fallback `withFallback` runs the Patterna call and switches to your own list on failure. ```ts import { withFallback } from "patterna-sdk"; const r = await withFallback( () => patterna.recommend({ surface: "home_articles", mode, user_id, context: { locale: "nl" } }), () => newestArticleIds(), // your current query { onFallback: (e) => metrics.inc("patterna_fallback", { code: e.code }) }, ); const ids = r.source === "patterna" ? r.value.items.map((i) => i.item_id) : r.value; ``` - It falls back on any SDK error except `ConsentError`, `ValidationError`, `pii_rejected` and `mode_conflict`. Those are bugs in your code, and a consent bug is a privacy bug, so they are rethrown. - An `ApiError` also falls back. Check its `code` in `onFallback`: `surface_off` (409) and `no_candidates` (422) are expected while a surface is off or empty; `bad_request` (400, also for any unknown JSON field), `unauthorized`/`wrong_scope` (401), and `unknown_surface` (404) are bugs to alert on, even though the user sees your list. - `{ on: "timeout" }` narrows the fallback to timeouts. - Report interactions only when `r.source === "patterna"`. A fallback list has no request id. - Report each item with its `kind` (copy it from the slate item) and its 0-based `position`. The engine takes `kind` and the served position from the logged slate anyway and refuses item ids that were not in it (`422 unknown_item`). - A slate with `shown: false` (fewer items than the surface's `min_slots`) means: hide the block. Do not call `abandon` for it; the engine has already logged it as not shown (abandon answers `409 not_shown`). Such a slate carries no items and stays in its arm counted as served with zero taps. - `withFallback` does not report abandonment. On a backend, prefer `patterna.recommendWithFallback(req, fallback)`, which does (see below). ## Backend proxy Your backend authenticates the user, swaps the account id for the Patterna id it minted, applies the user's consent and forwards. The proxy paths mirror Patterna's, so the same SDK works on both sides. ```ts import { Hono } from "hono"; import { PatternaClient, withFallback, ValidationError, ConsentError, type Event } from "patterna-sdk"; const patterna = new PatternaClient({ baseUrl: process.env.PATTERNA_URL!, keys: { serve: process.env.PATTERNA_SERVE_KEY, ingest: process.env.PATTERNA_INGEST_KEY }, }); const app = new Hono<{ Variables: { account: Account } }>(); app.use("/patterna/*", requireSession); // sets c.var.account app.post("/patterna/recommendations", async (c) => { const body = await c.req.json(); const { mode, patternaId } = await consentFor(c.var.account); // your table; missing = non_profiled const req = mode === "non_profiled" ? { surface: body.surface, mode, session_id: body.session_id, context: body.context } : { surface: body.surface, mode, user_id: patternaId, context: body.context }; // fallbackSlate(): your current list as a Slate with `fallback: true` and an empty request_id, // so the app renders one shape and skips reporting interactions for it. const r = await withFallback(() => patterna.recommend(req), () => fallbackSlate(body.surface)); return c.json(r.value); }); app.post("/patterna/events", async (c) => { const { events } = (await c.req.json()) as { events: Event[] }; const { mode, patternaId } = await consentFor(c.var.account); // Never trust identity from the client: set it here. const owned = events.map((e) => mode === "non_profiled" ? { ...e, user_id: undefined, source: "backend" as const } : { ...e, user_id: patternaId, session_id: undefined, source: "backend" as const }); try { return c.json(await patterna.ingestEvents(owned), 202); } catch (err) { if (err instanceof ValidationError || err instanceof ConsentError) return c.json({ code: err.code }, 400); throw err; } }); ``` `Account`, `requireSession`, `consentFor` and `fallbackSlate` are yours. Set `PATTERNA_URL` to `https://api.patterna.nl/v1`. ## Expo app The app talks to your proxy, authenticated as the signed-in user. It holds no Patterna key. ```ts import AsyncStorage from "@react-native-async-storage/async-storage"; import "react-native-get-random-values"; // crypto.getRandomValues for newSessionId on Hermes import { AppState } from "react-native"; import { EventQueue, PatternaClient, newSessionId, withFallback } from "patterna-sdk"; const patterna = new PatternaClient({ baseUrl: `${config.apiUrl}/patterna`, headers: async () => ({ authorization: `Bearer ${(await getValidTokens())?.accessToken ?? ""}` }), timeouts: { recommendMs: 400 }, // a phone round trip to your proxy, which keeps its own 150 ms }); export const outbox = new EventQueue({ client: patterna, storage: AsyncStorage, // persist / restore across app launches maxSize: 500, onDrop: (reason, n) => track("patterna_events_dropped", { reason, n }), }); let sessionId = newSessionId(); // rotate per launch export async function onConsentLoaded(mode: "personalized" | "cohort" | "non_profiled") { await outbox.setMode(mode); // nothing is queued before this; non_profiled purges named events } AppState.addEventListener("change", (s) => { if (s === "active") sessionId = newSessionId(); else void outbox.flush(); // send before the OS suspends the app }); export async function homeArticles(fallbackIds: string[]) { return withFallback( () => patterna.recommend({ surface: "home_articles", mode: "non_profiled", session_id: sessionId, context: { locale: "nl", platform: "ios" } }), () => fallbackIds, ); } export function seen(requestId: string, itemId: string, position: number) { return outbox.enqueue({ event_id: `${requestId}:${itemId}:seen`, // idempotency key: one seen per slot type: "rec_seen", occurred_at: new Date().toISOString(), source: "sdk", session_id: sessionId, request_id: requestId, surface: "home_articles", props: { item_id: itemId, kind: "document", position }, // kind from the slate item; position from 0 }); } ``` Only report when the user allowed usage analytics. Otherwise skip `seen` and pass `context.measured: false` on the request, so the slate is left out of the ledger: see [Consent modes](https://staging.patterna.nl/docs/consent-modes#measured-lists-and-analytics-consent). The proxy sets the real identity. So the app sends `session_id` only and stays correct whatever the user's mode is. ## Offline queue `EventQueue` is a bounded outbox for events sent from the app. - **Bounded.** `maxSize` caps it. When it overflows, `onDrop(reason, n)` tells you how many events went. - **Persistent.** Pass an AsyncStorage-shaped `storage` and the queue survives app restarts. Restoring is idempotent. - **Consent-aware.** Nothing is queued until `setMode` is called. Switching to `non_profiled` purges events that name a user. - **Idempotent.** Events keep their `event_id`, so a resend after a crash is counted once. - Call `flush()` when the app goes to the background. ## Signing ```ts import { signEvent } from "patterna-sdk"; // The key is the pss_ secret string. const signed = await signEvent(event, process.env.PATTERNA_SIGNING_SECRET!); ``` The signature is lowercase hex HMAC-SHA256 over the canonical JSON of the event without `signature`: keys sorted, no whitespace, `undefined` dropped, U+2028 and U+2029 escaped. The HMAC key is the `pss_` secret string's UTF-8 bytes. Since engine 0.2 secrets are `pss_` strings; a secret from before that (64 hex characters, no prefix) no longer verifies, so rotate it once. Events from your proxy with an ingest key are trusted without a signature anyway, so most backends never sign. Sign on a backend only. On React Native without `crypto.subtle`, pass a SubtleCrypto implementation as the third argument. Test vector: [Backend API](https://staging.patterna.nl/docs/connector-backend-api#signing-events). ## Deadline, abandonment and the rest of the client The same features as the [Go SDK](https://staging.patterna.nl/docs/connector-sdk-go#deadline-abandonment-and-the-rest-of-the-client), in TypeScript shape: - `recommend` sends `X-Deadline-Ms` (120 ms by default, below the 150 ms budget) and an `X-Request-Id` it minted. - `recommendWithFallback(req, fallback)` reports a timeout, 5xx or network failure with `abandon(requestId, reason)` in the background, so the engine flags the slate `client_abandoned`; it stays in its arm and counts in the fallback rate. Call `abandon` yourself when you drop a slate that came back shown. See [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger). - `recommendMany([{ request, fallback }, …])` for several surfaces on one screen, under one deadline. - `assignmentUnit(secret, userId)` for `assignment_unit` (64 hex characters, `non_profiled` only), `context.measured`, the interaction field `block`, and the call option `{ tester: true }` (`X-Patterna-Tester: 1`). - `getItem(kind, id)` reads one item with its `attributes`, `tags`, `status` and `enrichment` (ingest key; `item_not_found` when there is none under that id and kind); `listItems`, `itemsChangedSince` and `deleteItem` for catalog reconciliation. - Admin methods on the same client, with an admin key: `listSurfaces`, `setSurfaceStatus`, `setSurfaceTesters`, `createFlow`, `planFlow`, `applyFlow`, `activateFlow`, `rollbackFlow`, `startExperiment`, `endExperiment`, `ledger`, `exportLedger`, `createSigningSecret`, `rotateSigningSecret`. The `patterna` CLI ships with the [Go SDK](https://staging.patterna.nl/docs/connector-sdk-go). ## Control lists, communities and the context fields SDK 0.4.1 types `context.control_item_ids` (your own editorial list for the page, served to the control arm) and `context.member_of` (communities the user joined, left out in every mode), and reads `fill_count`, `fill`, `control_source` and `shown` from the slate. See [Backend API](https://staging.patterna.nl/docs/connector-backend-api#serve-recommend-and-report). The engine reads three more context fields: `weekday` (0 = Monday, from the device clock), `subscription_tier` and `tenure_days`. SDK 0.4.1 types them, together with the catalog `attributes` the engine reads, builders for the new events, the flow filters and generators of engine 0.3.1, and the reason key `recommended`. What each field does: [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals). ## Left out on purpose - A circuit breaker. Backends that need one use the [Go SDK](https://staging.patterna.nl/docs/connector-sdk-go); in an app, `withFallback` and the per-call budget are enough. --- # SDK Go URL: https://staging.patterna.nl/docs/connector-sdk-go > The Go SDK for a backend request path: timeouts, circuit breaker, fallback, async reports, abandonment, the admin package, and the shape of a backend drop-in. `github.com/payam-h/patterna-sdk/go` is the Go client. It is built for a customer backend that calls Patterna inside a user request. Standard library only, Go 1.25, no transitive dependencies. ## Install ```sh go get github.com/payam-h/patterna-sdk/go@v0.4.1 ``` 0.4.1 is the current release, published from the public repository [payam-h/patterna-sdk](https://github.com/payam-h/patterna-sdk) (MIT). The module lives in its `go/` subdirectory, so the git tag is `go/v0.4.1`; `go get …@v0.4.1` resolves it through `proxy.golang.org`. Pin a version, never `latest`. It works with engine 0.3.1 (`engine_version` on `/health`). ## Client ```go pc, err := patterna.New(patterna.Config{ BaseURL: os.Getenv("PATTERNA_URL"), // https://api.patterna.nl/v1 ServeKey: os.Getenv("PATTERNA_SERVE_KEY"), IngestKey: os.Getenv("PATTERNA_INGEST_KEY"), Logger: slog.Default(), }) if err != nil { return err } defer pc.Close(context.Background()) ``` | Method | Key | Budget | Retried | |---|---|---|---| | `Recommend`, `RecommendWithFallback` | serve | 150 ms | never (each call logs an impression) | | `ReportInteractions` (async), `ReportInteractionsSync` | serve | 5 s | once | | `IngestEvents`, `UpsertItems`, `SetItemStatus`, `SetItemTags` | ingest | 5 s | once | | `SetUserMode`, `SetUserProfile`, `DeleteUser` | admin if set, else ingest | 5 s | once | The engine accepts the ingest key for the three user calls, so configure only `ServeKey` and `IngestKey` on a backend. Keys start with `pk_s_` (serve), `pk_i_` (ingest) and `pk_a_` (admin). ## Recommend with a fallback ```go rec, err := pc.RecommendWithFallback(ctx, patterna.RecommendRequest{ Surface: "home_articles", Mode: patterna.ModePersonalized, UserID: patternaID, // the random id you minted, never an email or auth0|... Context: patterna.RecommendContext{Locale: "nl", Platform: "ios"}, }, newestArticleIDs) if err != nil { return err // consent or input bug: never hidden behind the fallback } // rec.ItemIDs is always set (empty when rec.Shown is false); rec.Slate is nil when the fallback was used. if rec.Slate != nil && rec.Slate.Visible() { // a fallback list has no request id; shown:false means hide the block it := rec.Slate.Items[0] _ = pc.ReportInteractions(rec.Slate.RequestID, []patterna.Interaction{{ ItemID: it.ItemID, Kind: it.Kind, // the engine takes kind and position from the logged slate anyway Position: it.Position, // 0-based slot as served Type: patterna.InteractionSeen, }}) // fire-and-forget } ``` `newestArticleIDs` is your current query. `rec.Cause` says why it ran. `RecommendWithFallback` falls back on timeouts, network errors, an open breaker and every API error answer except three: consent violations, `pii_rejected` and `mode_conflict` come back as errors, never as a fallback, because a consent bug is a privacy bug. Read `rec.Cause` and treat the codes differently: | `rec.Cause` | Meaning | What to do | |---|---|---| | `ErrTimeout`, `ErrNetwork`, `ErrCircuitOpen`, status 5xx or 429 | Patterna could not answer | expected now and then; count it | | `surface_off` (409), `no_candidates` (422) | the surface is off, or nothing is left after the filters | expected while a surface is off; otherwise fix the catalog or the flow | | `bad_request` (400), `unauthorized`/`wrong_scope` (401), `unknown_surface` (404) | a bug in your call or configuration | log at error level and alert; the fallback hides it from users, not from you | ```go var pe *patterna.Error if rec.Fallback && errors.As(rec.Cause, &pe) && !pe.Transient() && !patterna.IsAPIError(rec.Cause, "surface_off") && !patterna.IsAPIError(rec.Cause, "no_candidates") { slog.Error("patterna call is broken", "code", pe.Code, "request_id", pe.RequestID) } ``` Log `rec.Slate.Arm` (`treatment`, `control` or `none`) next to your traces when a slate came back. ## Timeouts - Each call runs under `context.WithTimeout(ctx, budget)`. A shorter deadline from the caller wins. - The budget covers the retry too. - A deadline is `ErrTimeout`. A cancel by the caller is `ErrAborted`. Leave the serve budget at 150 ms. Patterna answers in 60 ms p99 from cache and 250 ms live; above 150 ms your own list is the better answer. The engine's own budget is 120 ms by default, and the SDK asks it to answer within `X-Deadline-Ms` (`Config.RecommendDeadline`, 120 ms by default), so its answer arrives before the SDK gives up. When the SDK gives up anyway, it reports the abandoned request id. Details: [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger). ## Retry One retry after 100 ms, on 5xx and network errors, for idempotent calls only. `Recommend` is never retried. ## Circuit breaker - One breaker per scope (`serve`, `ingest`). - It opens after 5 consecutive failures: timeouts, network errors, 5xx and 429. - While open it fails fast with `ErrCircuitOpen` for 30 s. Then one probe call decides whether it closes. - A 4xx counts as the service being up. - `OnBreakerChange(scope, open)` and `BreakerOpen(scope)` feed your metrics and health checks. ## Fire-and-forget reports `ReportInteractions` puts the report on a bounded queue (1024) served by 2 workers and returns at once. A full queue returns `ErrQueueFull` and counts in `DroppedReports()`. `Close(ctx)` drains the queue, so call it on shutdown: ```go ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) _ = pc.Close(ctx) // drains queued interaction reports cancel() ``` Use `ReportInteractionsSync` when you need the result. ## Errors Every error is `*Error{Code, Op, Status, RequestID, Message, Details}`. API errors carry the API's code (`pii_rejected`, `mode_conflict`, `no_candidates`, `surface_off`, ...); the list is in [Error codes](https://staging.patterna.nl/docs/reference-error-codes). `Transient()` is true for timeouts, network errors, an open breaker, 5xx and 429. ```go if errors.Is(err, patterna.ErrTimeout) { // budget spent } if patterna.IsAPIError(err, "pii_rejected") { // a field that may not leave your system was sent } ``` ## Consent - A request needs a mode. `non_profiled` needs a session id and no user id. - User ids that look like an email or an identity-provider subject are refused everywhere, before anything is sent, and never appear in error text. - The engine answers `422 mode_conflict` if a `non_profiled` request still carries a user id. See [Consent modes](https://staging.patterna.nl/docs/consent-modes). ## Signing `SignEvent(e, secret string)` signs with the same canonical JSON and HMAC-SHA256 as the TypeScript and Python SDKs. The key is the `pss_` secret string itself. Since engine 0.2 secrets are `pss_` strings; a secret from before that (64 hex characters, no prefix) no longer verifies, so rotate it once. Events sent with an ingest key from your backend are trusted without a signature; sign only when an event passed an untrusted hop. Test vector and rules: [Backend API](https://staging.patterna.nl/docs/connector-backend-api#signing-events). ## Deadline, abandonment and the rest of the client - **Deadline.** `Recommend` sends `X-Deadline-Ms` (`Config.RecommendDeadline`, default 120 ms, always below the 150 ms `RecommendTimeout`). The engine answers within it or serves the fallback list, so its answer arrives before the SDK gives up. - **Abandon.** The SDK mints the request id (`X-Request-Id`). When `RecommendWithFallback` falls back on a timeout, a 5xx, a network error or a bad answer, it reports the request id with `Abandon(requestID, reason)` in the background. The engine flags that slate `client_abandoned` (the answer is `200` with `request_id`, `arm`, `fallback: true`, `client_abandoned: true`; repeating it is harmless): it stays in its arm and counts in the fallback rate. Call `Abandon(id, patterna.AbandonClientFallback)` yourself when you decide not to show a slate you received; `AbandonSync` waits for the answer. - **`RecommendMany(ctx, []ManyRequest)`.** Several surfaces for one screen (for example Home articles and Home products), each with its own fallback, run concurrently under one deadline. Each slate has its own request id and impression. - **Typed API codes.** `CodeNoCandidates`, `CodeSurfaceOff`, `CodeUnknownSurface`, `CodeUnknownRequest`, `CodePIIRejected`, `CodeModeConflict`, for `IsAPIError`. - **Kind filled in.** The client remembers each slate's item kinds, so a report without `Kind` gets the served kind. The engine takes `kind` and the served position from the logged slate anyway, refuses item ids that were not in the slate (`unknown_item`), and accepts an optional `Block` for one slate rendered in two blocks. - **New request fields.** `AssignmentUnit` with the helper `patterna.AssignmentUnit(secret, userID)` (64 hex characters; `non_profiled` requests only; keeps that person in one holdout arm, see [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors)); `Context.Measured` / `SetMeasured(analyticsConsent)` (see [Consent modes](https://staging.patterna.nl/docs/consent-modes#measured-lists-and-analytics-consent)); `WithTester()` sends `X-Patterna-Tester: 1`, so your own staff roster decides who sees a surface in status `testers`. - **Catalog.** `GetItem(ctx, kind, id)` reads one item with its `Attributes`, `Tags`, `Status` and `Enrichment` (ingest key; `ErrItemNotFound` when there is none under that id and kind), `ListItems`, `ItemsChangedSince(ctx, since, kind, fn)` for a nightly reconcile, and `DeleteItem(ctx, kind, id)`. - **Signing secret.** `CreateSigningSecret` fetches the first secret on a tenant that has none. - **Admin package and CLI.** `sdk-go/admin` covers surfaces (status, testers), flows (create, activate, rollback, `ApplyFlow`), experiments (start, end), the ledger and its export, and the signing secret. The `patterna` CLI wraps it. Keep flow configs in your repository and apply them from CI with an admin key that never sits in the request path: ```go pc, _ := patterna.New(patterna.Config{BaseURL: os.Getenv("PATTERNA_URL"), AdminKey: os.Getenv("PATTERNA_ADMIN_KEY")}) adm := admin.New(pc) cfg, _ := os.ReadFile("flows/more_to_read.json") res, err := adm.ApplyFlow(ctx, "more_to_read", cfg, "weights from the September review", true) // true: activate if err != nil { return err } fmt.Println(res) ``` `ApplyFlow` creates a new version only when the config differs from the active one. The CLI does not create surfaces: do that in the panel or with `POST /surfaces`. `patterna surface apply -f surfaces/<key>.json [--dry-run]` changes an existing surface's `item_kinds`, `slots`, `min_slots`, `fallback`, `device_resort`, `notes` and `exploration_share`, patching only the fields that differ (the fallback is replaced as a whole). It never changes the status. ## Shown, control lists and the context fields - **`Slate.Visible()`** (and `rec.Shown` on `RecommendWithFallback`) is false when the slate came back `shown: false` (fewer items than the surface's `min_slots`). Hide the block and do **not** call `Abandon`: the engine has already logged the slate as not shown (`Abandon` would get `409 not_shown`). Call `Abandon` only for a slate that came back shown and that you then did not show (for example when your hydration dropped items below what the block needs). A `shown: false` slate carries no items, and it stays in its arm counted as served with zero taps. - **Control lists and communities.** `RecommendContext.ControlItemIDs` (your own editorial list for the page, served to the control arm) and `RecommendContext.MemberOf` (communities the user joined, left out in every mode). See [Backend API](https://staging.patterna.nl/docs/connector-backend-api#serve-recommend-and-report). - **Fill and source.** `Slate.FillCount`, `SlateItem.Fill` and `Slate.ControlSource` say which positions came from the editorial list and where an editorial slate came from. - **Context fields.** The engine reads `weekday` (0 = Monday, from the device clock), `subscription_tier` and `tenure_days`. SDK 0.4.1's `RecommendContext` has typed fields for them, next to typed catalog attributes, event builders, the flow filters and generators of engine 0.3.1 and the reason key `recommended`. What each field does: [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals). ## Left out on purpose - Proof and ledger reads. The panel and the admin API own them. - Generated types. `types.go` is hand-written; change it with the contract. - A metrics library. `OnBreakerChange`, `DroppedReports` and `Recommendation.Cause` give you the numbers. ## A backend drop-in, in short The shape that works for a Go backend (for example Gin + gqlgen): - **One query, one mutation.** `recommendations(surface, context)` returns slots with reason text and a request id. `reportRecommendation(requestId, interactions)` forwards seen, click and dismiss through `ReportInteractions`, each with `kind` and the 0-based `position`, plus `displayedPosition` only where the app shows an item somewhere other than its served slot. - **A feature flag.** Off means your current query. It is the instant rollback; an empty `PATTERNA_URL` is the kill switch. - **Your consent records decide the mode**, with one mapping table used everywhere: see [Consent modes](https://staging.patterna.nl/docs/consent-modes#mapping-your-consent-records-to-a-mode). A consent change calls `SetUserMode`. Reporting follows the analytics consent. - **A mapping table.** Mint a random id (`u_` + 128 random bits) per user on first use. Your identity-provider subject and the email never leave your system. Revoking consent rotates the id. See [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors). - **Hydration stays with you.** Patterna returns ids; you load the items yourself, so premium redaction, locale and status stay yours. Hide a block when hydration leaves fewer items than the block needs. - **Catalog notifier.** Upsert, archive and schedule call `UpsertItems` and `SetItemStatus`, detached, never failing the editor's action. A backfill command loads the catalog once and a nightly reconcile catches lost calls. - **Deletion eraser.** Account deletion calls `DeleteUser` first. Failures go to a pending-deletions table that holds only the random id, and a loop retries. - **Environment.** | Variable | Value | |---|---| | `PATTERNA_URL` | `https://api.patterna.nl/v1`; empty disables Patterna | | `PATTERNA_SERVE_KEY` | a serve key, `pk_s_…` | | `PATTERNA_INGEST_KEY` | an ingest key, `pk_i_…`, also used for consent mode and deletion | | `PATTERNA_TIMEOUT_MS` | `150` | --- # PostHog URL: https://staging.patterna.nl/docs/connector-posthog > Point a PostHog HTTP destination at Patterna; see which events map to what and which fields are dropped. PostHog already sees what people read and join. Point a PostHog destination at Patterna and those events become canonical events. Patterna keeps the distinct id only and drops person properties before anything is written. **Status:** the PostHog destination is built and available since engine 0.2. It stays off for a tenant until an owner sets a PostHog token; without one the webhook accepts nothing. > **Warning** Check your distinct id first. The webhook never stores a raw distinct id: an id already minted by an import (`u_…`) is kept, any other is replaced by a Patterna-minted hash under the tenant's `PATTERNA_ID_SECRET` (which Patterna holds, so Patterna could derive it again from the distinct id) or dropped. If your app calls `posthog.identify()` with your account id or your identity provider's subject (`auth0|…`), that id would reach Patterna. Use the webhook only when the distinct id is the random Patterna id (or a hash of your id that only you can map back); otherwise use the batch export with id mapping, or send the events from your backend. PostHog also does not know a user's Patterna mode: filter the destination so only users in `personalized` mode are sent (for example on a person property you set from consent). See [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors). ## Set up the destination 1. In PostHog, open **Data pipelines** and choose **New destination**. 2. Pick the **HTTP Webhook** destination. 3. Set the URL to your tenant's webhook URL: ```text https://api.patterna.nl/v1/webhooks/posthog ``` 4. Method `POST`. Add the header `X-Patterna-Token: <token>` with the token an owner set for this tenant (below). The destination never carries an API key. Leave the body as PostHog's default event payload (one event, or an array of events: `uuid`, `event`, `distinct_id`, `timestamp`, `properties`). 5. Add an event filter so only the mapped events go out: ```text content_viewed content_engaged content_scroll_depth community_joined community_left ``` 6. Save and enable the destination. 7. In Patterna, open **Connectors → PostHog** and press **Test connection**. Within a few minutes the card shows rows in and rows mapped. How it authenticates: the destination sends an `X-Patterna-Token` header. An owner sets the token (24 to 200 characters of random data) with `PATCH /settings` (`posthog_token`); Patterna stores only its SHA-256 hash. Set an empty token to switch the destination off again. Events from the webhook are stored as unsigned. The answer is `202` with `accepted`, `duplicates`, `skipped` (events with no mapping) and `dropped_fields`. Sending other events does no harm: they are counted and dropped. The filter just keeps traffic and noise down. ## What maps to what | PostHog event | Canonical event | Dropped | |---|---|---| | `content_viewed` | `item_viewed`, with `content_type` → `collection` | `content_title`, `content_slug` | | `content_engaged` | `item_engaged` | | | `content_scroll_depth` | folded into `item_engaged` | | | `community_joined` / `community_left` | same name | | | `cyclus_visited`, `cyclus_action`, `breathing_*`, `*_location_*`, `$screen` | not ingested | everything | | category pages | not ingested | everything | | person properties | not ingested | `email`, `name` | `item_engaged` counts as a positive signal only when `engaged_seconds` is 20 or more. See [Event types](https://staging.patterna.nl/docs/reference-event-types) for weights. `content_viewed` with `content_type` `community_post` becomes `community_post_viewed` with the post id only. ## What is kept and what is dropped Kept: - The distinct id, as the user id. Ids containing `@` are rejected. The webhook replaces it with a Patterna-minted hash unless it is already an import-minted `u_…` id (see the warning above); the batch export can do the same (below). See [Consent modes](https://staging.patterna.nl/docs/consent-modes) for what each mode stores. - Event name, time, item or community id. - From `$` properties: OS, app version and locale only. Dropped before the row is written: - Person properties and `$set`. - IP address. - Every other `$` property. - Titles and slugs. Every drop is counted per field. The **Connectors** card shows the counts under "Fields dropped", so you can see that the rule ran. See [Data we never store](https://staging.patterna.nl/docs/data-we-never-store). ## Alternative: batch export files If you prefer not to stream, export PostHog events to an S3 bucket or a local directory as JSONL (optionally gzipped). The jobs read those files on the connectors schedule (every 15 minutes, at the interval set for your tenant) and apply the same mapping. Parquet exports are not supported yet. The batch reader can map ids: with `id_mapping: "hmac"` on the connector, each distinct id becomes `u_` plus a keyed hash, so the raw distinct id is never written. The key sits in the jobs' environment (`PATTERNA_ID_SECRET`), which means Patterna, not you, could recompute the mapping from a known id. If that is not acceptable, hash on your side before the export, or send events from your backend with the Patterna id. ## Check it works On **Connectors → PostHog** look at: - **Last run** and **status**. - **Rows in** and **rows mapped.** Rows in minus rows mapped is what was not ingested on purpose (for example `$screen`). - **Fields dropped**, with counts per field. - **Errors**, with the request id. Then open **Audiences → trace** for a test user and confirm the events arrive. --- # Google Analytics 4 URL: https://staging.patterna.nl/docs/connector-ga4 > Read GA4 events from the BigQuery export with a viewer service account under the same privacy rules as PostHog. Patterna reads GA4 data from the BigQuery export, never from the GA4 API. You link GA4 to BigQuery, give a service account read access to the export dataset, and the jobs read new days on a schedule. ## 1. Link GA4 to BigQuery 1. In GA4, open **Admin → Product links → BigQuery links** and choose **Link**. 2. Pick the Google Cloud project that will hold the export. 3. Choose a data location in the EU. 4. Turn on the **Daily** export. Patterna does not read the intraday (streaming) table yet. 5. Submit. The first daily table, `events_YYYYMMDD`, appears in a dataset named `analytics_<property id>` within about a day. ## 2. Give the reader access Grant Patterna's reader service account read access. Its address is shown on **Connectors → Google Analytics 4**. 1. In Google Cloud, open **BigQuery**, select the `analytics_<property id>` dataset and choose **Sharing → Permissions**. 2. Add the service account with the role **BigQuery Data Viewer**. This lets it read the tables and nothing else. 3. To run queries, the service account also needs **BigQuery Job User** on the project you name in the connector. The query jobs run in that project, so it carries the (small) query cost. Patterna supplies the service account; the jobs run with its credentials. You never upload a key. ## 3. Turn on the connector In **Connectors → Google Analytics 4**, fill in the project and dataset the card asks for and press **Test connection**. The connector runs on the connectors schedule: the scheduler checks every 15 minutes and runs it when your tenant's interval has passed. ## What the reader does - Reads new daily tables with a query, row by row through the same mapping rules as [PostHog](https://staging.patterna.nl/docs/connector-posthog). - `user_pseudo_id` is a device id. It is used only as an HMAC input to derive session and event ids. It is never stored. - GA4's `user_id`, when you set one, becomes the Patterna user id. Set it to your random Patterna id, never your account id; or turn on `id_mapping: "hmac"` on the connector so it is replaced by a Patterna-minted keyed hash (under `PATTERNA_ID_SECRET`, which Patterna holds) before it is written. See [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors). - Event ids are derived from the source, so re-reading a day does not double count. Ingest is idempotent on `event_id`. - Users who are `non_profiled` get a per-day rotating `session_id` instead of a user id. - Each run writes a row with rows in, rows mapped and per-field drop counts. ## What is dropped - Every user property and device field except OS, app version and locale. - IP-derived geography and any coordinates. - Free text such as page titles and search terms. - Event types without a mapping. The rules are the same as for PostHog: see [Data we never store](https://staging.patterna.nl/docs/data-we-never-store). ## Check it works **Connectors → Google Analytics 4** shows the last run, rows in, rows mapped, fields dropped and errors. A failing run shows its error and the request id. --- # Shopify URL: https://staging.patterna.nl/docs/connector-shopify > Bring Shopify products in from the public storefront feed or a read-only Admin API token, order history as co-purchase aggregates, HMAC-verified webhooks, and the shop link parameter. Shopify gives Patterna two things: your products as catalog items of kind `product`, and orders as `order_placed` outcomes. There are two ways in. Without any token, the storefront sync reads your shop's public product feed and an orders export gives co-purchase pairs (next section). With a read-only Admin API token, Patterna reads products and orders on a schedule, and webhooks for changes can go straight to Patterna or through your backend (sections 1 and 2). Neither way selects a customer field. ## Without a token: the storefront feed and an orders export **Products.** Every Shopify store publishes `https://<your-shop>/products.json`. The storefront sync reads it page by page and upserts each product: the Shopify product id as `external_id` (the same key the Admin connector uses, so switching later creates no duplicates), the product type as collection, the shop's tags, the description as text for tagging, and the attributes `price_cents`, `currency`, `compare_at_cents` (on sale), `available` (any variant in stock), `image_url`, `url`, `handle`, `product_type` and `format: "product"`. A product that left the feed is archived, not deleted. The sync runs on a schedule once the connector is configured for your tenant, and you can start one at any time from **Connectors → Shopify storefront → Sync now** (the panel queues it; the worker picks it up within a minute and shows the result: products read, products archived). The schedule is a tenant setting we set for you: `settings.connectors.shopify_storefront = {"enabled": true, "shop_url": "https://your-shop.example", "every_minutes": 60}`. The worker runs the feed every `every_minutes` (hourly by default) and writes each run under its own source, `shopify_storefront`, so **Connectors** shows the storefront's last run separately from the webhook's. A failed run raises a `connector_failure` alert (email or Slack, from Settings → Alerts) and appears in the notification centre. Without the setting the feed is read only when you press Sync now or when we run it by hand: ```sh python -m patterna_jobs shopify-storefront --tenant your-tenant --shop-url https://your-shop.example ``` The feed is capped at 10,000 products; a larger shop uses the Admin token. **Order history.** Export your orders as CSV from Shopify admin (Orders → Export) and hand us the file. The reader keeps three columns (order name, line-item name, SKU), matches line items to products by SKU and then by title, and writes product pairs bought together with their lift, for pairs in at least two orders. Names, emails and addresses in the export are never read, and the file is not copied. The pairs feed each product's neighbours ("bought together"); they say nothing about any one customer. ```sh python -m patterna_jobs orders-csv --tenant your-tenant --csv orders_export.csv ``` It prints the order count, the multi-product orders, the pairs and the line items it could not match. New orders after the export still need `order_placed` events (section 2) to count in `purchase_rate` and the repurchase window. ## 1. Create an Admin API token 1. In Shopify admin, open **Settings → Apps and sales channels → Develop apps** and create an app named `Patterna`. 2. Under **Configuration → Admin API integration**, select exactly these scopes: ```text read_products read_orders ``` 3. Install the app and copy the **Admin API access token**. Shopify shows it once. 4. Hand it to us through your secret channel, together with your shop domain (`your-shop.myshopify.com`). Today the token lives in the jobs worker's environment, named in the connector's config (`token_env`); it is not entered in the panel and never stored in the database. The jobs use the token to read products and orders through the Admin GraphQL API. The order query selects no customer, address, email or note fields. ## 2. Add the webhooks The endpoint for the two webhooks, both in JSON: ```text https://api.patterna.nl/v1/webhooks/shopify/your-tenant ``` | Topic | What Patterna does | |---|---| | `orders/create` | writes an `order_placed` event | | `products/update` | upserts the product as an item of kind `product` | You can create them under **Settings → Notifications → Webhooks**, or from the app. Shopify signs each delivery with an `X-Shopify-Hmac-Sha256` header. The tenant is in the path; there is no Bearer key. Each delivery is authenticated by its HMAC alone, under the `shopify_webhook_secret` an owner sets with `PATCH /settings`. Use the secret Shopify shows for the webhooks you created (admin-made and app-made webhooks have different secrets). - No secret set: every delivery is refused with `401 webhook_not_configured`. - Wrong HMAC: `401 bad_signature`. You can also receive `orders/create` in **your own backend** instead: verify Shopify's HMAC there, map the Shopify customer to your random Patterna id (or leave the user out), and send `order_placed` with `POST /events` and your ingest key. That path keeps the customer mapping on your side. Product updates also come in through the scheduled product read, or your own catalog sync with `PUT /catalog/items`. ## What becomes what **Products → items.** Kind `product`, with the editorial title, status and attributes such as `price_cents`, `currency`, `categories[]`, `image_url` and `url`. Products are tagged into your taxonomy like any other item. **Orders → `order_placed`.** | Field | Value | |---|---| | `order_id_hash` | a hash of the Shopify order id, never the id itself | | `product_ids[]` | the products in the order | | `value_cents` | order value in cents | | `currency` | ISO currency code | An order is linked to a user or session only through the `patterna_uid` or `patterna_sid` cart attribute, or by your backend when it forwards the order. Without one, the order is stored without a user: it still feeds the co-purchase pairs and the popularity prior, not any one person's profile. The ledger computes no revenue figure today; its product metric is `purchase_rate` (an order containing a shown product within 7 days of a tap). See [Proof and the ledger](https://staging.patterna.nl/docs/proof-and-the-ledger). ## The shop link parameter When your app links from a recommendation to a product page in the shop, add one tracking parameter to the link, `ref=<request_id>`: the request id of the slate, so the visit becomes a `product_viewed` event with `product_id` and `request_id`. It carries nothing else. Never put a user id in the link: not the Patterna id, not an email, an account id or a Shopify customer id. A shop URL is public (shared, logged, cached), and a pseudonymous id in it would travel with it. Linking an order to a person happens on your side, not in the link: your backend forwards the order with the Patterna id or session id, or your storefront writes the `patterna_uid` or `patterna_sid` cart attribute server-side from its own session, never from a URL parameter. The order reader looks only at those cart attributes. Until one of the two is in place, orders arrive without a user link, and forwarding orders from your backend is the simpler path. See [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors). ## What is never read - Customers, emails, names, phone numbers, addresses. - Order notes and any free text. - The raw Shopify order id (only its hash is stored). ## Check it works **Connectors → Shopify** shows the last run, rows in, rows mapped, fields dropped and errors; **Shopify storefront** shows the feed's own runs and the pending Sync now request. Place a test order and confirm one `order_placed` row appears. --- # Quiz CSV URL: https://staging.patterna.nl/docs/connector-quiz-csv > Map quiz answers to taxonomy tags by random user id with a TOML mapping; only mapped columns are read, never health values. If your onboarding quiz asks people about their goals, activity or stress, those answers can shape recommendations from day one. You export them as a CSV and write a small mapping from answers to tags in your taxonomy. The importer reads only the id column and the columns you map. Every other column is ignored and counted as dropped, so a free-text column in the export can never reach Patterna. ## What the file needs - **One id column** with the random per-tenant user id your backend minted for Patterna. Not an email, not your account id, not an identity-provider id. - **The answer columns** you want to use. One row per user. A cell may hold several answers separated by `;`. ```csv patterna_uid,main_goal,activity,notes u_mfrggzdfmztwq2lknnwg23tpobyxe,Beter slapen;Meer energie,moderate,free text that is never read u_k5xwg3dpmvzgk3lomnqxg5dbmzsw4,Minder stress,low, ``` ## The mapping file The mapping is a TOML file: the id column, and per answer column the tags each answer stands for, with a weight. ```toml id_column = "patterna_uid" multi_separator = ";" [columns.main_goal] "Beter slapen" = { sleep = 1.0 } "Meer energie" = { energy = 1.0 } "Minder stress" = { stress = 1.0, recovery = 0.3 } [columns.activity] "moderate" = { movement = 0.5 } ``` - Answers are matched case-insensitively after trimming. - Every tag must exist in your taxonomy. A mapping with an unknown tag stops the run before anything is written; add the tag in **Catalog → Taxonomy** first. - Never map a health value: no cycle data, symptoms, weight, diagnoses or anything a person typed in their own words. See [Data we never store](https://staging.patterna.nl/docs/data-we-never-store). ## Run it ```sh python -m patterna_jobs quiz-csv --tenant your-tenant \ --csv quiz_export.csv --mapping quiz_mapping.toml --out quiz_affinities.csv ``` The run writes the tag affinities per id to `--out` (for your own records) and merges them into the users Patterna already has. `--no-write` only writes the output file, which is the way to check a mapping first. ## Consent basis Quiz answers are attributes the person declared, not behaviour Patterna observed. What happens per mode: - **personalized**: the answers are merged into the user's tag affinities and rank together with their behaviour. - **cohort**: cohort ranking reads only the goals sent with `PUT /users/{user_id}/profile`, not these affinities. For cohort users, send the quiz goals through the profile call from your backend instead. - **non_profiled**: there is no user row in Patterna, so there is nothing to attach answers to. The row is skipped and counted as dropped (`user.no_profile_row`). You remain responsible for having a basis to share the answers. Record it on your side with the consent you already ask for. See [Consent modes](https://staging.patterna.nl/docs/consent-modes). ## Check it works **Connectors → Quiz CSV** shows rows in, rows mapped, columns dropped with counts, and errors. Open **Audiences → trace** for one id from the file to see the merged tag affinities. --- # API overview URL: https://staging.patterna.nl/docs/api-overview > Base URL, scopes, error codes, idempotency, rate limits and request ids for the Patterna API. The Patterna API is JSON over HTTPS. Every path below is relative to `https://api.patterna.nl/v1`. The full, rendered contract lives in the [API reference](https://staging.patterna.nl/docs/api). This page explains the rules that hold for every call. The OpenAPI file is binding; if a page disagrees, the page is wrong. ## Authentication Every request carries a scoped key as a Bearer token. The tenant comes from the key, never from the body. ```curl curl -sS https://api.patterna.nl/v1/surfaces \ -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" ``` The prefix tells you the scope: `pk_s_` for serve, `pk_i_` for ingest, `pk_a_` for admin. Patterna stores only a SHA-256 hash of each key. The secret is shown once, when you create it. Lost a secret? Revoke the key and create a new one. See [Rotate keys and the signing secret](https://staging.patterna.nl/docs/rotate-keys-and-signing-secret). ## Scopes There are three key scopes. Give each system the smallest scope it needs. An admin key passes every scope; a serve or ingest key used on another scope's path gets `401 wrong_scope`. | Scope | Prefix | Paths | Who holds it | |---|---|---|---| | `serve` | `pk_s_` | `POST /recommendations`, `POST /interactions` | your backend, in the request path | | `ingest` | `pk_i_` | `POST /events`, `/catalog/items` and below, `PUT /users/{user_id}/mode`, `PUT /users/{user_id}/profile`, `DELETE /users/{user_id}` | your backend, connectors, catalog sync | | `admin` | `pk_a_` | `GET /users/{user_id}`, `/users/{user_id}/trace`, `/surfaces` and below, `/experiments`, `/proof/*`, `/connectors`, `/keys` and below, `/audit`, `/settings` | the panel, operator scripts | | none | | `GET /health` | anyone | | own authentication | | `POST /webhooks/posthog` (`X-Patterna-Token`), `POST /webhooks/shopify/{tenant}` (Shopify HMAC) | PostHog, Shopify | The contract groups endpoints under five tags (`serve`, `ingest`, `catalog`, `proof`, `admin`). The tags are for reading. Keys have three scopes: catalog calls use an `ingest` key, proof calls use an `admin` key. The user endpoints that change state (`mode`, `profile`, `DELETE`) take an ingest key, because the customer's backend owns consent and deletion. An admin key works there too. Reading a user (`GET /users/{user_id}`, the trace) needs an admin key. Per-scope pages: [Serve](https://staging.patterna.nl/docs/api-serve), [Ingest](https://staging.patterna.nl/docs/api-ingest), [Admin](https://staging.patterna.nl/docs/api-admin). ## Responses and errors | Status | Meaning | |---|---| | `200` | done, body follows | | `201` | created (surface, flow version, experiment, key) | | `202` | accepted (events, interactions, webhooks, deletion, export). A deletion is already done when you get the 202; see [Admin](https://staging.patterna.nl/docs/api-admin). | | `204` | done, no body (key revoked) | | `400` | `bad_request`: a missing or invalid field, **any unknown JSON field**, an empty body or a body over 2 MiB | | `401` | `unauthorized` (missing or revoked key) or `wrong_scope` (key of another scope); webhooks: `webhook_not_configured`, `bad_signature` | | `403` | `forbidden_role` (a panel session whose role may not do this), `forbidden_scope` (an admin key minting an admin key) | | `404` | `not_found`, `unknown_surface`, `unknown_request`, `unknown_flow_version`, `item_not_found`. An unknown route also answers `404 not_found` in the error body below. | | `409` | `surface_off`, `experiment_running`, `deletion_scheduled`, `exists` | | `422` | a valid request the engine cannot honour: `mode_conflict`, `no_candidates`, `pii_rejected`, `unknown_item`, `displayed_position_required`, `bad_tester_id` | | `429` | `rate_limited`, with `Retry-After: 1`; `quota_exceeded` when the plan's monthly serve requests are used (`details.quota`, `used`, `resets_at`) | | `500` | `internal`; quote the `request_id` | The body is decoded strictly: a field the contract does not define is a `400`, not ignored. Upgrade the engine before you send a field from a newer contract. Every error has the same body: ```json { "code": "mode_conflict", "message": "non_profiled requests must not carry a user_id", "request_id": "6f1d0c2e-1d3b-4c51-9a55-3a8f0b8c2d11", "details": {} } ``` - `code` is stable. Branch on it, never on `message`. - `message` is for people and may change. - `request_id` matches the `X-Request-Id` header. - `details` is optional and depends on the code. All known codes, with what to do about each, are in [Error codes](https://staging.patterna.nl/docs/reference-error-codes). ## Idempotency There is no `Idempotency-Key` header. Each write has its own natural key. - **Events.** `event_id` is unique per tenant. Sending the same `event_id` again is counted under `duplicates` and stored once. Derive it from your source id, for example `{request_id}:{item_id}:seen`. - **Catalog.** `PUT /catalog/items` upserts by `external_id` and `kind`. Rerunning a backfill is safe. - **Interactions.** One row per request id, item and type; a repeat is ignored. The SDKs retry once on 5xx and network errors. - **Recommendations.** Never retry `POST /recommendations`. Every call logs an impression with propensities, so a retry is a second impression. On a timeout, show your own list and report the abandonment (the SDKs do it; see [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger)). ## Rate limits The serve and ingest scopes have a token bucket per key: | Scope | Sustained | Burst | |---|---|---| | `serve` | 200 requests per second | 400 | | `ingest` | 50 requests per second | 100 | | `admin` | no limit | | When a bucket is empty you get `429 rate_limited` with `Retry-After: 1`. Responses carry no other rate-limit headers. There is no per-user limit in the engine today; a batch of events counts as one request. Need more? Ask; the numbers are per deployment. ## Monthly quota A plan can carry a monthly request quota (`request_quota_monthly`). Every `POST /recommendations` sent with a key counts, in every mode and arm, shadow included. At 90% of the quota the tenant gets one `quota_warning` notification for that month (Settings shows the count next to the plan). Past it the engine answers `429 quota_exceeded` on `POST /recommendations` until the 1st of the next month, 00:00 UTC; `details` carries `quota`, `used` and `resets_at`. Nothing else is blocked: interactions, events and the catalog keep working. What your client does: show your own list, as for any 429, and do not retry the call; the SDKs' fallback does this. The count follows the key-usage flush, so with several engine instances it can lag by about ten seconds. Your quota is on Settings → Plan; ask us to raise it. ## Request ids Every response carries `X-Request-Id`, errors included. Send your own (up to 64 characters) and the engine uses it; otherwise it mints one. Log it next to your own trace id. Quote it when you ask for help: it finds the exact request in the engine logs. A slate also has a `request_id` in its body. That is the id you echo back in `/interactions`. Send a UUID you mint as `X-Request-Id` on `POST /recommendations` and it becomes the slate's `request_id`, so your client knows the id before the answer arrives and can report an abandoned request. The SDKs do this. ## Latency budgets - `POST /recommendations`: p99 60 ms from cache, 250 ms live, measured at the gateway. The engine gives up at its own budget (`SERVE_BUDGET`, 120 ms by default) and answers the fallback list, flagged. - Your client: give up after 150 ms and show your own list. The SDKs default to this. `Recommend` is never retried. - Send what is left of your budget as the header `X-Deadline-Ms` or the body field `deadline_ms` (10 to 10000; when both are sent the shorter wins). The engine answers within its own budget or the deadline minus a tenth for the network, whichever is shorter. The SDKs send 120 ms inside their 150 ms timeout. See [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger). - Other calls: the SDKs use 5 s per attempt. ## Versions Two numbers, one scheme. **Engine 0.3.3** is what `GET https://api.patterna.nl/v1/health` reports as `engine_version`; it is also the version of the OpenAPI contract, so the engine and the contract always carry the same number (`release` next to it is the build). **SDK 0.4.1** is the current release of the Go, TypeScript and Python clients; the install lines are on the SDK pages. The OpenAPI file is binding; if a page disagrees, the page is wrong. The [Changelog](https://staging.patterna.nl/docs/changelog) says what each engine version changed; "round" there is the internal name of a release and always has the engine version beside it. Everything on these pages works on the engine in production; a "since engine 0.3" marker only says when a feature arrived. See [Status](https://staging.patterna.nl/docs/status). --- # Serve scope URL: https://staging.patterna.nl/docs/api-serve > Annotated requests and responses for recommendations and interactions under the serve scope. The `serve` scope has two calls. Your backend asks for a slate with `POST /recommendations`. It reports what happened to that slate with `POST /interactions`, using the `request_id` from the slate. Use a `serve` key. Keep it on your backend; never ship it in an app. ## Setup for the code examples Every example below comes in four tabs. The TypeScript and Go tabs use the SDKs for serve and ingest calls. The SDKs have admin methods too (`applyFlow`, `setSurfaceStatus`, `ledger` and so on), but these tabs call the admin REST API through the small `rest` helper defined here so every request is visible. The Python tab calls the REST API with httpx; the Python SDK in `packages/sdk-py` has the same shape as the other two. The Go snippets run inside a function that has `ctx context.Context` and `pc *patterna.Client` from `newClient()` and returns an `error`; add `time` to the imports where a snippet uses it. ```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") ``` ## POST /recommendations Ranks a slate for one surface. The impression is logged with per-position propensities before the response returns. ### Personalized request ```curl curl -sS -X POST https://api.patterna.nl/v1/recommendations \ -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \ -H "Content-Type: application/json" \ -d '{ "surface": "home_articles", "mode": "personalized", "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe", "context": { "locale": "nl", "app_version": "3.4.0", "platform": "ios", "hour_bucket": 8, "premium": false }, "slots": 6 }' ``` ```ts const slate = await patterna.recommend({ surface: "home_articles", mode: "personalized", user_id: "u_mfrggzdfmztwq2lknnwg23tpobyxe", context: { locale: "nl", app_version: "3.4.0", platform: "ios", hour_bucket: 8, premium: false }, slots: 6, }); // Never retried: each call logs an impression. Budget 150 ms. console.log(slate.request_id, slate.items.map((i) => i.item_id)); ``` ```go hour, premium := 8, false slate, err := pc.Recommend(ctx, patterna.RecommendRequest{ Surface: "home_articles", Mode: patterna.ModePersonalized, UserID: "u_mfrggzdfmztwq2lknnwg23tpobyxe", Context: patterna.RecommendContext{ Locale: "nl", AppVersion: "3.4.0", Platform: "ios", HourBucket: &hour, Premium: &premium, }, Slots: 6, }) if err != nil { return err // in a request path use RecommendWithFallback instead } fmt.Println(slate.RequestID, slate.ItemIDs()) ``` ```python r = serve.post( "/recommendations", json={ "surface": "home_articles", "mode": "personalized", "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe", "context": { "locale": "nl", "app_version": "3.4.0", "platform": "ios", "hour_bucket": 8, "premium": False, }, "slots": 6, }, timeout=0.15, # never retry: each call logs an impression ) r.raise_for_status() slate = r.json() print(slate["request_id"], [i["item_id"] for i in slate["items"]]) ``` - `surface`: the surface key you created in the panel. - `mode`: `personalized`, `cohort` or `non_profiled`. Required. See [Consent modes](https://staging.patterna.nl/docs/consent-modes). - `user_id`: the random per-tenant id you minted. Never an email or an identity-provider subject. - `context.locale`: required. Everything else in `context` is optional. - `context.hour_bucket`: 0 to 23, and `context.weekday`: 0 (Monday) to 6, both from the device clock. - `context.subscription_tier` (`free`, `premium` or your own tier; with `free` the paywall cooldown applies) and `context.tenure_days` (days since the account was created; send it rounded to 0, 7 or 90). Bad values answer `400`. - `context.region`: one of the region names you configured, never a location. What every context field does: [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals). - `slots`: 1 to 50. ### Non-profiled request In `non_profiled` mode `user_id` must be absent (a `user_id` here is `422 mode_conflict`) and `session_id` is required. ```curl curl -sS -X POST https://api.patterna.nl/v1/recommendations \ -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \ -H "Content-Type: application/json" \ -d '{ "surface": "more_to_read", "mode": "non_profiled", "session_id": "s_k3j2h4g5f6d7s8a9", "context": { "locale": "nl", "platform": "android", "anchor_item_id": "doc_1842" }, "exclude_item_ids": ["doc_1842"] }' ``` ```ts const slate = await patterna.recommend({ surface: "more_to_read", mode: "non_profiled", session_id: "s_k3j2h4g5f6d7s8a9", // no user_id in this mode: the SDK refuses one context: { locale: "nl", platform: "android", anchor_item_id: "doc_1842" }, exclude_item_ids: ["doc_1842"], }); ``` ```go slate, err := pc.Recommend(ctx, patterna.RecommendRequest{ Surface: "more_to_read", Mode: patterna.ModeNonProfiled, SessionID: "s_k3j2h4g5f6d7s8a9", // no UserID in this mode: the SDK refuses one Context: patterna.RecommendContext{ Locale: "nl", Platform: "android", AnchorItemID: "doc_1842", }, ExcludeItemIDs: []string{"doc_1842"}, }) if err != nil { return err } fmt.Println(slate.RequestID) ``` ```python r = serve.post( "/recommendations", json={ "surface": "more_to_read", "mode": "non_profiled", "session_id": "s_k3j2h4g5f6d7s8a9", # no user_id in this mode "context": {"locale": "nl", "platform": "android", "anchor_item_id": "doc_1842"}, "exclude_item_ids": ["doc_1842"], }, timeout=0.15, ) r.raise_for_status() slate = r.json() ``` - `session_id`: a rotating id. It is the only identifier in this mode. - `context.anchor_item_id`: the item the user is reading, for item-to-item surfaces such as More to read. - `exclude_item_ids`: up to 200 ids that must not appear. Other optional fields: - `context.candidate_kinds`: limit the item kinds for this request. - `context.premium`: whether the user has premium; overrides the stored profile for premium filters. - `shadow: true`: rank and log, but mark as not shown (arm `none`). The panel preview uses it. - `assignment_unit`: exactly 64 hex characters, the hex HMAC-SHA256 of your own user id under a key you hold. Used only in `non_profiled` mode (ignored otherwise), only for the holdout arm and the ledger's unit, so one person keeps one arm across rotating session ids. See [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors). - `context.anchor_item_id` is looked up in the whole catalog, whatever its kind, so an article can anchor a product surface. A slot says `pairs_with` only where it is true: a product under a recipe (a document in a collection whose name contains recipe, recept, maaltijd or meal) or under a workout (workout, training, oefening, exercise), or a document under a community; each pairing has its own default text ("Pairs with this recipe", "Pairs with this workout", "Read in this community"). Any other cross-kind anchor, such as a product under an ordinary article, says `more_about_tag`. See [Reasons](https://staging.patterna.nl/docs/reasons). - `context.measured`: default `true`. Send `false` when the user did not allow usage analytics, so your app will not report seen or taps. The slate is served as usual but left out of the ledger, the power curve and learning, in both arms. - `context.control_item_ids` (up to 50): your own editorial list for this page, in order (for example an article's pairs-with products, or the editor's related articles). No surface setting is needed. The control arm serves it as its slate (`control_source: request`), with the anchor, `exclude_item_ids`, joined communities, candidate kinds, premium and locale applied; newest items that pass fill the rest. Without it, control serves the surface's `fallback.by_anchor` list for the anchor, else the surface's own list. The treatment arm uses it only to fill a short slate. Send it on every request to the surface, in both arms: you do not know the arm in advance. - `context.member_of` (up to 500): the community ids the user already joined, for requests without an identity (`non_profiled`). The engine leaves them out of the slate in every mode; in `personalized` and `cohort` mode membership also comes from `community_joined` / `community_left` events. Used for this request only, not stored. - `deadline_ms` in the body, or the header `X-Deadline-Ms` (10 to 10000; the shorter wins): what is left of your client budget. See [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger). - Header `X-Patterna-Tester: 1`: this user is on your own tester roster. A surface in status `testers` serves the ranked list to the request, so Patterna ids never need to be known. Any other value is `422 bad_tester_id`. - A UUID you mint in `X-Request-Id` becomes the slate's `request_id`. Locale: `context.locale` is matched on its base language: `nl-NL`, `nl_nl` and `nl` all mean `nl`, against every locale an item carries. When no published item of the surface's kinds has that language, the engine serves from every locale rather than nothing. ### Response 200 ```json { "request_id": "0b6e9f62-5c1e-4f0a-9f43-2d7c2e9b8a10", "surface": "home_articles", "arm": "treatment", "policy_version": "linear-2026-10-13", "flow_version": 3, "fallback": false, "latency_ms": 41, "items": [ { "item_id": "doc_1842", "kind": "document", "position": 0, "reason_key": "matches_goals", "reason": { "nl": "Past bij je doelen", "en": "Matches your goals" }, "generator": "content_affinity", "score": 0.82, "phase_fit": ["luteal"] } ] } ``` - `request_id`: echo it in `/interactions`. - `arm`: `treatment`, `control` (the holdout) or `none` (no running experiment, a shadow request or the testers gate). Render both arms the same way. - `policy_version` and `flow_version`: what produced this slate. The control arm's slates carry `policy_version: "editorial"`. - `shown`: `false` when the slate is shorter than the surface's `min_slots`. Hide the block and do not call abandon: the engine has logged it as not shown. Such a slate comes back with no items (`items: []`), is logged `not_shown` and counts in its arm with no success (intention to treat), in both arms. Abandon on it answers `409 not_shown`. - `fill_count`: how many trailing positions were filled from the editorial list (the list control would serve) because the ranked candidates ran out; absent when 0. Each filled slot is logged with `fill: true` and propensity 1 (see the trace). Control is padded with newest items the same way. - `control_source`: on editorial slates only (control, and the fallback list), where the list came from: `request` (`context.control_item_ids`), `by_anchor` (`fallback.by_anchor`) or `surface`. Absent on ranked slates. - `fallback`: `true` only when the safety net answered: the engine ran out of budget, hit storage trouble, or the surface has no active flow. A control slate is the editorial list and is **not** a fallback (`fallback: false`). See [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger). - `item_id`: your own external id. Hydrate it on your side (title, premium redaction, locale, status). - `position`: the slot Patterna served. Positions start at 0: `items[0]` has position `0`. Report this number back, even if you re-sort. - `reason_key` and `reason`: the template key and the sentence per locale. Overrides are keyed by reason key; see [Reason keys](https://staging.patterna.nl/docs/reference-reason-keys). - `phase_fit`: tags an app may use to re-sort on the device. Never used on the server. Sent only when the tenant turned `phase_tags` on in Settings; off by default. ### Errors | Status | Code | When | What your backend does | |---|---|---|---| | `400` | `bad_request` | missing `surface`, `mode` or `context.locale`; `slots` outside 1 to 50; more than 200 `exclude_item_ids`; an unknown field | fix the request; do not hide it behind a fallback | | `401` | `unauthorized`, `wrong_scope` | missing, revoked or non-serve key | fix the configuration | | `404` | `unknown_surface` | no surface with that key | fix the surface key | | `409` | `surface_off` | the surface's status is `off` | show your own list; this is expected while a surface is off | | `422` | `mode_conflict` | a `user_id` in `non_profiled` mode | a consent bug: fix it, never retry with the id | | `422` | `bad_tester_id` | `X-Patterna-Tester` with a value other than `1` | send `1` or leave the header out | | `422` | `no_candidates` | no published item of the surface's kinds and locale survives the filters | show your own list and check the catalog sync and filters | | `429` | `rate_limited` | the key's bucket is empty | show your own list | | `5xx`, timeout | `internal` | engine or network trouble | show your own list | The Go SDK's `RecommendWithFallback` returns an error only for bugs it catches before sending (a consent violation, invalid input, a missing key). Every other failure, including a `4xx` from the engine, falls back and leaves the error in `Cause`. Log `Cause` and alert on `400`, `401`, `404` and `mode_conflict`, so a bug does not hide behind your own list. ### Rules - Never retry. Each call logs an impression. - Give up after 150 ms and show your own list. Do not report interactions for your own list; report the abandoned request instead (below). The SDKs do this. - A surface under an experiment must not be re-sorted on the device, unless the surface has `device_resort: true` (set with `PATCH /surfaces/{key}`). Then every interaction must carry `displayed_position`, or it is refused with `422 displayed_position_required`. ### One slate, several blocks When one screen shows recommendations in two places, for example one product at the top of an article and a carousel of six at the end, ask for one slate of seven and split it: position 0 goes to the top block, positions 1 to 6 to the carousel. Keep reporting the slate positions. One request keeps the items unique across both blocks and logs one impression with exact propensities. Send `block` (for example `top` or `end`) with each interaction so the trace shows where it happened; the ledger can also give a row per block (`by=block`). Set `min_slots` on the surface to the smallest slate worth showing (usually 1): a slate shorter than that comes back with `shown: false` and you hide the block. If the end block has nothing at positions 1 to 6, hide only that block. ## POST /recommendations/{request_id}/abandon Tells Patterna your client gave up on a request and showed its own list. Send it after a timeout when you know the request id (you sent it as `X-Request-Id`), or when your app hid a block it received (for example hydration dropped items below what you show). Never for a slate answered with `shown: false`: that answers `409 not_shown`. No body. Idempotent. ```curl curl -sS -X POST https://api.patterna.nl/v1/recommendations/0b6e9f62-5c1e-4f0a-9f43-2d7c2e9b8a10/abandon \ -H "Authorization: Bearer $PATTERNA_SERVE_KEY" ``` Response `200`: ```json { "request_id": "0b6e9f62-5c1e-4f0a-9f43-2d7c2e9b8a10", "arm": "treatment", "fallback": true, "client_abandoned": true } ``` The impression is flagged `fallback: true, client_abandoned: true`. It stays in its arm (intention to treat) and counts in the fallback rate. `RecommendWithFallback` reports it for you (`Abandon` / `AbandonSync` in Go). Errors: `401`, `404 unknown_request`. ## POST /interactions Reports seen, click and dismiss for one slate. Up to 50 interactions per call. ```curl curl -sS -X POST https://api.patterna.nl/v1/interactions \ -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \ -H "Content-Type: application/json" \ -d '{ "request_id": "0b6e9f62-5c1e-4f0a-9f43-2d7c2e9b8a10", "interactions": [ { "item_id": "doc_1842", "kind": "document", "position": 0, "type": "seen", "occurred_at": "2026-10-13T08:12:03Z" }, { "item_id": "doc_1842", "kind": "document", "position": 0, "type": "click", "occurred_at": "2026-10-13T08:12:09Z" }, { "item_id": "doc_0977", "kind": "document", "position": 2, "type": "dismiss", "occurred_at": "2026-10-13T08:12:15Z" } ] }' ``` ```ts await patterna.reportInteractions("0b6e9f62-5c1e-4f0a-9f43-2d7c2e9b8a10", [ { item_id: "doc_1842", kind: "document", position: 0, type: "seen", occurred_at: "2026-10-13T08:12:03Z" }, { item_id: "doc_1842", kind: "document", position: 0, type: "click", occurred_at: "2026-10-13T08:12:09Z" }, { item_id: "doc_0977", kind: "document", position: 2, type: "dismiss", occurred_at: "2026-10-13T08:12:15Z" }, ]); ``` ```go at := func(s string) time.Time { t, _ := time.Parse(time.RFC3339, s); return t } // ReportInteractions (no Sync) queues the report and returns at once; use it in a request path. err := pc.ReportInteractionsSync(ctx, "0b6e9f62-5c1e-4f0a-9f43-2d7c2e9b8a10", []patterna.Interaction{ {ItemID: "doc_1842", Kind: "document", Position: 0, Type: patterna.InteractionSeen, OccurredAt: at("2026-10-13T08:12:03Z")}, {ItemID: "doc_1842", Kind: "document", Position: 0, Type: patterna.InteractionClick, OccurredAt: at("2026-10-13T08:12:09Z")}, {ItemID: "doc_0977", Kind: "document", Position: 2, Type: patterna.InteractionDismiss, OccurredAt: at("2026-10-13T08:12:15Z")}, }) if err != nil { return err } ``` ```python r = serve.post( "/interactions", json={ "request_id": "0b6e9f62-5c1e-4f0a-9f43-2d7c2e9b8a10", "interactions": [ {"item_id": "doc_1842", "kind": "document", "position": 0, "type": "seen", "occurred_at": "2026-10-13T08:12:03Z"}, {"item_id": "doc_1842", "kind": "document", "position": 0, "type": "click", "occurred_at": "2026-10-13T08:12:09Z"}, {"item_id": "doc_0977", "kind": "document", "position": 2, "type": "dismiss", "occurred_at": "2026-10-13T08:12:15Z"}, ], }, ) r.raise_for_status() # 202, no body ``` - `request_id`: from the slate. - `kind`: the item's kind (`document`, `community`, `event`, `product`). The engine takes the kind and the served position from the logged slate and ignores the client's `kind`; send it anyway, it costs nothing and helps when you read your own logs. - `position`: the served slot, from 0. - `displayed_position`: where the app showed the item, only when it re-sorted the slate on the device. Required on a `device_resort` surface. - `block`: up to 32 characters naming the block that showed the item, when one slate is rendered in two places. - `type`: `seen`, `click` or `dismiss`. - `occurred_at`: optional; defaults to the time the engine receives the report. Response: `202 Accepted`, no body. A repeat of the same request id, item and type is ignored. | Status | Code | When | |---|---|---| | `400` | `bad_request` | no `request_id`, 0 or more than 50 interactions, an unknown `type`, an empty `item_id`, an unknown field | | `401` | `unauthorized`, `wrong_scope` | wrong key | | `404` | `unknown_request` | no impression with that `request_id`. The engine waits one write interval for a fresh impression before it answers this. | | `422` | `unknown_item` | an `item_id` that is not in the slate; `details.unknown_item_ids` lists them | | `422` | `displayed_position_required` | a `device_resort` surface and an interaction without `displayed_position` | ## See also - [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger) for budgets, abandonment and intention to treat. - [Flow config reference](https://staging.patterna.nl/docs/reference-flow-config) for what ranks the slate. - [Backend API connector](https://staging.patterna.nl/docs/connector-backend-api) for keys and signing. - [SDK TypeScript](https://staging.patterna.nl/docs/connector-sdk-typescript) and [SDK Go](https://staging.patterna.nl/docs/connector-sdk-go) for timeouts and fallback. - [API overview](https://staging.patterna.nl/docs/api-overview) for errors, rate limits and request ids. --- # Ingest scope URL: https://staging.patterna.nl/docs/api-ingest > Annotated requests and responses for events, catalog items, tags, status and webhooks under the ingest scope. The `ingest` scope covers everything that flows into Patterna: canonical events, the catalog and the two webhook endpoints. Use an `ingest` key from your backend or a connector. ## 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") ``` ## POST /events Up to 500 events per call. Idempotent on `event_id`. The full list of types and their props is in [Event types](https://staging.patterna.nl/docs/reference-event-types). ```curl curl -sS -X POST https://api.patterna.nl/v1/events \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \ -H "Content-Type: application/json" \ -d '{ "events": [ { "event_id": "community_joined:c_204:u_mfrggzdfmztwq2lknnwg23tpobyxe", "type": "community_joined", "occurred_at": "2026-10-13T09:30:00Z", "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe", "source": "backend", "context": { "locale": "nl", "platform": "ios" }, "props": { "community_id": "c_204", "source": "home" } }, { "event_id": "req_01HZX4T9W6Q2:doc_1842:seen", "type": "rec_seen", "occurred_at": "2026-10-13T08:12:03Z", "session_id": "s_k3j2h4g5f6d7s8a9", "surface": "home_articles", "request_id": "req_01HZX4T9W6Q2", "source": "backend", "context": { "locale": "nl" }, "props": { "item_id": "doc_1842", "kind": "document", "position": 0, "policy_version": "linear-2026-10-13" } } ] }' ``` ```ts const result = await patterna.ingestEvents([ { event_id: "community_joined:c_204:u_mfrggzdfmztwq2lknnwg23tpobyxe", type: "community_joined", occurred_at: "2026-10-13T09:30:00Z", user_id: "u_mfrggzdfmztwq2lknnwg23tpobyxe", source: "backend", context: { locale: "nl", platform: "ios" }, props: { community_id: "c_204", source: "home" }, }, { event_id: "req_01HZX4T9W6Q2:doc_1842:seen", type: "rec_seen", occurred_at: "2026-10-13T08:12:03Z", session_id: "s_k3j2h4g5f6d7s8a9", surface: "home_articles", request_id: "req_01HZX4T9W6Q2", source: "backend", context: { locale: "nl" }, props: { item_id: "doc_1842", kind: "document", position: 0, policy_version: "linear-2026-10-13" }, }, ]); console.log(result.accepted, result.duplicates, result.rejected); ``` ```go at := func(s string) time.Time { t, _ := time.Parse(time.RFC3339, s); return t } res, err := pc.IngestEvents(ctx, []patterna.Event{ { EventID: "community_joined:c_204:u_mfrggzdfmztwq2lknnwg23tpobyxe", Type: "community_joined", OccurredAt: at("2026-10-13T09:30:00Z"), UserID: "u_mfrggzdfmztwq2lknnwg23tpobyxe", Source: "backend", Context: &patterna.EventContext{Locale: "nl", Platform: "ios"}, Props: map[string]any{"community_id": "c_204", "source": "home"}, }, { EventID: "req_01HZX4T9W6Q2:doc_1842:seen", Type: "rec_seen", OccurredAt: at("2026-10-13T08:12:03Z"), SessionID: "s_k3j2h4g5f6d7s8a9", Surface: "home_articles", RequestID: "req_01HZX4T9W6Q2", Source: "backend", Context: &patterna.EventContext{Locale: "nl"}, Props: map[string]any{"item_id": "doc_1842", "kind": "document", "position": 0, "policy_version": "linear-2026-10-13"}, }, }) if err != nil { return err } fmt.Println(res.Accepted, res.Duplicates, res.Rejected) ``` ```python r = ingest.post( "/events", json={ "events": [ { "event_id": "community_joined:c_204:u_mfrggzdfmztwq2lknnwg23tpobyxe", "type": "community_joined", "occurred_at": "2026-10-13T09:30:00Z", "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe", "source": "backend", "context": {"locale": "nl", "platform": "ios"}, "props": {"community_id": "c_204", "source": "home"}, }, { "event_id": "req_01HZX4T9W6Q2:doc_1842:seen", "type": "rec_seen", "occurred_at": "2026-10-13T08:12:03Z", "session_id": "s_k3j2h4g5f6d7s8a9", "surface": "home_articles", "request_id": "req_01HZX4T9W6Q2", "source": "backend", "context": {"locale": "nl"}, "props": {"item_id": "doc_1842", "kind": "document", "position": 0, "policy_version": "linear-2026-10-13"}, }, ] }, ) r.raise_for_status() print(r.json()) # {"accepted": ..., "duplicates": ..., "rejected": [...]} ``` - `event_id`: your idempotency key, unique per tenant. Derive it from the source id. - `occurred_at`: event time at the source. Patterna sets `received_at` itself. - `user_id` or `session_id`: in `non_profiled` mode only `session_id` is allowed. A `user_id` containing `@` rejects the event. - `source`: `backend`, `sdk`, `posthog`, `ga4`, `shopify`, `csv` or `synthetic`. - `context`: only `locale`, `app_version`, `platform`, `hour_bucket`, `weekday`, `region`, `subscription_tier`, `tenure_days`. Any other key is dropped and counted in `dropped_fields` (a personal-data name such as `latitude` rejects the event with `pii_rejected` instead). - `props`: only the keys listed for the type in [Event types](https://staging.patterna.nl/docs/reference-event-types). Any other key, and any string over 256 characters, is dropped and counted. For `rec_*` events send `kind` and, after a device re-sort, `displayed_position`. `coach_product_shown` and `coach_product_tapped` carry `product_id`, `conversation_id` and `position` (0-based, the place in the answer). - `signature`: optional. Every event sent with an ingest (or admin) key is trusted and stored as signed; there is no separate SDK key scope. When you do send a signature it is always verified, and a wrong one rejects that event. The format, the secret (the `pss_` string as is; a secret from before engine 0.2 has no `pss_` prefix and must be rotated once) and test vectors are in [Rotate keys and the signing secret](https://staging.patterna.nl/docs/rotate-keys-and-signing-secret#sign-an-event). - `synthetic`: `true` for generated rows. They never reach the ledger. ### Response 202 ```json { "accepted": 1, "duplicates": 1, "rejected": [ { "event_id": "item_viewed:doc_1842:s_k3j2", "reason": "unknown type", "code": "invalid_event" } ], "pii_rejected": 0, "dropped_fields": { "item_viewed.surface_rank": 1 } } ``` - `accepted`: new events stored. - `duplicates`: events whose `event_id` was already stored. - `rejected`: per event, the `event_id`, a `reason` (`event_id required`, `unknown type`, `occurred_at missing or in the future` (more than 5 minutes ahead), `unknown source`, `user_id or session_id malformed`, `props must be an object`, `context must be an object`, `invalid signature`, …) and a stable `code` (below). The rest of the batch is stored. - `pii_rejected`: how many events were rejected for personal data. - `dropped_fields`: per `type.prop` (or `context.key`), how many values were removed before storage. Watch it: a non-zero count means your mapping sends something Patterna will not keep. The call itself fails only on a malformed body: `400` for 0 or more than 500 events, an event that is not JSON, or an unknown top-level field. ### Personal data in events Each event is judged on its own and the call answers `202`. An event with a personal-data field name in `props` or `context` (such as `email` or `name`), or an email or `provider|id` as `user_id`, is rejected rather than stored. Each entry in `rejected[]` then has `event_id`, `reason`, a stable `code` and, for `pii_rejected`, `fields` with the paths (never the values). Other fields outside the type's list are not personal data by their name: they are dropped and counted in `dropped_fields`, and the event is kept. The per-event codes are `invalid_event`, `pii_rejected`, `invalid_signature`, `no_signing_secret`, `unknown_request`, `unknown_item` and `displayed_position_required`. There is no whole-batch `422` for events. What counts as personal data: email, name, IP, device id, free text, coordinates and any health value. See [Data we never store](https://staging.patterna.nl/docs/data-we-never-store). ## PUT /catalog/items Upserts up to 500 items. The key is `external_id` plus `kind`. ```curl curl -sS -X PUT https://api.patterna.nl/v1/catalog/items \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \ -H "Content-Type: application/json" \ -d '{ "items": [ { "external_id": "doc_1842", "kind": "document", "collection": "recipes", "title": "Havermout met magnesiumrijke zaden", "locale": "nl", "status": "published", "premium": false, "published_at": "2026-09-30T06:00:00Z", "attributes": { "duration_min": 10, "categories": ["ontbijt"], "phase_fit": ["luteal"] }, "text": "Editorial body text used for tagging only.", "tags": [ { "tag": "magnesium", "weight": 1 } ] } ] }' ``` ```ts const result = await patterna.upsertItems([ { external_id: "doc_1842", kind: "document", collection: "recipes", title: "Havermout met magnesiumrijke zaden", locale: "nl", status: "published", premium: false, published_at: "2026-09-30T06:00:00Z", attributes: { duration_min: 10, categories: ["ontbijt"], phase_fit: ["luteal"] }, text: "Editorial body text used for tagging only.", tags: [{ tag: "magnesium", weight: 1 }], }, ]); console.log(result.upserted, result.enrichment_queued); ``` ```go at := func(s string) time.Time { t, _ := time.Parse(time.RFC3339, s); return t } published := at("2026-09-30T06:00:00Z") weight := 1.0 res, err := pc.UpsertItems(ctx, []patterna.ItemUpsert{{ ExternalID: "doc_1842", Kind: patterna.KindDocument, Collection: "recipes", Title: "Havermout met magnesiumrijke zaden", Locale: "nl", Status: patterna.StatusPublished, Premium: false, PublishedAt: &published, Attributes: map[string]any{ "duration_min": 10, "categories": []string{"ontbijt"}, "phase_fit": []string{"luteal"}, }, Text: "Editorial body text used for tagging only.", Tags: []patterna.ItemTag{{Tag: "magnesium", Weight: &weight}}, }}) if err != nil { return err } fmt.Println(res.Upserted, res.EnrichmentQueued) ``` ```python r = ingest.put( "/catalog/items", json={ "items": [ { "external_id": "doc_1842", "kind": "document", "collection": "recipes", "title": "Havermout met magnesiumrijke zaden", "locale": "nl", "status": "published", "premium": False, "published_at": "2026-09-30T06:00:00Z", "attributes": {"duration_min": 10, "categories": ["ontbijt"], "phase_fit": ["luteal"]}, "text": "Editorial body text used for tagging only.", "tags": [{"tag": "magnesium", "weight": 1}], } ] }, ) r.raise_for_status() print(r.json()) # {"upserted": 1, "enrichment_queued": ...} ``` - `kind`: `document`, `product`, `community`, `event` or `tip`. - `title`: the editorial title only. - `status`: `draft`, `scheduled`, `published` or `archived`. Default `published`. - `attributes`: `price_cents`, `currency`, `difficulty`, `duration_min`, `categories[]`, `phase_fit[]`, `image_url`, `url`. Keys like `email`, `name` or `ip` reject the call with `422 pii_rejected`. - `text`: body text for enrichment. Stored only as an embedding and tags, never returned. - `tags`: tags you already know. Editor and import tags are never overwritten by the LLM. ### Response 200 ```json { "upserted": 1, "enrichment_queued": 1 } ``` `enrichment_queued` counts items whose text changed and wait for tagging. ## GET /catalog/items Lists items, 100 per page by default, up to 500. ```curl curl -sS "https://api.patterna.nl/v1/catalog/items?kind=document&status=published&limit=100" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" ``` ```ts // Listing items is not in the SDK: plain fetch with the ingest key. const page = await rest<{ items: unknown[]; next_cursor: string | null }>( "PATTERNA_INGEST_KEY", "GET", "/catalog/items?kind=document&status=published&limit=100", ); console.log(page?.items.length, page?.next_cursor); ``` ```go // Listing items is not in the SDK: plain net/http with the ingest key. var page struct { Items []map[string]any `json:"items"` NextCursor *string `json:"next_cursor"` } if err := rest(ctx, "PATTERNA_INGEST_KEY", "GET", "/catalog/items?kind=document&status=published&limit=100", nil, &page); err != nil { return err } fmt.Println(len(page.Items), page.NextCursor) ``` ```python r = ingest.get("/catalog/items", params={"kind": "document", "status": "published", "limit": 100}) r.raise_for_status() page = r.json() print(len(page["items"]), page["next_cursor"]) ``` ```json { "items": [ { "external_id": "doc_1842", "kind": "document", "collection": "recipes", "title": "Havermout met magnesiumrijke zaden", "locale": "nl", "status": "published", "premium": false, "published_at": "2026-09-30T06:00:00Z", "attributes": { "duration_min": 10 }, "tags": [ { "tag": "magnesium", "weight": 1, "source": "editor", "approved": true }, { "tag": "claim:verbetert-slaap", "weight": 0.6, "source": "llm", "approved": false } ], "enrichment": { "status": "done", "updated_at": "2026-10-13T03:10:00Z" } } ], "next_cursor": "doc_1842" } ``` - `kind`, `status`: optional filters. Items come in `external_id` order. - `next_cursor`: pass it as `cursor` for the next page. `null` on the last page. - `tags[].source`: where the tag came from. `approved: false` marks an LLM health-claim tag waiting for an editor. See [Fix a tag](https://staging.patterna.nl/docs/fix-a-tag). - `enrichment.status`: `pending`, `done`, `failed` or `skipped`. `enrichment.updated_at` is the item's last change. One item: `GET /catalog/items/{external_id}?kind=document` returns the same shape, or `404 item_not_found`. `GET /catalog/items?changed_since=2026-10-12T00:00:00Z&cursor=…` returns only items changed since that time, archived ones included and deleted ones gone, so a nightly job can reconcile without paging the whole catalog. Items also carry `locales` (up to 20, the primary locale always included) for content that reads in more than one language; a request's locale matches on the base language, so `nl-NL` finds `nl`. ## POST /catalog/items/{external_id}/status Archives, schedules or publishes one item. `kind` is a required query parameter. ```curl curl -sS -X POST "https://api.patterna.nl/v1/catalog/items/doc_1842/status?kind=document" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "scheduled", "published_at": "2026-10-20T06:00:00Z" }' ``` ```ts await patterna.setItemStatus("document", "doc_1842", { status: "scheduled", published_at: "2026-10-20T06:00:00Z", }); ``` ```go publishAt, _ := time.Parse(time.RFC3339, "2026-10-20T06:00:00Z") if err := pc.SetItemStatus(ctx, patterna.KindDocument, "doc_1842", patterna.StatusScheduled, &publishAt); err != nil { return err } ``` ```python r = ingest.post( "/catalog/items/doc_1842/status", params={"kind": "document"}, json={"status": "scheduled", "published_at": "2026-10-20T06:00:00Z"}, ) r.raise_for_status() ``` Response: `200`. Archived and unpublished items leave every slate within the serve cache's 60 seconds. ## DELETE /catalog/items/{external_id} Removes one item with its tags, and the references other tables hold to it (user shortlists, item neighbours, curated list entries). `kind` defaults to `document`. Takes an ingest or admin key. Logged slates keep the id, so the ledger does not change after the fact. Use it for items that are gone at the source; for items that may come back, archive instead. ```curl curl -sS -X DELETE "https://api.patterna.nl/v1/catalog/items/doc_1842?kind=document" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" ``` Response `200`: ```json { "external_id": "doc_1842", "kind": "document", "deleted": true, "tags_removed": 3, "references_removed": 12 } ``` Errors: `404 item_not_found`. ## PUT /catalog/items/{external_id}/tags Sets the editor-approved tags of one item. `kind` is required. ```curl curl -sS -X PUT "https://api.patterna.nl/v1/catalog/items/doc_1842/tags?kind=document" \ -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \ -H "Content-Type: application/json" \ -d '{ "tags": [ { "tag": "magnesium", "weight": 1, "approved": true }, { "tag": "claim:verbetert-slaap", "weight": 0.6, "approved": true } ] }' ``` ```ts await patterna.setItemTags("document", "doc_1842", [ { tag: "magnesium", weight: 1, approved: true }, { tag: "claim:verbetert-slaap", weight: 0.6, approved: true }, ]); ``` ```go f := func(v float64) *float64 { return &v } yes := true err := pc.SetItemTags(ctx, patterna.KindDocument, "doc_1842", []patterna.ItemTag{ {Tag: "magnesium", Weight: f(1), Approved: &yes}, {Tag: "claim:verbetert-slaap", Weight: f(0.6), Approved: &yes}, }) if err != nil { return err } ``` ```python r = ingest.put( "/catalog/items/doc_1842/tags", params={"kind": "document"}, json={ "tags": [ {"tag": "magnesium", "weight": 1, "approved": True}, {"tag": "claim:verbetert-slaap", "weight": 0.6, "approved": True}, ] }, ) r.raise_for_status() ``` `weight` defaults to 1 and `approved` to `true`. Response: `200`. ## POST /webhooks/posthog The PostHog destination endpoint, built and available since engine 0.2. It maps PostHog events to canonical events and drops everything else. It is off until a tenant sets a token. Set it up from the panel: [PostHog](https://staging.patterna.nl/docs/connector-posthog). Response: `202`. PostHog authenticates with the header `X-Patterna-Token`, a token you set with `PATCH /settings` (`posthog_token`; Patterna stores only its SHA-256). Distinct ids are never stored raw: a `u_…` id minted by Patterna's importer is kept, others are replaced by `u_` plus a keyed hash under the tenant's `PATTERNA_ID_SECRET`, or dropped when no secret is set. That secret is held by Patterna, so Patterna can re-derive such an id from the distinct id. If that is not acceptable, make the distinct id your own random Patterna id before PostHog sends it (see [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors)). ## POST /webhooks/shopify/{tenant} Receives Shopify `orders/create` and `products/update`. Shopify posts with no API key; the HMAC in `X-Shopify-Hmac-Sha256` under the `shopify_webhook_secret` you set with `PATCH /settings` is the only authentication. No secret set: `401 webhook_not_configured`. Wrong HMAC: `401 bad_signature`. Set it up from the panel: [Shopify](https://staging.patterna.nl/docs/connector-shopify). Response: `202`. ## See also - [Event types](https://staging.patterna.nl/docs/reference-event-types) - [Error codes](https://staging.patterna.nl/docs/reference-error-codes) - [API overview](https://staging.patterna.nl/docs/api-overview) --- # Admin scope URL: https://staging.patterna.nl/docs/api-admin > Annotated requests and responses for users, surfaces, flows, experiments, the ledger, exports, keys and audit under the admin scope. 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). --- # Event types URL: https://staging.patterna.nl/docs/reference-event-types > Every canonical event type with its envelope, props, learner weight, where else it acts and who sends it. Every signal Patterna learns from is one of the canonical events below. Connectors map external events into them. Your backend sends them directly. The SDK and the serve path emit the recommendation events. Send them with [POST /events](https://staging.patterna.nl/docs/api-ingest). ## Envelope Every event has these fields. | Field | Type | Notes | |---|---|---| | `tenant_id` | uuid | from the API key, never from the body | | `event_id` | string | idempotency key, unique per tenant; connectors derive it from the source id | | `type` | enum below | | | `occurred_at` | RFC 3339 | event time at the source; `received_at` is set by ingest | | `user_id` | string, optional | your random per-tenant id; absent in `non_profiled` mode | | `session_id` | string, optional | rotating id; the only identifier in `non_profiled` mode | | `surface` | string, optional | surface key when the event happened on a placement | | `request_id` | string, optional | the serve request the event belongs to | | `source` | string | `backend`, `sdk`, `posthog`, `ga4`, `shopify`, `csv`, `synthetic` | | `signed` | bool | true for every event sent with an ingest or admin key; a present `signature` is verified and a wrong one rejects the event | | `synthetic` | bool | generated rows; excluded from the ledger | | `context` | object | `locale`, `app_version`, `platform`, `hour_bucket`, `weekday`, `region` (one of your configured region names), `subscription_tier`, `tenure_days`; nothing else | | `props` | object | type-specific fields below | Identity precedence: `user_id` from the backend, then the source's user id mapped through your mapping, then `session_id`. A row never carries email, name, IP, device id, free text, coordinates, or any health value. A connector that sees such a field drops it before the row is written. See [Data we never store](https://staging.patterna.nl/docs/data-we-never-store). ## Types **Weight** is what the nightly affinity learner adds to the user's affinity for the item (and its tags) per event, before a 30-day half-life decay. The weights are fixed in the learner; they are not a per-tenant setting today. An empty weight means the event does not teach the affinity model; it may still act elsewhere (last column). The same list, with where each signal acts in serve, is on [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals). | Type | Props | Weight | Also acts in | Who sends | |---|---|---|---|---| | `rec_seen` | `item_id`, `kind`, `position`, `displayed_position`, `policy_version` | | the ledger (`tap_rate` denominator) | SDK/backend by request id | | `rec_click` | `item_id`, `kind`, `position`, `displayed_position` | +1.0 | the ledger; trending | SDK/backend | | `rec_dismiss` | `item_id`, `kind`, `position`, `displayed_position` | −1.0 | the item stays out of this person's slates for `exclude_dismissed_days` (30) | SDK/backend | | `item_viewed` | `item_id`, `kind`, `collection` | +0.2 | trending before a surface has its own log | backend, PostHog `content_viewed` | | `item_engaged` | `item_id`, `kind`, `engaged_seconds`, `max_scroll_percent` | +0.6 when `engaged_seconds >= 20` or `max_scroll_percent >= 60`, else 0 | an engaged view counts three times in trending; `engaged_rate`; the user's format preference | backend, PostHog `content_engaged` | | `item_completed` | `item_id`, `kind`, `collection` | +1.5 | the item stays out for `exclude_consumed_days` (30, personalized); the user's format preference | backend (workout, breathwork, protocol) | | `item_pinned`, `item_unpinned` | `item_id`, `kind` | +2.0 / −1.0 | an unpinned item counts as dismissed | app, through your backend | | `community_joined`, `community_left` | `community_id`, `source` | +1.5 / −1.0 | joined communities are never offered; `join_rate` | backend | | `community_post_viewed` | `post_id`, `community_id` | +0.2 | | PostHog or backend | | `community_post_created`, `community_reply`, `community_reaction` | `post_id`, `community_id` (`reaction` on the last) | +1.5 on the community, +0.5 on its tags | the community's `activity_rate` (posts and replies per member per week) | backend | | `event_rsvp`, `event_attended` | `event_id` | +1.0 on `event_rsvp` | | backend | | `search_performed` | `query_hash`, `result_count`, `clicked_item_id` | +0.8 on `clicked_item_id` | | app, through your backend (query hashed, never the text) | | `paywall_viewed` | `item_id`, `request_id` | | the item is not offered to that free user for `paywall_cooldown_days` (7) | backend | | `subscription_started`, `subscription_cancelled` | `tier`, `source` | | | backend webhooks | | `product_viewed` | `product_id`, `request_id` | +0.5 | | shop link parameter | | `order_placed` | `order_id_hash`, `product_ids[]`, `value_cents`, `currency` | +2.0 per product | `purchase_rate`; the repurchase window; co-purchase pairs | Shopify webhook, your backend, or an export | | `push_sent`, `push_opened` | `notification_type`, `item_id` | +1.0 on `push_opened` | the item's `pullback_rate` (opened over sent) | backend | | `tip_shown`, `tip_tapped` | `tip_id` | | | app | | `coach_question` | `topic_cluster` | | | backend (never the text) | | `coach_product_shown` | `product_id`, `conversation_id`, `position` | | a descriptive count, no holdout | backend | | `coach_product_tapped` | `product_id`, `conversation_id`, `position` | | a descriptive count, no holdout | backend | | `consent_changed` | `mode` (`personalized`, `cohort`, `non_profiled`) | | the opt-out path | backend | | `user_deleted` | | | triggers the deletion job | backend | Since engine 0.3.1: `community_post_created`, `community_reply` and `community_reaction`, and the context keys `weekday`, `region`, `subscription_tier` and `tenure_days`. The learners read events on slates of arm `treatment` and `none` and events outside any slate; they never learn from the control arm. Ingest keeps an allowlist of props per type. A prop that is not in the list, or a string longer than 256 characters, is not stored; the response counts it in `dropped_fields`. A prop with a personal-data name (such as `email`) rejects the whole event with `pii_rejected` instead. Positions start at 0. `kind`: on `rec_*` events with a `request_id` (and on `POST /interactions`) the engine takes the kind and the served position from the logged slate and ignores the `kind` you send. On `item_*` events outside a slate, send `kind` when an id could belong to two kinds. ## Signatures There is no separate SDK key scope: `POST /events` takes an ingest (or admin) key, and every event on such a key is trusted. A `signature` is optional. When present it is always verified, and a wrong one rejects that event. The signature is a lowercase hex HMAC-SHA256 over the canonical JSON of the event without `signature`, keyed with the tenant's `pss_…` signing secret, used as the string itself (a secret from before engine 0.2 has no `pss_` prefix: rotate it once). Sign on your backend; never ship the secret in an app. Format and a test vector: [Rotate keys and the signing secret](https://staging.patterna.nl/docs/rotate-keys-and-signing-secret#sign-an-event). ## Positions `position` is always the slot Patterna served, starting at 0. When your app re-sorts a slate on the device, also report `displayed_position`. The ledger and the off-policy estimator read the served position. A surface under an experiment must not be re-sorted, unless it has `device_resort: true` (a surface setting): then both arms are re-sorted by the same device rule, the ledger stays intention to treat on served slates, and `displayed_position` is required on every interaction. ## PostHog mapping The default mapping of the PostHog destination. Events not listed are not ingested. | PostHog event | Canonical | Dropped | |---|---|---| | `content_viewed` | `item_viewed` with `content_type` → collection | `content_title`, `content_slug` | | `content_engaged` | `item_engaged` | | | `content_scroll_depth` | folded into `item_engaged` | | | `community_joined` / `community_left` | same | | | `cyclus_visited`, `cyclus_action`, `breathing_*`, `*_location_*`, `$screen` | not ingested | everything | | person properties | not ingested | `email`, `name` | Setup: [PostHog](https://staging.patterna.nl/docs/connector-posthog). ## Coach events `coach_product_shown` and `coach_product_tapped` describe products an AI assistant named in an answer. Nothing is ranked, so they carry no `request_id`, belong to no slate and sit outside every holdout. Send them from your backend, only for users who allowed usage analytics, with the user's Patterna id in `personalized` and `cohort` mode and the session id in `non_profiled` mode. `position` is the product's place in the answer, from 0. --- # Flow config reference URL: https://staging.patterna.nl/docs/reference-flow-config > Every flow config field with its default and valid range, what each generator reads, and the validation errors. A flow is the versioned recipe that ranks a surface: which generators propose items and how much each one weighs, which filters apply, which ranker combines the scores, whether to explore, and the reason texts. You create a version with `POST /surfaces/{key}/flows` and activate it with `POST /surfaces/{key}/flows/{version}/activate` ([Admin](https://staging.patterna.nl/docs/api-admin)). This page lists every field the engine reads. ## Minimal and full example The smallest valid config is one generator. Everything else has a default. ```json { "generators": [ { "kind": "trending", "weight": 1 } ] } ``` A full config for a home feed: ```json { "generators": [ { "kind": "content_affinity", "weight": 0.5 }, { "kind": "persona_bayes", "weight": 0.2 }, { "kind": "trending", "weight": 0.2 }, { "kind": "curated", "weight": 0.1, "params": { "item_ids": ["doc_1842", "doc_0977"] } } ], "filters": { "exclude_seen_days": 7, "premium_policy": "cap", "premium_cap": 2, "per_collection_cap": 2, "freshness_days": 365, "diversity": 0.3 }, "ranker": { "kind": "linear" }, "exploration": { "kind": "plackett_luce", "share": 0.2, "temperature": 0.7 }, "reasons": { "matches_goals": { "nl": "Past bij wat je leest", "en": "Fits what you read" } } } ``` ## How scoring works For every candidate item, each generator returns a raw score or stays silent. The engine z-normalises each generator's scores across the candidates (mean 0, standard deviation 1), multiplies them by the generator's `weight` and adds them up. Because of the normalisation, weights compare directly: a weight of 0.6 against 0.3 means "twice the say", whatever the scale of the raw signal. A silent generator contributes nothing for that item; a generator that is silent for every item drops out of the request. The generator with the largest contribution to an item "wins" it and decides the item's reason key. The contributions per generator are logged with the impression and shown in the trace. ## generators A list of at least one generator. Order does not matter. | Field | Type | Default | Range | Notes | |---|---|---|---|---| | `kind` | string | required | one of the twelve kinds below | an unknown kind is a `400` | | `weight` | number | 0 | any; normally 0 to 1 | a weight of 0 switches the generator off; negative weights push items down | | `params` | object | none | | `curated` reads `item_ids`; `session_items` reads `sequence` (`both`, `next` or `neighbours`, see below); `user_to_user` reads `recent_items` and `half_life_days`; `vector_retrieval` reads `k`; `sequence_model` reads `top_items` (1 to 2000, default 100: how many of its best items carry a score); any other kind rejects these keys with a `400` and ignores the rest | | Kind | What it reads | Silent when | Reason key it leaves | |---|---|---|---| | `trending` | the smoothed 7-day tap rate of each item on this surface ((taps + 1) / (slates + 20), a tap followed by an engaged view counting three), all locales together; the request's locale only decides which items are candidates. A surface without its own log uses 30 days of tenant-wide engagement instead. The same for everyone on the surface, not per segment or persona | an item has no data at all | `trending` from the surface's own log; `popular` from the tenant-wide prior | | `curated` | `params.item_ids` in order; without `item_ids`, the surface's curated lists from the panel, as scheduled. A boost by rank (the first item +2, less for each place down the list), not a z-score, so a short list never drowns a large catalog | the item is not on the list | `editors_pick` | | `item_to_item` | the anchor in `context.anchor_item_id`: the nightly behavioural and embedding neighbours, or tag cosine before they exist | the request has no anchor, or the anchor is not in the catalog | `similar_to_read` with an anchor | | `content_affinity` | the user's tag affinities: behaviour in `personalized` mode, declared goals in `cohort` mode, against the item's approved tags | `non_profiled` requests, users without a profile, items without tags | `matches_goals` | | `persona_bayes` | the nightly per-persona model; users without a persona get the `cold` persona | no model trained yet | `people_like_you` | | `user_bayes` | the user's nightly shortlist from matrix factorisation | anything but `personalized` mode, or no shortlist for the user yet | `people_like_you` | | `surface_specific` | freshness (newer is better, scaled over 180 days); in `personalized` mode communities the user already joined are pushed down hard | never | `new` for items under 30 days old, else `recommended` | | `session_items` | what this session just looked at (`context.session_items` and the session's own reports), recency-weighted, an engaged view three times and a tap twice a plain view; every mode. `params.sequence` picks the relation to the candidates: `both` (the default) reads the path where it is known and the topic where it is not: the directed next-item shares ("what people open after this", learned nightly from the order of each person's positives) for a session item that has them, its undirected neighbours ("what goes with this") otherwise; `next` reads the path only; `neighbours` the topic only (the behaviour before round 7). It helps within a visit: on the public benchmark the path alone put the next film in the top 10 for 20% of users against 8% for the topic alone, and on a return visit more than an hour later it added nothing | the session has no history | `similar` | | `user_to_user` | what the person's 200 nearest users did last: the nearest users are learned nightly from the same matrix factorisation as `user_bayes` (or from shared items for people the model has no row for yet), each neighbour's latest 10 completions, pins, taps and engaged views (`params.recent_items`) count by similarity and fade with a 30-day half-life (`params.half_life_days`), and the person's own items are left out. Personalized mode only. Next to `session_items` the engine blends the two by the gap since the person's previous visit: in the same visit the configured weights hold; after hours, days or a week away `session_items` is faded (×0.5, ×0.25, ×0.1) and `user_to_user`, `user_bayes` and `content_affinity` raised (×1.25, ×1.5, ×1.5), the weights used are logged with the slate and the gap bucket in the trace (`gap`). On the public benchmark it is the signal for the daily reopen, see the status page | not `personalized`, or no neighbours for the person yet | `people_like_you`, only when the neighbours did engage with the item | | `vector_retrieval` | the k items nearest by content to what the person looked at (since engine 0.3.3). Per request the engine builds one query vector, the recency-weighted mean of the text embeddings of the session's items (`context.session_items` first, then the session's own reports; every mode) and, in `personalized` mode, the person's recent positives, with `session_items`' weights (a view 1, a tap 2, an engaged view 3, each step back ×0.85), and asks the vector index for the `params.k` nearest published items of the surface's kinds (default 100, at most 500). Those items join the candidates even when no other generator proposed them, so a new item or one far down the catalog can be recommended; their score is the cosine similarity, everything else reads 0. Filters, caps and exclusions apply as to every other candidate. With Redis configured an interaction is reflected in the next slate of the session; without it the session is read from the log. On the public benchmark see the status page | nothing looked at yet (no session items, no recent positives) | `similar`, only on a positive cosine | | `sequence_model` | the nightly next-item model: a small neural network (a GRU) trained every night on the order in which each person or session opened items, exported to ONNX and evaluated inside the engine with ONNX Runtime. It reads what this session looked at (`context.session_items` and the session's own reports, like `session_items`) and, in `personalized` mode, the person's last 50 positives, and scores its best `top_items` (default 100) as the likely next ones, a shortlist that sits above every other item like `user_bayes`'s; the session's own items get no score. Every mode. The jobs activate a new model only when its hit@10 on held-out data beats the directed path rule that `session_items` reads; without an active model, or without the ONNX Runtime library on the engine host, the generator scores nothing and the flow serves as if it were absent. On the public benchmark see the status page | no active model, no ONNX Runtime library, or nothing in the session and no history | `similar`, only for an item above the sequence's average score | | `time_fit` | the item's tap rate in the request's time bucket (weekday or weekend × morning, day, evening, night, from `context.hour_bucket` and `context.weekday`), learned nightly; before an item has a profile, a duration rule: short items fit the day, long ones the evening and the weekend. Every mode. It helps a feed whose items differ in length or in when people read them; on a surface with one kind of short item it adds little | no profile and no `duration_min` or `reading_time_min` | none: silent | | `format_fit` | the user's learned format preference (video, audio, text, from engaged views and completions) against the item's `format` attribute; personalized mode only. It helps where a catalog mixes formats and people clearly prefer one; with one format it does nothing | no preference learned yet, the item has no `format`, or not `personalized` | none: silent | `time_fit` and `format_fit` are **silent**: they move scores, show up in the trace, and never win a reason. Start them at a small weight (0.1 to 0.2) next to the generators that carry the slate. `vector_retrieval.params`: | Field | Type | Default | Notes | |---|---|---|---| | `k` | integer, 1 to 500 | 100 | how many nearest items the index returns per request; more widens the candidate pool and costs a little latency | `curated.params`: | Field | Type | Default | Notes | |---|---|---|---| | `item_ids` | array of external ids | none (use the panel's curated lists) | ids not in the catalog or not published are skipped | `context.anchor_item_id` is resolved against the whole catalog, any kind. An article can anchor a product surface. A slot `item_to_item` wins across kinds carries `pairs_with` only for a recipe or a workout with a product, or a community with a document, each with its own default text; any other cross-kind anchor carries `more_about_tag`. `content_affinity` leaves `matches_goals` for a declared goal and `more_about_tag` for behaviour. ## filters Filters remove candidates before scoring, or cap the slate while it is built. | Field | Type | Default | Range | What it does | |---|---|---|---|---| | `exclude_seen_days` | integer | 0 (off) | 0 or more | drops items served to this user on this surface in the last N days (from the last 50 slates). Not applied in `non_profiled` mode: there is no user to remember. | | `locales` | array of strings | none | | **accepted and stored, but not read by the engine.** The request's `context.locale` decides the locale. | | `premium_policy` | string | none (no rule) | `cap`, `exclude` or absent | `cap`: at most `premium_cap` premium items for non-premium users, no cap for premium users. `exclude`: no premium items for non-premium users. | | `premium_cap` | integer | 2 when `premium_policy` is `cap` and this is 0 | 1 or more | | | `per_collection_cap` | integer | 0 (no cap) | 0 or more | at most N items from one collection in a slate | | `freshness_days` | integer or null | null (no limit) | 0 or more | drops items published more than N days ago | | `diversity` | number | 0 (off) | 0 to 1 | an MMR penalty: at each position an item's score is lowered by `diversity` times its highest tag similarity to the items already picked. It is applied inside the sampler, so logged propensities stay exact. | | `region_boost` | number | 0.3 | 0 to 1 | added to the score of items whose region is the request's `context.region`; other items are never hidden. Logged as the contribution `region_match`; never a reason | | `exclude_dismissed_days` | integer | 30 | 0 to 365 | items this person dismissed on any surface, or unpinned, stay out for N days (by user id, or by session id without one). Helps every surface: nobody wants to see a thing twice after saying no. 0 = off | | `exclude_consumed_days` | integer | 30 | 0 to 3650 | items the user consumed (`item_completed`, `event_attended`) stay out for N days; personalized mode. Read from the user's whole history (the nightly `user_consumed` set plus the last two days of events), so a heavy user's completed items never come back inside the window. **0 means ever**, not off: nothing the user ever consumed is served again. Helps for workouts, protocols and articles read once; a surface where people repeat items on purpose sets a short window such as 1 | | `paywall_cooldown_days` | integer | 7 | 0 to 365 | an item that showed this free user a paywall (`paywall_viewed`) stays out for N days; applies when `context.subscription_tier` is `free`. Helps a free user not hit the same wall twice. 0 = off | | `repurchase_days` | integer | 30 | 0 to 365 | a product the user bought (from `order_placed`) is not offered again inside N days; the item's own `repurchase_days` attribute wins. Helps durable products; give consumables a short attribute instead of lowering this for the whole surface | | `hide_unavailable` | boolean | true | | products whose `available` (or `in_stock`) attribute is `false` are never served. A hard rule, the same in both arms. Turn it off only if your app shows sold-out products on purpose | | `relevance_floor` | number | 0 (off) | 0 to 1 | on a request with `context.anchor_item_id`, only items whose similarity to the anchor (precomputed neighbours, else tag cosine) reaches the floor count toward `min_slots`. With `min_slots: 1` a block then hides when nothing is related to the page, in both arms alike. Helps anchored blocks such as products under an article; leave it at 0 on unanchored feeds, where it does nothing. Pick the value from the trace's similarities, then check the hidden share in Simulate | `diversity` is the only diversity setting. Older contract versions had a top-level `diversity: {mmr_lambda}` object; it was never read, and the engine drops it on save. Two kinds of rule. **Hard rules** (an item past `expires_at`, an event past `starts_at`, an unavailable product, the request's exclusions, joined communities, kinds, premium, freshness) are decided before the arm and apply in both arms; `min_slots` counts on them. **Personal exclusions** (dismissed, completed, paywalled, bought) are preferences: when they would leave the slate short, they are relaxed, and the fallback fill honours them too. Caps and diversity apply in the deterministic top-k and inside the exploration sampler alike. The request's `exclude_item_ids` and the anchor item are always removed. So are the communities the user already joined: `context.member_of`, in every mode, plus `community_joined` / `community_left` events in `personalized` and `cohort` mode. When no candidate survives, the request answers `422 no_candidates`. ## ranker | Field | Type | Default | Range | Notes | |---|---|---|---|---| | `kind` | string | `linear` | `linear`, `learned_linear`, `gbdt` | `lambdamart` is refused (`400`): no model is trained for it | | `bandit` | boolean | false | | a Thompson-sampling mixture: per request, each generator's weight is multiplied by a draw from its nightly posterior for this surface. The drawn weights are logged with the impression, so propensities stay exact. Never in the control arm | | `gap_blend` | boolean | true | | the return-visit blend: when the flow has `session_items` next to a long-term generator (`content_affinity`, `user_bayes`, `user_to_user`), a personalized request after a pause fades the session weight (×0.5 after hours, ×0.25 after days, ×0.1 after weeks) and raises the long-term ones (×1.25, ×1.5, ×1.5). The blended weights are logged with the slate. `false` keeps the configured weights on every visit (a course or a playlist) | | `policy_version` | string | empty | | informational; the slate's `policy_version` says which artifact actually ran | - `linear`: the weighted sum above, with your weights. - `learned_linear`: your weights replaced by the nightly per-generator weights fitted from logged taps (examination and inverse-propensity weighted). Generators without a learned weight keep yours. Until the first model exists it behaves like `linear`. It trains on slates in arm `treatment` and arm `none` (never control), from the interactions you report on them; filled positions are never ranking evidence. - `gbdt`: the nightly gradient-boosted trees rescore the candidates from the generators' contributions, item age, premium and kind, the item features (`engaged_rate`, `pullback_rate`, `activity_rate`, `purchase_rate`, the time-bucket tap rate) and the request's hour, weekend and platform. Older models without the item and context features keep scoring. Until a model exists it behaves like `linear`. Simulate a ranker change first (`POST /surfaces/{key}/simulate`) and look at the effective sample size. ## exploration | Field | Type | Default | Range | Notes | |---|---|---|---|---| | `kind` | string | none | `none`, `plackett_luce` | anything else is a `400` | | `share` | number | the surface's `exploration_share` when 0 or absent | 0 to 0.5 | the share of eligible requests that sample instead of taking the top-k | | `temperature` | number | 0.7 when 0 or absent | above 0 | higher spreads picks further down the ranking | With `plackett_luce`, a sampled slate draws positions one by one from a softmax over the scores, with caps and diversity applied inside the draw, and logs the exact probability of every pick. See [Exploration and propensities](https://staging.patterna.nl/docs/exploration-and-propensities). **Where exploration runs.** On treatment slates during a holdout, and on slates outside any holdout (arm `none`). The control arm is never explored. ## reasons A map from a **reason key** or a **generator kind** to a map from locale to text. ```json { "reasons": { "similar_to_read": { "nl": "Lees ook", "en": "Read next" }, "editors_pick": { "nl": "Uitgekozen voor jou", "en": "Picked for you" } } } ``` An entry replaces the default for that key in every locale; give each locale you serve. A reason-key entry wins over a generator-kind entry, which wins over the default. The engine fills `{anchor_title}`, `{tag}` and `{segment}`, and rejects unknown keys, locales and placeholders with `400 invalid_reason_template`. The keys and defaults: [Reason keys](https://staging.patterna.nl/docs/reference-reason-keys). ## Surface fields that interact with the flow These live on the surface, not the flow ([Admin](https://staging.patterna.nl/docs/api-admin)). | Field | Default | Notes | |---|---|---| | `slots` | 6 | slate length; a request's `slots` (1 to 50) overrides it | | `item_kinds` | required | candidates are only items of these kinds; `context.candidate_kinds` narrows it per request | | `fallback` | `{ "kind": "newest" }` | the editorial list: `newest`, `curated` with `item_ids`, or `popular` (7-day trending); `newest` fills any gap. Served to the control arm and, flagged `fallback`, as the safety net. Optional `by_anchor`: an object from an anchor item id to a list of item ids, served to control on that anchor's page. A request's `context.control_item_ids` comes first, then `by_anchor`, then this list; the request's rules apply to all of them. A treatment slate short after its filters is filled from the same list, reported as `fill_count`. | | `exploration_share` | 0.2 | used when the flow's `exploration.share` is 0 or absent | | `status` | `shadow` | `shadow`, `testers`, `live`, `off`. `shadow`: the flow ranks every request, logged with arm `none` and marked as not shown (your app shows its own list). `testers`: testers get the flow; everyone else gets the editorial list with arm `none`. `live`: everyone gets the flow, in arms when a holdout runs. `off` answers `409 surface_off`. | | `min_slots` | 0 | 0 to 50; 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. 0 = always shown; 1 hides a block with nothing in it | | `device_resort` | false | the app may re-sort the served slate under a holdout; interactions then need `displayed_position` (`422 displayed_position_required`) | A surface without an active flow version serves the editorial list, flagged `fallback`. ## Validation `POST /surfaces/{key}/flows` checks the config and answers `400 bad_request` with one of these messages: | Message | Fix | |---|---| | `config must be an object: …` | send a JSON object under `config` | | `config.generators must hold at least one generator` | add a generator | | `unknown generator kind X` | use one of the twelve kinds | | `unknown config key X (generators, filters, ranker, exploration, reasons)` | remove the key; a top-level `diversity` from older clients is tolerated and dropped | | `ranker.kind lambdamart is not available yet: …` | use `linear`, `learned_linear` or `gbdt` | | `ranker.kind must be linear, learned_linear or gbdt` | fix the kind | | `filters.diversity must be between 0 and 1` | | | `filters.relevance_floor must be between 0 and 1` | | | `filters.region_boost must be between 0 and 1` | | | `filters.exclude_dismissed_days must be between 0 and 365 days` (also `exclude_consumed_days`, `paywall_cooldown_days`, `repurchase_days`) | | | `exploration.kind must be none or plackett_luce` | | | `exploration.share must be between 0 and 0.5` | | | `invalid_reason_template` | use known reason keys and placeholders | Unknown fields inside `filters`, `ranker` or `exploration` are stored but have no effect; an unknown top-level key is refused. Check a new version with the panel's preview or `simulate` before you activate it. --- # Reason keys URL: https://staging.patterna.nl/docs/reference-reason-keys > Every reason key, its default sentence per locale, how flow overrides are keyed, and the placeholders serve fills. Every slot in a slate carries a reason. Nothing is recommended without a sentence the user could read. This page lists the keys the engine uses today and how to change their text. The idea behind it is in [Reasons](https://staging.patterna.nl/docs/reasons). ## In the slate Each item has two fields: - `reason_key`: the key, chosen from the generator that contributed most to the item's score, when what it would say is true; otherwise `recommended`. - `reason`: the sentence, one string per locale. ```json { "item_id": "doc_1842", "position": 0, "reason_key": "similar_to_read", "reason": { "nl": "Omdat je dit net las", "en": "Because you just read this" }, "generator": "item_to_item" } ``` Show `reason` in the user's locale. If that locale is missing, pick a default of your own (for example `nl`). Hide the reason line when you show your own list. ## The keys The winning generator decides the key. The sentences below are the defaults the engine ships. | `reason_key` | Left by | Default `nl` | Default `en` | |---|---|---|---| | `similar_to_read` | `item_to_item`, with `context.anchor_item_id` | Omdat je dit net las | Because you just read this | | `similar` | `item_to_item`, without an anchor | Lijkt op wat je leest | Similar to what you read | | `matches_goals` | `content_affinity` on a goal the user declared | Past bij je doelen | Matches your goals | | `people_like_you` | `persona_bayes`, `user_bayes`, `user_to_user` (only when the person's nearest users engaged with the item) | Populair bij leden zoals jij | Popular with members like you | | `trending` | `trending`, on this surface's own 7-day log (all locales together) | Deze week veel gelezen | Widely read this week | | `editors_pick` | `curated`; also the control arm and fallback when the surface's list is `curated` | Gekozen door de redactie | Chosen by the editors | | `new` | `surface_specific` for an item under 30 days old; also the control arm and fallback when the list is `newest` | Nieuw | New | | `popular` | `trending` before the surface has its own log (the tenant-wide prior); the control arm and fallback when the list is `popular` | Populair | Popular | | `pairs_with` | `item_to_item` with an anchor of another kind that pairs: a recipe with a product, a workout with a product, a community with a document | Past bij dit recept · Past bij deze workout · Gelezen in deze community | Pairs with this recipe · Pairs with this workout · Read in this community | | `more_about_tag` | `content_affinity` won on behaviour rather than a declared goal; `item_to_item` with any other cross-kind anchor | Meer over dit onderwerp | More about this topic | | `recommended` | nothing true to say: the winner's own signal was not positive, a cold-start persona, an old item under `surface_specific` | Aanbevolen | Recommended | `matches_goals` is only given for a goal the user declared; an affinity learned from behaviour gets `more_about_tag`. A collection counts as a recipe collection when its name contains recipe, recept, maaltijd or meal, and as a workout collection when it contains workout, training, oefening or exercise. `time_fit`, `format_fit` and the region boost never name a reason, so they have no key. `recommended` is a valid key for templates like any other: override its text if "Aanbevolen" does not fit your design, but keep it free of claims. The rule behind it: [Reasons](https://staging.patterna.nl/docs/reasons#the-truthfulness-rule). ## Overriding the text Overrides live in the flow config under `reasons`, keyed by **reason key** or by **generator kind**, then by locale. A reason-key entry wins over a generator-kind entry, which wins over the default. Editing the texts creates a new flow version, like any other change. ```json { "reasons": { "similar_to_read": { "nl": "Lees ook", "en": "Read next" }, "matches_goals": { "nl": "Past bij wat je leest", "en": "Fits what you read" }, "trending": { "nl": "Veel gelezen deze week", "en": "Read a lot this week" } } } ``` An override replaces the whole entry for that key: give every locale you serve. A key without an override keeps the defaults above. ## Placeholders | Placeholder | Filled with | |---|---| | `{anchor_title}` | the anchor item's title in the request locale | | `{tag}` | the taxonomy label of the winning tag in that locale; phases and health claims are never named | | `{segment}` | the persona's label, from a taxonomy tag of kind `segment` | When a placeholder cannot be filled for a locale (no anchor, no label in that locale), that locale gets the default text instead. The rendered `reason` never contains a placeholder. Serve also checks overrides when you create a flow version: an unknown key, locale or placeholder answers `400 invalid_reason_template`. ## Special cases - **Control arm.** The control arm gets the surface's editorial list (`newest`, `curated` or `popular`). It carries a reason too (`new`, `editors_pick` or `popular`), so "a reason for every slot" holds in both arms. - **Your own fallback.** When your backend shows its own list after a timeout, that list is not from Patterna. Mark those slots as yours, show no Patterna reason, and report no interactions for them. - **Drafted texts.** The `reasons` job drafts a missing nl or en sentence and saves a new, inactive flow version whose note starts with `REVIEW NEEDED`; it never overwrites a text. It drafts per generator kind, and serve reads generator-kind templates, so the drafts work as they are. --- # Error codes URL: https://staging.patterna.nl/docs/reference-error-codes > Stable error codes from the API, the SDKs and the panel, with status and what to do. Every API error has a stable `code`. Branch on the code, not on the message. Every error also carries the `request_id`; quote it when you ask for help. The error body is described in the [API overview](https://staging.patterna.nl/docs/api-overview). The tables below list every code the engine returns on the integration paths (serve, ingest, users, flows, experiments, keys), with its status. "Fall back" means: show your own list and keep the page working; "fix" means a bug or a configuration mistake that a fallback must not hide. ## API: serve and ingest | Code | Status | Meaning | What to do | |---|---|---|---| | `bad_request` | 400 | a missing or invalid field, **an unknown JSON field** (bodies are decoded strictly), an empty body or a body over 2 MiB; the `message` names the problem | fix the request | | `unauthorized` | 401 | missing, revoked or expired key | fix the key | | `wrong_scope` | 401 | a serve or ingest key on another scope's path (`pk_s_` serve, `pk_i_` ingest, `pk_a_` admin passes everywhere) | use the key of the right scope | | `not_found` | 404 | a path the API does not have, answered in the usual error body | check the path and the `/v1` prefix | | `unknown_surface` | 404 | no surface with that key | fix the surface key | | `unknown_request` | 404 | `POST /interactions` (or `/abandon`) with a `request_id` that has no impression | report with the `request_id` from the slate; for your own list report nothing | | `unknown_item` | 422 | an interaction for an `item_id` that is not in the slate; `details.unknown_item_ids` lists them | report only items from the slate | | `displayed_position_required` | 422 | an interaction on a `device_resort` surface without `displayed_position` | send where the app showed the item | | `bad_tester_id` | 422 | `X-Patterna-Tester` with a value other than `1` | send `1` or leave the header out | | `webhook_not_configured` | 401 | a Shopify webhook for a tenant without `shopify_webhook_secret` | set the secret with `PATCH /settings` | | `bad_signature` | 401 | a Shopify webhook whose `X-Shopify-Hmac-Sha256` does not match | check the secret in Shopify and in Patterna | | `item_not_found` | 404 | a catalog call for an `external_id` and `kind` that do not exist | check the id and the `kind` query parameter | | `surface_off` | 409 | the surface's status is `off` | fall back; expected while a surface is off | | `not_shown` | 409 | `POST /recommendations/{request_id}/abandon` for a slate answered with `shown: false` | nothing to do: hide the block; the slate already counts in its arm | | `mode_conflict` | 422 | a `user_id` on a `non_profiled` request; `PUT /users/{user_id}/profile` with mode `non_profiled`, or without `mode` for a user who opted out | fix: send only `session_id` in `non_profiled` mode; set the mode explicitly before a profile. A consent bug is a privacy bug | | `mode_required` | 422 | `PUT /users/{user_id}/profile` without `mode` for a user Patterna does not know yet | send `mode` (`personalized` or `cohort`) with the profile, or set it first with `PUT /users/{user_id}/mode` | | `no_candidates` | 422 | no published item of the surface's kinds and locale survives the flow's filters | fall back, then check the catalog sync, the locale and the filters | | `pii_rejected` | 422 | a catalog item attribute named `email`, `name` or `ip` | remove the attribute | | `rate_limited` | 429 | the key's token bucket is empty (serve 200/s, burst 400; ingest 50/s, burst 100); `Retry-After: 1` | fall back on serve; retry ingest later. Never retry `recommend` | | `quota_exceeded` | 429 | the plan's monthly serve requests are used; `details` carries `quota`, `used` and `resets_at` (the 1st of next month, UTC). Interactions, events and the catalog keep working | show your own list until the reset; never retry `recommend`. Ask us to raise the quota; a `quota_warning` notification arrived at 90% | | `internal` | 500 | an unexpected error; logged with the `request_id` | fall back; quote the `request_id` | | `unavailable` | 503 | storage did not answer in time or could not be reached (a timed-out key lookup answers this, never 401); `Retry-After: 1` | fall back on serve, retry once elsewhere; alert if it persists | Events are not rejected as a batch for content. `POST /events` answers `202` and lists problem events in `rejected[]` with a reason (`unknown type`, `invalid signature`, `user_id or session_id malformed`, …), and counts removed props in `dropped_fields`. See [Ingest](https://staging.patterna.nl/docs/api-ingest#response-202). Each entry in `rejected[]` also carries a stable `code`: `invalid_event`, `pii_rejected` (with `fields`, the paths, never the values), `invalid_signature`, `no_signing_secret` (a signed event while the tenant has no secret), `unknown_request`, `unknown_item` or `displayed_position_required`. The batch still answers `202`. ## API: admin | Code | Status | Meaning | What to do | |---|---|---|---| | `bad_request` | 400 | also an invalid flow config: `config.generators must hold at least one generator`, `unknown generator kind …`, `ranker.kind must be linear, learned_linear or gbdt`, `ranker.kind lambdamart is not available yet`, `filters.diversity must be between 0 and 1`, `exploration.kind must be none or plackett_luce`, `exploration.share must be between 0 and 0.5`; an experiment with a `primary_metric` other than `tap_rate`, `second_item_rate`, `join_rate`, `engaged_after_tap` or `purchase_rate` | fix the config; see [Flow config reference](https://staging.patterna.nl/docs/reference-flow-config) | | `forbidden_role` | 403 | a panel session (or key) whose role may not do this: viewers read only, editors configure, owners manage keys, people and secrets | ask an owner | | `forbidden_scope` | 403 | an admin key tried to mint an admin key | an owner mints admin keys in the panel | | `session_required` | 403 | an action tied to a person (approvals, saved views) called with a key | use the panel | | `not_found` | 404 | the object does not exist for this tenant | check the id | | `unknown_flow_version` | 404 | preview of a flow version that does not exist | check the version | | `experiment_running` | 409 | the surface already has a running experiment | end it with `POST /experiments/{id}/end` first | | `approval_required` | 409 | activating a flow on a live surface while the two-person rule is on | request an approval | | `approval_pending` | 409 | an approval for the same change is already open | wait for it or reject it | | `exists` | 409 | a surface, key or member with that name already exists | pick another name | | `deletion_scheduled` | 409 | a workspace deletion is already scheduled | cancel it first | | `unknown_items` | 422 | a curated list or gold set names items that are not in the catalog | fix the ids | | `invalid_reason_template` | 400 | a flow's `reasons` names an unknown reason key or generator kind, locale or placeholder | use the keys and placeholders in [Reason keys](https://staging.patterna.nl/docs/reference-reason-keys) | | `signing_secret_exists` | 409 | `POST /settings/signing-secret` when a secret already exists | rotate instead | | `metric_not_applicable` | 400 | an experiment with `primary_metric: join_rate` on a surface without communities, or `purchase_rate` on a surface without products | pick a metric that fits the surface | ## TypeScript SDK Every failure is a `PatternaError` with `code`, `op`, `status`, `requestId`, `details` and a `transient` getter. | Code or class | Meaning | What to do | |---|---|---| | `consent_required` (`ConsentError`) | `recommend` without a mode, or an unknown mode | always pass the user's mode; a consent bug is a privacy bug | | `consent_violation` (`ConsentError`) | a `non_profiled` request with a `user_id` or without a `session_id` | fix the caller; nothing was sent | | `ValidationError` | bad input, for example an email or `auth0\|...` as user id, or a context key outside the allowlist | fix the caller; the id is never in the message | | `TimeoutError` | the per-call budget ran out | show your own list | | `NetworkError` | no answer | show your own list; idempotent calls were retried once | | `ApiError` | the API answered with an error; `code` is the API's | see the API table | `withFallback` falls back on every SDK error except `ConsentError` and `ValidationError`, which it rethrows. That includes an `ApiError` with status 400, 401, 404 or `mode_conflict`: log `code` and alert on those, so a bug does not hide behind your own list. The SDK has typed errors for `no_candidates`, `surface_off` and `unknown_surface`. ## Go SDK Every error is `*Error{Code, Op, Status, RequestID, Message, Details}`. Compare with `errors.Is` or `patterna.IsAPIError(err, "pii_rejected")`. | Sentinel | Meaning | What to do | |---|---|---| | `ErrTimeout` | the deadline passed (budget or a shorter caller deadline) | serve your own list | | `ErrAborted` | the caller cancelled the context | nothing | | `ErrCircuitOpen` | the breaker for this scope is open after 5 failures; it stays open 30 s | serve your own list; watch `OnBreakerChange` | | `ErrQueueFull` | the interaction report queue (1024) is full; the report was dropped | check `DroppedReports()`; look for a slow API | | `ErrUnknownSurface` | the API answered `404 unknown_surface`; `RecommendWithFallback` serves your own list and sets `rec.Cause` | a configuration bug: alert on it | | `ErrUnknownItem` | matches the API code `unknown_item` (`422`, an interaction for an item not in the slate) whatever the status | report only items from the slate | | `ErrItemNotFound` | matches the API code `item_not_found` (`404` from the catalog calls `GetItem`, `DeleteItem`, `SetItemStatus`, `SetItemTags`; SDK 0.4.1) | check the id and the `kind` | Consent and opaque-id checks in Go fail before anything is sent, like in the TypeScript SDK. ## Panel login These come from the panel's auth endpoints, which are not in the contract yet. | Code | Meaning | What to do | |---|---|---| | `invalid_credentials` | wrong email or password | try again | | `rate_limited` | too many attempts | wait a moment | | `mfa_pending` | signed in, MFA not done yet (401 from `/auth/me`) | enter the MFA code | | `mfa_invalid` | wrong MFA code | try again with a fresh code | | `sso_<reason>` | the Orven one-click login failed; the suffix says why | sign in with email and password | --- # Glossary URL: https://staging.patterna.nl/docs/glossary > Short definitions of the terms used across Patterna's docs, panel and API. **Abandonment.** Your client tells Patterna it gave up on a slate or hid the block (`POST /recommendations/{request_id}/abandon`, answered `200`). The slate keeps its arm and counts in the fallback rate, flagged `client_abandoned` with a reason (`timeout`, `server_error`, `network_error`, `bad_response` or `client_fallback`). **Arm.** The group a unit is assigned to while a holdout runs on a surface: `treatment` or `control`. Every other slate (no running experiment, `shadow`, `testers`) has arm `none` and never enters the ledger. Arms are sticky: the same user stays in the same arm. **Assignment unit.** An optional top-level field on `non_profiled` requests, 64 hex characters: an HMAC-SHA256 your backend computes from its own user id under a key it holds, so a person keeps one arm across sessions. Used only for the arm and `unit_hash`; never stored raw. See [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors). **baseline_rate.** The metric's rate today, used with `relative_change` to size a holdout (`readable_sample`). Take it from your own analytics for the block you replace; for a new block, read the control rate off the surface after a week of traffic. Left empty, the ledger uses the observed control rate and a 20% change. **blocks_shown.** The blocks of a page a slate was seen in, recorded on the impression from `seen` interactions that carry `block`. The ledger writes a row per block (`by=block`) and the surface metrics filter on it. **Clustered interval.** A 95% interval that treats all slates of one user (or session) as one unit, because they are not independent. The ledger computes it with the delta method. See [Proof and the ledger](https://staging.patterna.nl/docs/proof-and-the-ledger). **Cohort.** A consent mode. Recommendations use the user's declared goals only, no behaviour. The learning jobs leave cohort users' events out. **Consent mode.** How much Patterna may know about a user: `personalized`, `cohort` or `non_profiled`. See [Consent modes](https://staging.patterna.nl/docs/consent-modes). **Control.** The holdout arm that gets the surface's editorial list (newest, curated ids or 7-day popular), logged with policy `editorial`, propensity 1 and a reason. It is the comparison, not a fallback. On an anchored surface control serves your own list for the page from `context.control_item_ids` when you send one, else the surface's `fallback.by_anchor` list for the anchor; the answer's `control_source` says which. **device_resort.** A surface setting: the app may re-order the slate on the device. Every interaction must then carry `displayed_position`; the engine keeps the served position for the propensities. **Diversity.** `filters.diversity` (0 to 1) in the flow config: a penalty on items similar to the ones already picked, applied inside the sampler so propensities stay exact. The only diversity setting. **Effective sample size.** How many logged impressions really inform an off-policy estimate. It drops when the new flow ranks very differently from what was logged. **event_id.** The idempotency key of an event, unique per tenant. Sending it twice stores it once. **Example mode.** The panel's view of the synthetic example data from the twin, flagged `synthetic`, so an empty workspace shows what every screen looks like. Nothing in it enters the ledger. **Exploration share.** The share of requests in the treatment arm or outside any holdout (arm `none`) on which the sampler explores with Plackett-Luce instead of returning the deterministic top-k. Control is never explored. From the flow's `exploration.share`, else the surface's `exploration_share` (default 0.2); at most 0.5. **Fallback.** The safety net: when a request that should have been ranked times out or hits storage trouble, or the surface has no active flow, Patterna answers with the editorial list and `fallback: true`, logged in the assigned arm. The fallback rate is fallback slates (safety-net fallbacks plus slates the client abandoned) over non-shadow slates served; on a surface's page `shown: false` slates are left out of both counts. Control slates never count. Your backend also keeps its own list for when Patterna does not answer within 150 ms. See [Timeouts and what counts in the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger). **Flow.** The recipe of a surface: generators, filters (including `filters.diversity`), ranker, exploration and reason overrides. See [Surfaces and flows](https://staging.patterna.nl/docs/surfaces-and-flows) and the [Flow config reference](https://staging.patterna.nl/docs/reference-flow-config). **Flow version.** A saved, numbered flow config. Saving creates a new version; activating one is audited; the previous version stays available for rollback. **Generator.** A scorer of candidates, twelve of them: `trending`, `curated`, `item_to_item`, `session_items`, `vector_retrieval` (also a candidate source: it fetches the nearest items by content), `content_affinity`, `persona_bayes`, `user_bayes`, `user_to_user`, `surface_specific`, and the silent `time_fit` and `format_fit`, which move scores but never name a reason. Each score is normalised and weighted; the largest contribution wins the slot and sets its reason key. **Holdout.** An experiment where part of the traffic (the control arm) gets the editorial list. The difference between arms is the proof. Pilot default: 50/50, sticky per user. **Intention to treat.** Every served slate counts in its arm, seen or not, including slates the client gave up on. See [Timeouts and what counts in the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger). **Impression.** One slate served to one user or session, logged with its arm, every position and its propensity. Impressions are not the unit of the interval; users (or sessions) are. **Ledger.** The table per surface with the holdout difference, its interval, slates and units per arm, the SRM p-value and a readable flag. Intention to treat. Not yet recomputable from the export alone (the proof export lacks the unit, `measured`, `not_shown` and the experiment window; see [Status](https://staging.patterna.nl/docs/status)). Synthetic rows are excluded. **Measured.** `context.measured: false` marks a slate for a user who did not allow usage analytics. It is served as usual but left out of the ledger, power and learning in both arms. **Minimum detectable effect.** The smallest relative change a holdout can show with its sample at 5% two-sided and 80% power. `relative_change` is the change you choose to detect; the readable sample follows from it and the baseline. **Not shown.** A slate shorter than the surface's `min_slots`, answered with `shown: false`. It comes back with no items, counts in its arm as served with zero success, and cannot be abandoned (`409 not_shown`). **non_profiled.** A consent mode. No user id is stored or sent; only a rotating session id and context. Patterna still learns one global, context-only policy, and the holdout still covers these sessions. **Persona.** A named cluster of users built by the jobs. `persona_bayes` ranks per persona; users without one use the `cold` persona. **Personalized.** A consent mode. Patterna keeps a random per-tenant user id and learns from that user's behaviour. **Plackett-Luce.** The sampling model used for exploration. It draws items one position at a time, so the probability of every item in every position is known exactly. See [Exploration and propensities](https://staging.patterna.nl/docs/exploration-and-propensities). **Policy version.** The version of the model artifact that ranked a slate. Logged on every impression and returned in the slate. **Pre-register.** Choosing the surface, the primary metric, the baseline and the change to detect when the holdout starts, before any result is seen. The ledger reads only that metric, so a row cannot be picked after the fact. **Propensity.** The probability that an item was shown in a given position. Logged with every impression; needed for off-policy estimates such as SNIPS. **Quota.** A plan's monthly number of serve requests (`request_quota_monthly`). Every `POST /recommendations` counts. A `quota_warning` notification at 90%, `429 quota_exceeded` past it until the 1st of the next month (UTC); show your own list until then. **Readable sample.** The number of randomised units per arm an experiment needs, given the baseline and the change to detect, at 5% two-sided and 80% power. A ledger row is `readable` when each arm reaches it. **Reason key.** The key a slot carries, set by the winning generator, for example `similar_to_read`. Flow overrides are keyed by it. The app shows the sentence in the user's locale. See [Reason keys](https://staging.patterna.nl/docs/reference-reason-keys). **Request id.** The id of one API call, in the `X-Request-Id` header and in error bodies. For a recommendation it is also the slate's `request_id`, which you echo back in `/interactions`. **RLS.** Row-level security in Postgres. Every connection sets the tenant, so one tenant can never read another's rows. **Round.** The internal name of an engine release in the changelog (round 4, round 5). These pages use the engine version instead: engine 0.3.1 is what `/health` reports and the contract's version. **Scope.** What a key may do: `serve`, `ingest` or `admin`. **SERVE_BUDGET.** The engine's own time budget for `POST /recommendations`, 120 ms by default and below the SDKs' 150 ms. Past it the engine answers the editorial list with `fallback: true`. A shorter client deadline (`X-Deadline-Ms`) wins. **Shadow.** Two uses. As a surface status, the first one: Patterna ranks and logs every slate with arm `none`, your backend calls it as usual and discards the answer, and your app shows its own list. As a request flag, `"shadow": true` on `POST /recommendations`: rank and log this one request as not shown (arm `none`), whatever the surface's status; the panel preview uses it. Neither enters the ledger or the fallback rate. **Signed event.** Every event sent with an ingest or admin key counts as signed. A `signature` (lowercase hex HMAC-SHA256 over the canonical event) is optional; when present it is verified and a wrong one rejects the event. Secrets are `pss_` strings and the HMAC key is the secret string itself, as the SDKs do. A secret issued before engine 0.2 has no `pss_` prefix: rotate it once. **Slate.** The ordered list of items for one request, each with a position (from 0), reason and propensity. **SNIPS.** Self-normalised inverse propensity scoring. It estimates from logged impressions how a different flow would have done, without running it. Used by simulate and the replay report. **SRM.** Sample ratio mismatch. The arms are not the size they should be. A ledger row with an SRM p-value below 0.001 is flagged and should not be read. **Surface.** One placement in your product, such as `home_articles` or `more_to_read`. It has a status, slots, an editorial list (`fallback`) and an active flow version. **Synthetic.** Rows generated by the twin, flagged `synthetic`. They power the "example data" view and never enter the ledger. **Tenant.** One customer workspace. Every row, key and setting belongs to exactly one tenant. **Tenant seed.** The script that sets up a new workspace: the tenant row, its first admin key and, when agreed with you, its surfaces and settings, so you start from a filled panel. Locally `engine seed` does the same for development. A seeded surface is an ordinary surface: change it in the panel or with `patterna surface apply`. **Testers.** The second surface status. Ids on the surface's testers list get the ranked slate; everyone else gets the editorial list, all with arm `none`. The `X-Patterna-Tester: 1` header lets your own roster decide. **Treatment.** The arm that gets Patterna's ranked slate. **Truth scale.** A setting of the twin: 1.0, 0.5 or 0.25, how much persona structure the simulated audience has. Simulate reports the lift at all three, so you see how much the result depends on the twin's assumptions. **Twin.** A simulator of the tenant's users and catalog. It produces the synthetic example data and the `twin_curve` in simulate. It is not proof. See [The twin](https://staging.patterna.nl/docs/the-twin). --- # Changelog URL: https://staging.patterna.nl/docs/changelog > What changed in Patterna, newest first: engine 0.3.3 (round 8: the next-item model, content retrieval, real-time sessions) and SDKs 0.4.1 are live. Newest first. Each entry says what changed and what it means for you. ## 2026-10-14: engine 0.3.3 (round 8) - Hosting: Patterna moved to its own server on 15 October 2026 with its own domain. The API is `https://api.patterna.nl/v1`, the panel and these docs `https://patterna.nl`; staging is `api-staging.patterna.nl` and `staging.patterna.nl`. The previous `*.orven.am` names keep working (the API names as aliases, the panel names as redirects) so nothing built against them breaks; update your base URL when convenient. - Engine and contract 0.3.3 (`engine_version` on `/health`). New generator `vector_retrieval`: per request the engine builds one query vector from what the person looked at (the session's items in every mode, the recent positives in personalized mode, with `session_items`' weights) and reads the `params.k` nearest items by content (default 100, at most 500) from a vector index over the catalog's embeddings; they join the candidates with their cosine as the score, so an item nothing else proposed can be recommended. Reason `similar`. `model_versions` names the embedding model as `embeddings`. - Real-time session state: with Redis configured, a session's reports (`POST /interactions`) and signed `rec_*` / `item_engaged` events are kept for two hours with the session's running content vector, and the next slate of the session reflects them. Without Redis, or when it is down, the session is read from the log as before. Nothing new to send; `context.session_items` still comes first. - Flow editor: the generator with its `k`; the signals cards and docs describe it in English and Dutch. - New generator `sequence_model`: a small neural network trained every night on the order in which people open items, exported to ONNX and evaluated inside the engine (ONNX Runtime, no Python at serve time). It reads what this session looked at and, for personalized users, their last 50 items, and scores its best `top_items` (default 100) as the likely next ones, a shortlist that sits above every other item like `user_bayes`'s. Every mode, reason `similar`. The jobs activate a model only when it beats the plain path rule on held-out data; without a model or without the ONNX Runtime library on the engine host the generator scores nothing and the flow serves as before. `model_versions` carries `sequence`. - Nothing new to send; the contract adds the kind to the generator enum only. - A storage failure on the key or session lookup now answers `503 unavailable` with `Retry-After: 1` instead of `401 unauthorized`, and a serve request whose safety net could not read answers the same instead of `500`. Read 503 as "retry or fall back", never as a bad key. The SDKs already fall back on any non-200 from `recommend`. - The engine's 60-second catalog refresh reads only what changed since the last one (new, changed, unpublished, deleted and re-tagged items), so a large catalog no longer costs a latency spike once a minute or gigabytes of memory; the first request after a start still builds the cache once. Several engine instances may now start with `AUTO_MIGRATE=true` at the same time (the migration takes a lock). ## 2026-10-14: engine 0.3.2, the audit round (r8-issues) - Flow switch `ranker.gap_blend` (default true): a flow can turn the round-7 return-visit blend off, so a course or a playlist keeps its session weight on every visit. In the flow editor's ranker node and the signals grid; the gap bucket is still logged. - The gradient-boosted ranker reads the visit gap (`ctx:gap:visit|hours|days|weeks|unknown`) as a context feature on both sides, so it can tell a return visit from an in-session request. Replay numbers in the Gorse comparison, "Round 8". - `ranker.kind: learned_linear` now applies the nightly ranker's `feature_weights` (the item outcome rates fitted beside the generator multipliers), logged per candidate as `learned_features`; until now only the per-generator weights were read. - Settings: the Learning window (`learn.history_days`, 30 to 730 days; empty = deployment default) is edited on the Recommendations tab. - Example mode: the attribution card works on the example set. The demo seed and the weekly twin write `generator_attribution` rows flagged synthetic (engine migration 0020), and `GET /proof/attribution?include_synthetic=true` returns them, never mixed with real rows. - Imports keep an editor's label: the jobs' item sink preserves `attributes.editor_label` across runs, as the engine does on `PUT /catalog/items`. - Contract: `contracts/schema.sql` now carries the notifications, key usage and account tables (memberships, invites, password resets, sign-in attempts, mail outbox). ## 2026-10-13: SDKs 0.4.1 - SDKs 0.4.1 (Go, TypeScript, Python): `GetItem(ctx, kind, id)` / `getItem(kind, id)` / `get_item(kind, id)` reads one catalog item (`GET /catalog/items/{external_id}?kind=`, ingest key) with its `attributes`, `tags`, `status` and `enrichment`, so a backend can hydrate product cards from the catalog without a raw HTTP call. New sentinel `ErrItemNotFound` (`item_not_found`, 404, catalog) next to `ErrUnknownItem` (`unknown_item`, 422 from `/interactions`); the SDK docs had the two swapped. No wire changes: 0.4.0 keeps working. Install: npm `patterna-sdk@0.4.1`, Go `go get github.com/payam-h/patterna-sdk/go@v0.4.1` (tag `go/v0.4.1`), Python from tag `v0.4.1`. ## 2026-10-14: engine 0.3.2 (round 7) - Engine and contract 0.3.2 (`engine_version` on `/health`). New generator `user_to_user`: what the person's nearest users (learned nightly from the same factorisation as `user_bayes`) did last, personalized mode only, weight off by default; its params `recent_items` and `half_life_days`. Reason `people_like_you` now also names it. - Return visits: the engine logs the bucketed time since the person's previous visit (`gap` in the impression context: `visit`, `hours`, `days`, `weeks`, `unknown`) and, when a flow carries `session_items` next to a long-term generator, fades the session signal and raises the long-term ones on a return after hours or days. The blended weights are logged with the slate; nothing new to send. - `session_items` defaults to `sequence: both`: the directed next-item shares where a session item has them, its neighbours otherwise; `next` and `neighbours` stay selectable. - Exclusions read the whole history: `exclude_consumed_days` goes up to 3650 (0 keeps the rule off), fed by a nightly `user_consumed` set. Cold items reach `item_to_item` through their nearest established items by content. - The learned gbdt ranker reorders the linear ranker's top 60 inside their score range instead of rescoring the whole pool; the ALS shortlist keeps every listed item above every unlisted one. On the public MovieLens benchmark the round-3 flow goes from 0.086 to 0.12 HitRate@10, the in-session flow to 0.21–0.22, return visits over an hour from 0.083 to 0.10 (offline, no live A/B yet). - Settings: `learn.history_days` (30–730) sets how far back the collaborative learners read. ## 2026-10-13: engine 0.3.1, SDKs 0.4.0 published - Engine and contract 0.3.1 (`engine_version` on `/health`). The contract now describes `429 quota_exceeded` with its `details` (`quota`, `used`, `resets_at`), makes `occurred_at` on interactions optional (the engine stores the receive time when absent), and says what the code does for `weekday` (UTC when absent), `tenure_days` (logged as a bucket, not ranked on) and `member_of` (every mode). The OpenAPI file is binding; if a page disagrees, the page is wrong. - SDKs 0.4.0 are installable: npm `patterna-sdk@0.4.0` (unscoped), Go `go get github.com/payam-h/patterna-sdk/go@v0.4.0` (tag `go/v0.4.0`), Python from tag `v0.4.0` (not on PyPI, by decision). They type `weekday`, `subscription_tier`, `tenure_days`, the catalog attributes and the new events. - Docs: one version scheme (engine 0.3.1, SDK 0.4.0); the Elvou import page is no longer public; the glossary gains round, tenant seed, Example mode, `SERVE_BUDGET`, `device_resort`, `blocks_shown`, pre-register, minimum detectable effect, shadow (status versus request flag), quota and `baseline_rate`; `llms-full.txt` has absolute links and a header listing its sections. ## 2026-10-13: engine 0.3.0 (round 5), the signals The engine now reads every input listed on [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals), and nothing else. Contract 0.3.0 (unchanged version; the additions are optional fields), engine migration 0012. - **Request context.** `weekday` (0 = Monday), `subscription_tier` and `tenure_days` next to `hour_bucket`, `platform`, `region` and `session_items`. Bad values answer `400`. The context is logged with the impression for the learners and never stored on the user. - **Catalog attributes the engine reads.** `format`, `duration_min` or `reading_time_min`, `difficulty`, `author`, `available` or `in_stock`, `expires_at`, `starts_at`, `repurchase_days`, next to `region` and `phase_fit`. - **Hard rules, the same in both arms.** An item past `expires_at`, an event past `starts_at` and a product with `available: false` are never served (`filters.hide_unavailable`, on by default). `min_slots` counts on them. - **Personal exclusions, relaxed when nothing else is left.** Dismissed or unpinned items stay out for `exclude_dismissed_days` (30), completed items for `exclude_consumed_days` (30, personalized), an item that paywalled a free user for `paywall_cooldown_days` (7), a bought product inside `repurchase_days` (30; the item's own attribute wins). The fallback fill honours them too. - **Generators `time_fit` and `format_fit`.** The item's tap rate in the request's time bucket (else a duration rule), and the user's learned format preference (personalized). Both are silent: they move scores and never name a reason. - **`filters.relevance_floor`** (0 to 1, off by default): on an anchored request only items similar enough to the anchor count toward `min_slots`, judged the same in both arms, so a block with nothing related hides. - **Truthful reasons.** A slot names its winner only when the winner's own signal is positive; "trending" needs the surface's own log (before that it says "popular"), "people like you" a real persona, "new" an item under 30 days. Otherwise the new reason key `recommended` ("Aanbevolen" / "Recommended"). See [Reasons](https://staging.patterna.nl/docs/reasons#the-truthfulness-rule). - **`pairs_with` only where it is true**: a recipe or a workout with a product, a community with a document, each with its own default text. Other cross-kind anchors say `more_about_tag`. - **`curated` is a boost by rank** (the first item +2), not a z-score; a kind whose catalog has one collection is no longer capped per collection. - **Ranker features by name** in `gbdt`: `engaged_rate`, `pullback_rate`, `activity_rate`, `purchase_rate`, the time-bucket tap rate, the hour, the weekend and the platform. Older models keep scoring. - **Events** `community_post_created`, `community_reply`, `community_reaction` feed a community's activity rate. The learner's weight for every event, including `push_opened`, community participation, search picks and orders per product, is now listed in [Event types](https://staging.patterna.nl/docs/reference-event-types). - **Products from a shop.** The jobs sync a Shopify store's public `products.json` feed (text, tags, price, stock, image; no API token) and read a Shopify orders export into co-purchase pairs without reading any customer field. See [Go live in six steps](https://staging.patterna.nl/docs/go-live-in-six-steps#products-from-your-shop). - **SDK 0.4.0** types the new context fields, the catalog attributes, builders for the new events, the new flow filters and generators, and `recommended`. SDK 0.3.0 still works with this engine: it sends the new context fields in `context` as given. - Docs: a new page, [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals); the event reference gets a weight per event (fixed in the learner, not a per-tenant setting); the flow config reference gets the ten generators and the new filters; trending is described as per surface (all locales together), which is what it always was. ## 2026-10-13: engine 0.3.0 (round 4) and SDKs 0.3.0 - Contract 0.3.0; `engine_version` on `/health` reports `0.3.0`. - `context.region`: one of the region names you configure in Settings (coarse, such as a province; never coordinates; `400 unknown_region` otherwise). Items carry a `region` too, and `filters.region_boost` (0.3 by default) lifts same-region items; other items are never hidden. - `context.session_items` and the `session_items` generator: neighbours of what the session just looked at, in every mode. - Trending counts a tap followed by an engaged view three times; `engaged_rate` per item. - `ranker.bandit`: a Thompson-sampling mixture over the generator weights, fitted nightly per surface; the drawn weights are logged, so propensities stay exact. Never in the control arm. - No exploration on slates with `measured: false`. - A monthly request quota per plan: a warning at 90 %, then `429 quota_exceeded`. - SDKs 0.3.0 (Go, TypeScript, Python): `control_item_ids`, `member_of`, `control_source`, `fill_count` and `fill`, `shown` on every result (a `shown: false` slate is never abandoned), `mode_required` and `not_shown` errors, coach product events, `patterna surface apply`, ledger by block. Go: `go get github.com/payam-h/patterna-sdk/go@v0.3.0`; TypeScript and Python install from the repository at tag `v0.3.0`. ## 2026-10-13: engine 0.3 (contract 0.2.0) Released. The contract is version 0.2.0; `engine_version` on `/health` reports that API version. - Anchored control: control serves, in order, your `context.control_item_ids` (up to 50, no surface setting needed), else the surface's `fallback.by_anchor` list for the anchor (an object from anchor item id to item ids), else the surface's list. The request's rules apply (the anchor, `exclude_item_ids`, joined communities, kinds, premium, locale); newest items fill the rest. The answer says which in `control_source` (`request`, `by_anchor`, `surface`). - `not_shown` slates count in their arm as served with zero success (intention to treat), in both arms; a `shown: false` answer carries no items, and abandon on it answers `409 not_shown`. `min_slots` is counted before the arm, from the items that pass the request's rules. - Fallback fill: a treatment slate that comes out short is filled from the editorial list, like control. `fill_count` is on the answer and the impression; each filled slot carries `fill: true` and propensity 1 in the trace. - `stratify_by: "persona"` is applied: the arm is drawn within each persona; users without one form their own stratum. Optional `strata_shares` per stratum. - `readable` follows the power calculation (`readable_sample` per arm) instead of a fixed 200 units. - Per-block ledger rows (`by=block` or `block=`) when interactions carry `block`. - `model_versions` on every impression, in trace and export. - Learning: the slate learners train on arms `treatment` and `none`, never control, from the interactions you report (joined to the slate by `request_id`) and signed `item_completed` events for served items; taps and dismissals on served slates also reach the per-user and per-item models. Filled slots are never ranking evidence. - Opt-out (`PUT /users/{id}/mode` with `non_profiled`, or `consent_changed`): the request deletes the user row, keeps only a salted hash as the opt-out marker and files a deletion request holding only that hash; the hourly job unlinks the user's impressions (interactions stay with their slate) and deletes events, assignments and the shortlist. Serve treats a personalized or cohort request for that id as `non_profiled` and logs it without the id (audited once a day as `serve.optout_downgrade`) until the mode is set again. - `PUT /users/{id}/profile` without `mode`: an unknown user gets `422 mode_required`, an opted-out user `422 mode_conflict`; a known user keeps the current mode. - `context.member_of`: communities the user already joined are excluded in every mode. - Events `coach_product_shown` and `coach_product_tapped` with `product_id`, `conversation_id` and `position`. - `pairs_with` stays the reason for any cross-kind anchor; limiting it to a recipe anchor and a product is not built. Its default text names a recipe, so override it with a reason template. (Replaced in rounds 4 and 5: `pairs_with` is now said only per pairing.) - `phase_fit` is sent only when the tenant turns `phase_tags` on (off by default). - Unknown routes answer `404` JSON with `code: not_found`. - `engine quickstart`: runs the migrations, creates a `demo` tenant with 40 example items, a live `home` surface and one key per scope, and prints the first three calls. - SDK 0.2.0 has no typed fields yet for `control_item_ids`, `member_of`, `fill_count` and `control_source`. - Docs: every page describes engine 0.3 as live; the "engine 0.3" markers for coming features are gone. ## 2026-10-13: engine 0.2 and SDKs 0.2.0 Released (release `7a7683e`). ### Engine - Deadline: header `X-Deadline-Ms` or body `deadline_ms` (10 to 10000); the engine's own budget is 120 ms by default. See [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger). - `POST /recommendations/{request_id}/abandon` answers `200` and flags the slate `client_abandoned`; it stays in its arm and counts in the fallback rate. - A UUID you send as `X-Request-Id` becomes the slate's `request_id`. - `assignment_unit` (64 hex characters, non_profiled only): one holdout arm per person. See [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors). - `context.measured`; `context.anchor_item_id` resolved across kinds with the reason key `pairs_with`; `more_about_tag` split from `matches_goals`. - Reason templates keyed by reason key or generator kind; placeholders `{anchor_title}`, `{tag}`, `{segment}`; bad templates answer `400 invalid_reason_template`. See [Reason keys](https://staging.patterna.nl/docs/reference-reason-keys). - Exploration also runs on slates outside a holdout (arm `none`); control is never explored. - `X-Patterna-Tester: 1` puts a request on a surface's testers path (`422 bad_tester_id` otherwise). - Surface settings `min_slots` (the slate comes back with `shown: false`) and `device_resort` (`422 displayed_position_required`). - Interactions: every item must be in the slate (`422 unknown_item`), kind and served position come from the logged slate, optional `block`. - Events judged one by one with a `code` per rejection (`pii_rejected`, `invalid_signature`, `no_signing_secret`, …); the batch still answers `202`. - Catalog: `GET /catalog/items?changed_since=`, `DELETE /catalog/items/{external_id}` (`200` with counts), `locales` per item. - Signing secrets are `pss_` strings used as is; `POST /settings/signing-secret` creates the first one. A secret from before this release has no `pss_` prefix: rotate once. - Webhooks: PostHog with `X-Patterna-Token`; Shopify at `/webhooks/shopify/{tenant}`, HMAC only (`401 webhook_not_configured`, `401 bad_signature`). - Metrics `second_item_rate`, `join_rate` (community surfaces only, `400 metric_not_applicable`) and `engaged_after_tap`; surface metrics gain `fallback_rate`, `client_abandoned_rate`, `unmeasured_share`. ### SDKs (Go, TypeScript, Python 0.2.0) - `Abandon` / `AbandonSync`; `RecommendWithFallback` reports abandonment itself. - A 120 ms deadline sent inside the 150 ms timeout (`Config.RecommendDeadline` in Go). - `RecommendMany` for several surfaces on one screen; `WithTester()`; `Measured` and `AssignmentUnit` on the request. - Catalog: `ListItems`, `ItemsChangedSince`, `DeleteItem`; `CreateSigningSecret`. - An `admin` package and a `patterna` CLI for surfaces, flows, experiments and activation, so flow configs can live in your repository. - Signing keys the HMAC with the secret string (`pss_` secrets). - Published from `payam-h/patterna-sdk`: Go tag `go/v0.2.0` (`go get github.com/payam-h/patterna-sdk/go@v0.2.0`). TypeScript (`patterna-sdk`) is not on npm yet and Python (`patterna-sdk`) is not on PyPI; both install from that repository at tag `v0.2.0`. ### Docs - Every page now describes engine 0.2 as live; the "engine 0.2 is next" markers are gone. What engine 0.3 adds is marked "engine 0.3". - One budget everywhere: the engine's default is 120 ms. - Corrected: exploration outside a holdout, reason placeholders, `trending` per surface and locale, the PostHog destination status, connector-minted ids (keyed under a secret Patterna holds), the coach event fields, SDK installation. ## 2026-10-13: earlier the same day ### Engine - Built and smoke-tested locally against a real customer catalog export (210 items): catalog upsert, profiles, recommendations in all three consent modes, interactions, experiments with sticky arms, the ledger, trace, export and deletion. - Exploration with sequential Plackett-Luce sampling and exact propensities. Caps (per collection, premium) and the diversity penalty (`filters.diversity`) are applied inside the sampler. - Rankers: `linear`, `learned_linear` and `gbdt`; `lambdamart` is refused. - Ledger intervals clustered on the randomised unit, with an SRM check. The primary metric is `tap_rate`. - Deletion is synchronous: `DELETE /users/{id}` removes the rows before it answers. - Signing-secret rotation: `POST /settings/signing-secret/rotate`. - `POST /experiments/{id}/end`. - MFA enrolment and confirmation, panel users with roles and invite tokens. - Per-key token-bucket rate limits: serve 200 per second (burst 400), ingest 50 per second (burst 100). - Optional Redis as a shared cache across engine instances (`REDIS_URL`); without it each instance caches on its own. - A load-test harness for the p99 budget (`cmd/loadtest`). - Not in this release: the S3 export target (exports go to a local directory) and the LightGBM ranker. ### SDKs - TypeScript SDK `patterna-sdk`: serve and ingest client, typed errors, 150 ms / 5 s budgets, one retry for idempotent calls, consent gate, opaque-user-id checks, `withFallback`, offline `EventQueue`, HMAC signing. Not published to npm yet. - Go SDK `github.com/payam-h/patterna-sdk/go`: the same, plus a circuit breaker per scope and fire-and-forget interaction reports drained on `Close`. ### Jobs - LLM layer: Anthropic and OpenRouter behind one interface, pinned models per job, a daily cost ceiling per tenant, a content-hash cache and a 30-day audit. - Enrichment: tagging into the tenant taxonomy with health-claim review, embeddings, the gold set, and reason-template drafts saved as inactive flow versions. - Learning: per-persona and per-user models, item neighbours, the learned linear ranker and the gradient-boosted ranker, written nightly. - Connectors: PostHog, GA4 via BigQuery, Shopify, quiz CSV and a customer database import. - Maintenance: deletions, retention, the daily LLM cost ledger. - Scheduler with `/healthz`, graceful shutdown, and a per-job lock so two workers never run the same job. ### Contract - `contracts/openapi.yaml` 0.1.0 fixed without semantic change: descriptions that broke parsing, and `nullable: true` replaced by OpenAPI 3.1 `type: [T, 'null']`. Regenerate anything built from the old file. - The `raw_payloads` table was dropped. Ingest stores no raw payloads; only the allowed fields of each event are kept. ### Docs - The Docs hub in the panel: concepts, guides, connector setup, API pages and reference, in English and Dutch. - One API host everywhere: `https://api.patterna.nl/v1`. - New pages: [Flow config reference](https://staging.patterna.nl/docs/reference-flow-config), [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger), [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors). - Corrected: key prefixes (`pk_s_`, `pk_i_`, `pk_a_`), `mode_conflict` is `422`, the full error-code list, the signing model and the secret, positions start at 0, reason overrides are keyed by reason key, deletion is synchronous, the user endpoints take an ingest key. ## 2026-10-11 ### Connectors - PostHog mapping: `content_viewed`, `content_engaged`, `content_scroll_depth`, `community_joined` and `community_left` are mapped; cycle, breathing, location and screen events and all person properties are not ingested. See [Event types](https://staging.patterna.nl/docs/reference-event-types). --- # Status URL: https://staging.patterna.nl/docs/status > What runs where, which versions are deployed, the job schedule, and what is built or not built. What runs where today. Version numbers come from the running services. ## Services | Service | What | Port | Version | Health | |---|---|---|---|---| | Engine | one Go binary: gateway, ingest, catalog, serve, proof, admin, webhooks | 5300 | `see /health` | `GET https://api.patterna.nl/v1/health` | | Jobs worker | Python 3.12: learning, enrichment, connectors, deletions, retention, cost ledger, alerts, reports | 5320 | `see the jobs worker /healthz` | `GET /healthz` on the worker | | Panel | SvelteKit control panel and these docs | 5310 | `see the panel /healthz` | `GET /healthz` on the panel | Check the engine yourself: ```curl curl -sS https://api.patterna.nl/v1/health ``` ## Addresses | What | Address | |---|---| | API | `https://api.patterna.nl/v1` | | Docs and panel | `https://patterna.nl` | | Contract | `https://patterna.nl/docs/openapi.yaml` | These are the only public addresses. Keys for a separate staging environment are issued per customer with the environment's own address. Self-hosting artefacts exist: container images for the engine, panel and jobs, a Docker Compose file for one host and a Helm chart for Kubernetes, with the runbook section and a measured capacity baseline. Ask for them if Patterna must run on your own infrastructure. ## Stores | Store | Status | |---|---| | Postgres 17 + pgvector | required; holds everything, with row-level security per tenant. The precomputed candidate tables (user shortlists, item neighbours) live here too. | | Redis | optional shared cache. Without `REDIS_URL` each engine instance keeps its own in-process cache (surfaces 60 s, aggregate answers 45 s). With it, the 45 s aggregate answers (ledger, surface metrics, users summary) are shared across instances. Nothing else needs Redis. | | S3-compatible bucket | not built; exports land in the engine's export directory. | ## Schedule The jobs worker runs these entries for every tenant. Times are UTC. | Entry | When | Does | |---|---|---| | `partitions` | daily 01:00 | creates monthly partitions for this month and the next three | | `deletions` | hourly | processes deletion requests that arrived as `user_deleted` events. `DELETE /users/{id}` does not wait for this: it deletes at once. | | `connectors` | every 15 min | runs each tenant connector whose own interval has passed | | `enrich` | hourly | tags and embeds pending items | | `cost_ledger` | daily 00:15 | rolls up LLM spend; warns at 80% of the daily ceiling | | `learn_nightly` | daily 02:00 | in order: embeddings, the user model and item similarity (with co-purchase pairs), tag affinity and personas (with format preferences and purchases), item signals (time profiles and item features), then per surface in testers or live the persona model with SNIPS, the learned linear ranker, the gbdt ranker and the bandit posteriors, then the month's ledger snapshot and generator attribution. A step whose inputs did not change is skipped (for at most 7 days); marks deletions retrained | | `retention` | daily 03:00 | removes data past its retention period per data class | | `ledger_monthly` | 1st of the month, 04:00 | writes the ledger snapshot for the previous month | | `twin_weekly` | daily 05:00 | refreshes the twin curve per surface | | `analyst_notes` | weekly | writes the analyst note on the overview | | `workspace_deletion` | hourly | deletes a workspace whose seven-day notice has passed | | `alerts` | every 5 min | sends alert notifications as email and Slack messages | | `lab_runs`, `lab_ai` | every 2 min | runs queued simulations and AI proposals | ## Built Everything below runs on engine 0.3.3. `engine_version` on `/health` reports that number (it is also the contract version), next to the build in `release`. The current SDKs are 0.4.1. ### Retrieval at scale and real-time sessions (engine 0.3.3) - `vector_retrieval`: a generator that is also a candidate source. Per request the engine builds one query vector from what the person looked at (the session's items in every mode, the recent positives when personalized) and reads the k nearest items by content from the vector index over the catalog's embeddings; they join the candidates with their cosine as the score. Reason `similar`. `params.k` 1 to 500, default 100. - Real-time session state: with Redis configured the session's reports and signed events are kept for two hours with the session's running content vector, and a slate requested right after a tap reflects it. Without Redis, or when Redis is down, the session is read from the log as before. Nothing new to send. - Numbers (100,000 items, 50,000 users, one engine instance, offline MovieLens replay): the repository's docs/LOADTEST.md and docs/GORSE_COMPARISON.md, "Round 8". ### The signals (engine 0.3.1) - The request context `weekday`, `subscription_tier` and `tenure_days`, next to `hour_bucket`, `platform`, `region` and `session_items`; logged with the impression, never on the user. See [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals). - Catalog attributes read by the engine: `format`, `duration_min`/`reading_time_min`, `difficulty`, `author`, `available`/`in_stock`, `expires_at`, `starts_at`, `repurchase_days`. - Hard rules in both arms: expired items, past events and unavailable products are never served (`filters.hide_unavailable`). - Personal exclusions, relaxed when nothing else is left: `exclude_dismissed_days` (30), `exclude_consumed_days` (30), `paywall_cooldown_days` (7), `repurchase_days` (30). - Generators `time_fit` and `format_fit`, silent in reasons. - `filters.relevance_floor` for anchored surfaces, judged the same in both arms. - Truthful reasons and the reason key `recommended`; `pairs_with` only for a recipe or workout with a product, or a community with a document. - `curated` as a boost by rank. - `gbdt` reads the item features and the request's hour, weekend and platform. - Events `community_post_created`, `community_reply`, `community_reaction`. - Jobs: the Shopify storefront sync (public `products.json`, no token), on the connector schedule per tenant (`shopify_storefront`, `every_minutes`) or on request, and the orders export reader (co-purchase pairs, no customer field). ### Context, regions and the quota - `context.region` with tenant-configured regions and `filters.region_boost`; `context.session_items` and the `session_items` generator; `ranker.bandit` with logged weights; engagement-weighted trending; no exploration on unmeasured slates; a monthly request quota (`429 quota_exceeded`). ### Earlier - Serve in three consent modes, with generators `trending` (per surface, all locales together), `curated`, `item_to_item`, `content_affinity`, `persona_bayes`, `user_bayes`, `user_to_user`, `surface_specific`, `session_items`, `time_fit` and `format_fit`. - Rankers `linear`, `learned_linear` and `gbdt`. `lambdamart` is refused. - Plackett-Luce exploration with exact propensities, caps and `filters.diversity` inside the sampler. Exploration runs on treatment slates and on slates outside any holdout (arm `none`); control is never explored. - Holdouts with sticky arms, `stratify_by: "persona"` (default `none`) with optional `strata_shares`, the ledger with clustered intervals and SRM, weekly rows, per-block rows (`by=block`), power, export, ending an experiment. A row is `readable` when each arm reaches the experiment's `readable_sample`. - Anchored control: control serves the request's `context.control_item_ids`, else the surface's `fallback.by_anchor` list for the anchor, else the surface's list, under the request's rules; the answer's `control_source` (`request`, `by_anchor`, `surface`) says which. - `shown: false` answers carry no items, count in their arm as served with zero success (intention to treat) and cannot be abandoned (`409 not_shown`). `min_slots` is counted before the arm. - Fallback fill: a short treatment slate is filled from the editorial list; `fill_count` on the answer and the impression, `fill: true` and propensity 1 on each filled slot in the trace. - `model_versions` on every impression, in trace and export. - Learning from served slates: the slate learners train on arms `treatment` and `none` (never control) from the interactions you report, joined to the slate by `request_id`, plus signed `item_completed` events for served items. - `context.member_of`: communities the user already joined are left out, in every mode. - A client deadline (`X-Deadline-Ms` or `deadline_ms`) and a default `SERVE_BUDGET` of 120 ms. See [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger). - `POST /recommendations/{request_id}/abandon` (`client_abandoned`), with a client-minted `X-Request-Id`. - `assignment_unit` (64 hex characters) for one arm per person across sessions. See [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors). - `X-Patterna-Tester: 1` for testers from your own roster. - `context.measured` on the request; `min_slots` (with `shown: false`) and `device_resort` on the surface. - Interactions checked against the slate (`422 unknown_item`), kind and position from the logged slate, an optional `block`. - Metrics `tap_rate`, `second_item_rate`, `join_rate` (community surfaces), `engaged_after_tap` and `purchase_rate` (product surfaces); surface metrics `fallback_rate` including abandoned slates, `client_abandoned_rate` and `unmeasured_share`. - Reasons: templates keyed by reason key or generator kind, placeholders `{anchor_title}`, `{tag}` and `{segment}`, reason keys `pairs_with`, `more_about_tag` and `recommended`, `400 invalid_reason_template`. `phase_fit` in slates only when the tenant turns `phase_tags` on (off by default). - Catalog upsert, status, tags, `locales` per item, a `changed_since` listing and `DELETE /catalog/items/{id}`. - Events judged one by one, with an allowlist per type; PII field names rejected per event (`pii_rejected` in `rejected`). No raw payload is stored. - Webhooks: PostHog with `X-Patterna-Token`, Shopify at `/webhooks/shopify/{tenant}` with HMAC only. - Immediate deletion with `DELETE /users/{id}`; export and trace per user. Opt-out (`PUT /users/{id}/mode` `non_profiled` or `consent_changed`) deletes the user row in the request and keeps only a salted-hash marker; the hourly job unlinks impressions (interactions stay) and deletes events, assignments and the shortlist. Profiled requests for an opted-out id are served as `non_profiled` without the id (audited as `serve.optout_downgrade`). - `PUT /users/{id}/profile` without `mode` for an unknown user: `422 mode_required`. - Events `coach_product_shown` and `coach_product_tapped` with `product_id`, `conversation_id` and `position`. - Scoped keys (`pk_s_`, `pk_i_`, `pk_a_`), rate limits, signing secrets (`pss_…`, used as the string; create and rotate), MFA, roles, two-person rule, audit log. - Surface status `off` refused with `409 surface_off`. - Prometheus metrics at `/metrics`, a load-test harness for the p99 budget. Unknown routes answer `404` JSON with `code: not_found`. - `engine quickstart`: runs the migrations, then creates a `demo` tenant with 40 example items, a live `home` surface and one key per scope, and prints the first three calls. - SDKs 0.4.1 (Go, TypeScript, Python): `Abandon`, `RecommendMany`, a tester option, the deadline, control lists and `member_of`, `shown`, fill and control source, coach product events, an admin package and a `patterna` CLI with `surface apply`, typed fields for `weekday`, `subscription_tier` and `tenure_days`, the catalog attributes and the new events, and (0.4.1) `GetItem` with `ErrItemNotFound`. Install: npm `patterna-sdk@0.4.1`; Go `go get github.com/payam-h/patterna-sdk/go@v0.4.1` (tag `go/v0.4.1`); Python from tag `v0.4.1` of `payam-h/patterna-sdk` (subdirectory `python`; not on PyPI, by decision). ## Not built Engine: - S3 export target. - A shared Redis cache for anything other than the aggregate answers. - The proof export carries impressions and interactions only. It lacks the clustering unit, `measured`, `not_shown`, `tester`, `fallback`, blocks, the experiment window and the events `join_rate` needs, so a ledger row cannot yet be recomputed from the export alone. - No revenue figure: `revenue_cents` is not computed. - `difficulty`, `author`, `app_version` and `tenure_days` are stored but not ranked on yet. - When a request has no `weekday`, `time_fit` uses the engine's clock in UTC, not your timezone. Jobs: - Parquet PostHog exports and the GA4 intraday table. - The event weights are fixed in the learner, not a per-tenant setting. SDKs: - Python `patterna-sdk` is not on PyPI, by decision: install it from tag `v0.4.1` of `payam-h/patterna-sdk` (`pip install "patterna-sdk @ git+https://github.com/payam-h/patterna-sdk@v0.4.1#subdirectory=python"`). npm (`patterna-sdk@0.4.1`) and Go (`go/v0.4.1`) are published. See the [Changelog](https://staging.patterna.nl/docs/changelog) for what changed and when. --- # llms.txt URL: https://staging.patterna.nl/docs/agents/llms > Compact machine-readable index of Patterna: what it is, the API in five lines, the rules, and a link to every page and the contract. > Patterna is a multi-tenant recommendation service. Your backend asks for a slate per surface and gets item ids, a reason per slot and a request id; it reports seen, click and dismiss by request id. Every surface runs a sticky holdout, and the ledger reports the difference per impression with a 95% interval clustered on users. Three consent modes; `non_profiled` stores no user id. - API base: `https://api.patterna.nl/v1`. Auth: `Authorization: Bearer <key>`. Tenant comes from the key. Key prefixes: `pk_s_` serve, `pk_i_` ingest, `pk_a_` admin (passes every scope). - Contract (OpenAPI 3.1): https://staging.patterna.nl/docs/openapi.yaml. Rendered reference: https://staging.patterna.nl/docs/api. - Scopes: `serve` (`POST /recommendations`, `POST /interactions`), `ingest` (`POST /events`, `/catalog/*`, `/webhooks/*`, `PUT /users/{id}/mode`, `PUT /users/{id}/profile`, `DELETE /users/{id}`), `admin` (reading users, surfaces, flows, experiments, `/proof/*`, keys, audit, settings). `GET /health` needs no key. - Errors: JSON `{code, message, request_id, details}` with stable codes. Serve: `400 bad_request` (also any unknown JSON field), `401 unauthorized`/`wrong_scope`, `404 unknown_surface`, `409 surface_off`, `422 mode_conflict`, `422 no_candidates`, `429 rate_limited`/`quota_exceeded` (show your own list); interactions: `404 unknown_request`, `422 unknown_item`. Every response has `X-Request-Id`. `422 mode_required` (profile without `mode` for an unknown user), `409 not_shown` (abandon on a `shown: false` slate). Unknown routes answer JSON `404 not_found`. - Idempotency: events on `event_id`; catalog upserts are rerunnable. Never retry `POST /recommendations`: each call logs an impression. - Rules: never send email, name, IP, device id, free text, coordinates or health values. User ids are random ids you mint. `non_profiled` requests carry `session_id` and no `user_id`. Keys and the signing secret stay on the backend. Report the served `position` (0-based) with every interaction; the engine takes `kind` and the served position from the logged slate. Give up after 150 ms (the SDKs send `X-Deadline-Ms: 120`; the engine's own budget is 120 ms), show your own list and report the abandonment. The control arm is the surface's editorial list, not a fallback. Deletion is synchronous. Events need no signature on an ingest key; a signature, if sent, is hex HMAC-SHA256 keyed with the `pss_` secret string. - SDKs 0.4.1, from `github.com/payam-h/patterna-sdk`: Go `go get github.com/payam-h/patterna-sdk/go@v0.4.1` (tag `go/v0.4.1`); TypeScript `npm install patterna-sdk@0.4.1` (unscoped; there is no `@patterna/sdk`); Python from the tag (not on PyPI, by decision): `pip install "patterna-sdk @ git+https://github.com/payam-h/patterna-sdk@v0.4.1#subdirectory=python"`. 0.4.1 types the context fields `weekday`, `subscription_tier`, `tenure_days`, the catalog attributes and the events of engine 0.3.1. Engine and contract are 0.3.1 (`engine_version` on `/health`); the OpenAPI file is binding, a page that disagrees is wrong. - Live since engine 0.2: `X-Deadline-Ms` / `deadline_ms`, `POST /recommendations/{request_id}/abandon` (`client_abandoned`), client-minted `X-Request-Id`, `assignment_unit` (64 hex, non_profiled), `X-Patterna-Tester: 1`, `context.measured`, surface `min_slots` (`shown: false`) and `device_resort`, interactions checked against the slate, catalog `changed_since`, `DELETE` and `locales`, `pss_` signing secrets, per-event rejection codes, webhook tokens, reason templates with placeholders, `pairs_with` and `more_about_tag`, metrics `tap_rate`/`second_item_rate`/`join_rate`/`engaged_after_tap`. Exploration runs on treatment and on slates outside a holdout (arm `none`), never on control. - Live since contract 0.2.0 (engine release 0.3): anchored control (`context.control_item_ids`, else `fallback.by_anchor` for the anchor, else the surface list; `control_source` on the answer), `shown: false` with no items counted in its arm (abandon answers `409 not_shown`), fallback fill (`fill_count`, `fill: true` per filled slot), `stratify_by: persona`, `readable` from `readable_sample`, per-block ledger rows (`by=block`), `model_versions` on impressions, opt-out storing only a salted hash (the hourly job unlinks impressions, keeps interactions, deletes events), `PUT /users/{id}/profile` without `mode` for an unknown user is `422 mode_required`, `context.member_of`, `coach_product_shown`/`coach_product_tapped` with `product_id`, `conversation_id`, `position`, `phase_fit` only with `phase_tags` on, JSON 404s, `engine quickstart`. Learners train on arms treatment and none, never control. `pairs_with` only for a recipe or workout with a product, or a community with a document. - Live since contract 0.3.0: `context.region` (one of the tenant's configured region names; `400 unknown_region`), `context.session_items` and the `session_items` generator, `filters.region_boost` (0.3), `ranker.bandit` (Thompson mixture with logged weights), a monthly request quota (`429 quota_exceeded`). - Live on engine 0.3.1 ("the signals"): context `weekday`, `subscription_tier`, `tenure_days`; catalog attributes read by the engine (`format`, `duration_min`/`reading_time_min`, `available`/`in_stock`, `expires_at`, `starts_at`, `repurchase_days`); generators `time_fit` and `format_fit` (silent: never a reason); filters `exclude_dismissed_days` (30), `exclude_consumed_days` (30), `paywall_cooldown_days` (7), `repurchase_days` (30), `hide_unavailable` (true), `relevance_floor` (0); events `community_post_created`, `community_reply`, `community_reaction`; truthful reasons with reason key `recommended`. The full list: [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals). - Start here: [Integrate with your AI agent](https://staging.patterna.nl/docs/agents/integrate-with-your-ai-agent). ## Concepts - [What Patterna is](https://staging.patterna.nl/docs/what-is-patterna): Patterna is a multi-tenant recommendation service that proves its lift with a holdout, gives a reason for every slot and works without a user id. - [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals): The full list of signals: request context, catalog attributes, events with their weights, the nightly artifacts, what is refused, and the rule for what a reason may say. - [Consent modes](https://staging.patterna.nl/docs/consent-modes): The three consent modes personalized, cohort and non_profiled, and what each stores, learns from and never stores. - [Proof and the ledger](https://staging.patterna.nl/docs/proof-and-the-ledger): How the sticky holdout, user-clustered intervals, the SRM check and the readable sample make the ledger the only proof. - [Exploration and propensities](https://staging.patterna.nl/docs/exploration-and-propensities): Plackett-Luce exploration with exact per-position propensities, caps inside the sampler, and SNIPS estimates of a flow on logged traffic. - [The twin](https://staging.patterna.nl/docs/the-twin): The twin is a tenant simulator for example data and what-if curves at three truth scales; it is never proof. - [Reasons](https://staging.patterna.nl/docs/reasons): Every slot carries a reason key and a sentence per locale, said only when it is true (else `recommended`), overridable per reason key in the flow config. - [Surfaces and flows](https://staging.patterna.nl/docs/surfaces-and-flows): Surfaces move through shadow, testers, live and off; flows are versioned configs that you activate with a note and roll back by activating an older version. - [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger): Client and engine budgets, fallback versus control, abandoned requests and intention to treat: what counts in the ledger. - [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors): Random ids, hashing, PostHog distinct ids, Shopify customers and the assignment unit: one person, one id, never personal data. - [Data we never store](https://staging.patterna.nl/docs/data-we-never-store): The PII Patterna never stores, the allowlists that enforce it, retention per data class, and how deletion and export work. ## Guides - [Go live in six steps](https://staging.patterna.nl/docs/go-live-in-six-steps): The setup checklist in six steps, from connecting a data source to a live surface with a holdout and the first readable ledger row. - [Run a holdout](https://staging.patterna.nl/docs/run-a-holdout): Start a holdout on a surface, size it with the readable sample, and keep it valid until it can be read. - [Read a ledger row](https://staging.patterna.nl/docs/read-a-ledger-row): A field-by-field walkthrough of one ledger row: rates, difference, interval, sample sizes, readable flag, SRM and revenue. - [Fix a tag and approve a health claim](https://staging.patterna.nl/docs/fix-a-tag): Edit an item's tags in Catalog and approve LLM-proposed health-claim tags under the two-person rule. - [Roll back a flow](https://staging.patterna.nl/docs/roll-back-a-flow): Roll a surface back by activating an earlier flow version with a note, or switch Patterna off on your side instantly. - [Handle a deletion request](https://staging.patterna.nl/docs/handle-a-deletion-request): Delete or export one user's data by random id and follow both timestamps until rows and models are clean. - [Rotate keys and the signing secret](https://staging.patterna.nl/docs/rotate-keys-and-signing-secret): Replace a scoped API key without downtime, sign events with the pss_ secret string (with test vectors), and rotate the secret. - [Integrate with your AI agent](https://staging.patterna.nl/docs/agents/integrate-with-your-ai-agent): Step-by-step brief for a coding agent: create keys, load the catalog, set consent, recommend, report interactions, send events and verify with the trace. ## Connectors - [Backend API](https://staging.patterna.nl/docs/connector-backend-api): Call Patterna directly from your backend: the three key scopes, a curl for each, and event signing. - [SDK TypeScript](https://staging.patterna.nl/docs/connector-sdk-typescript): The TypeScript SDK for backends and Expo apps: client, backend proxy, offline queue, consent gate, fallback and signing. - [SDK Go](https://staging.patterna.nl/docs/connector-sdk-go): The Go SDK for a backend request path: timeouts, circuit breaker, fallback, async reports, and a backend drop-in. - [PostHog](https://staging.patterna.nl/docs/connector-posthog): Point a PostHog HTTP destination at Patterna; see which events map to what and which fields are dropped. - [Google Analytics 4](https://staging.patterna.nl/docs/connector-ga4): Read GA4 events from the BigQuery export with a viewer service account under the same privacy rules as PostHog. - [Shopify](https://staging.patterna.nl/docs/connector-shopify): Bring Shopify products in from the public storefront feed or a read-only Admin API token, order history as co-purchase aggregates, HMAC-verified webhooks, and the shop link parameter. - [Quiz CSV](https://staging.patterna.nl/docs/connector-quiz-csv): Import declared quiz answers by random user id and mapped columns; only declared attributes, never health values. ## API - [API overview](https://staging.patterna.nl/docs/api-overview): Base URL, scopes, error codes, idempotency, rate limits and request ids for the Patterna API. - [Serve scope](https://staging.patterna.nl/docs/api-serve): Annotated requests and responses for recommendations and interactions under the serve scope. - [Ingest scope](https://staging.patterna.nl/docs/api-ingest): Annotated requests and responses for events, catalog items, tags, status and webhooks under the ingest scope. - [Admin scope](https://staging.patterna.nl/docs/api-admin): Annotated requests and responses for users, surfaces, flows, experiments, the ledger, exports, keys and audit under the admin scope. ## Reference - [Event types](https://staging.patterna.nl/docs/reference-event-types): Every canonical event type with its envelope, props, learner weight, where else it acts and who sends it. - [Reason keys](https://staging.patterna.nl/docs/reference-reason-keys): Every reason key, its default sentence per locale, how flow overrides are keyed, and the placeholders. - [Flow config reference](https://staging.patterna.nl/docs/reference-flow-config): Every flow config field with its default and valid range, what each generator reads, and the validation errors. - [Error codes](https://staging.patterna.nl/docs/reference-error-codes): Stable error codes from the API, the SDKs and the panel, with status and what to do. - [Glossary](https://staging.patterna.nl/docs/glossary): Short definitions of the terms used across Patterna's docs, panel and API. - [Changelog](https://staging.patterna.nl/docs/changelog): What changed in Patterna, newest first: engine 0.3.3 (round 8: the next-item model, content retrieval, real-time sessions) and SDKs 0.4.1 are live. - [Status](https://staging.patterna.nl/docs/status): What runs where, which versions are deployed, the job schedule, and what is built or not built.