Recommendations with receipts

A recommendation API for your app.

Send your catalog and events, get a ranked list with a reason for every slot, and a holdout that proves what it earned.

  • +0.0%tap rate lift in the example workspace (synthetic data)
  • 0.0 msp99 ranking latency, local load test
  • 0slates in the example ledger
Holdout · illustrationreadable
+1.9 pp
95% interval +0.9 to +2.8what a readable row looks like
Why this item

“More about magnesium” · more_about_tag 0.82

How it works

Three calls from your backend.

Send items, send events, ask for a slate. Every call is JSON over HTTPS with a scoped key. The SDKs add timeouts, a fallback and a consent check.

Set up once · curl
export PATTERNA_INGEST_KEY="pt_live_..."   # from Connectors, shown onceexport PATTERNA_SERVE_KEY="pt_live_..."
  1. 01

    Send your items

    Upsert what can be recommended, up to 500 per call. Patterna keeps the id, the kind and a few attributes; body text only becomes tags.

    PUT/v1/catalog/items
    Request · curl
    curl -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",    "tags": [{ "tag": "magnesium", "weight": 1 }]  }]}'
    Response · 200
    {  "upserted": 1,  "enrichment_queued": 1}
  2. 02

    Send what people do

    Views, completions, joins: up to 500 events per call, idempotent on your own event id. An email or a name in the payload rejects the call.

    POST/v1/events
    Request · curl
    curl -X POST https://api.patterna.nl/v1/events \  -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \  -H "Content-Type: application/json" \  -d '{"events": [{    "event_id": "item_viewed:doc_1842:u_mfrggzdfmztwq2lknnwg23tpobyxe",    "type": "item_viewed",    "occurred_at": "2026-10-13T09:30:00Z",    "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe",    "source": "backend",    "props": { "item_id": "doc_1842", "collection": "recipes" }  }]}'
    Response · 202
    {  "accepted": 1,  "duplicates": 0,  "rejected": []}
  3. 03

    Ask for a slate

    Name the place in your app and the consent mode. You get item ids in order, a reason per slot and a request id to report taps against.

    POST/v1/recommendations
    Request · curl
    curl -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  }'
    Response · 200
    {  "request_id": "req_01HZX4T9W6Q2",  "arm": "treatment",  "items": [{    "item_id": "doc_1842",    "position": 0,    "reason_key": "similar_to_read",    "reason": {      "en": "Because you read 3 magnesium recipes this week"    },    "score": 0.82  }]}

Then report what the user saw and tapped with POST /v1/interactions and the slate’s request_id. Full reference: ingest and serve.

Architecture

Three parts, one database.

The SDK lives in your backend, next to your keys. Patterna is an engine, a jobs worker and this panel, all on one Postgres with row-level security. No queue, no second store to run.

Proof

Lift with an interval, or it did not happen.

The control arm is your own list. The treatment arm is the engine. Both are logged the same way, the interval is clustered on the user, and a row stays unreadable until the sample is large enough.

  1. 01ConnectItems and events arrive. Unknown fields are dropped.
  2. 02ShadowEvery request is ranked and logged, shown to nobody.
  3. 03HoldoutHalf see your list, half the engine. Sticky arms.
  4. 04LedgerA monthly row per surface, with its interval.
How this is computed
week 1week 12treatmentcontrol
Example data from the twin, not a customer.

Who it is for

Apps that respect consent. Platforms that resell it.

Health, media, retail: a rail your members tap, and a number your board believes.

  • Editors stay in charge. Curated lists, per-collection caps, premium rules, two people to approve a live change.
  • A reason on every slot. In Dutch and English, from the generator that won.
  • Numbers you can repeat. Export the ledger and recompute the interval yourself.
Go live in six steps

Get started

Start in five minutes.

  1. 1
    Sign in and make two keys. Connectors gives you an ingest key and a serve key, each shown once.
  2. 2
    Install the SDK for your backend, or use plain HTTP.
  3. 3
    Make the three calls above. Nobody sees a slate until you have read the reasons in shadow mode.
  • TypeScript npm install patterna-sdk
  • Go go get github.com/payam-h/patterna-sdk/go@latest
  • Python pip install "git+https://github.com/payam-h/patterna-sdk#subdirectory=python"

Using curl or another language? There is nothing to install: every call is JSON over HTTPS. The contract is one OpenAPI file.