# Ingest scope

> Send events, keep the catalog in sync, receive webhooks.

Source: https://www.patterna.nl/docs/api-ingest

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://patterna-api.orven.am/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://patterna-api.orven.am/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://patterna-api.orven.am/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://www.patterna.nl/docs/reference-event-types).

```curl
curl -sS -X POST https://patterna-api.orven.am/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://www.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://www.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://www.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://patterna-api.orven.am/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://patterna-api.orven.am/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://www.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://patterna-api.orven.am/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://patterna-api.orven.am/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://patterna-api.orven.am/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://www.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://www.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://www.patterna.nl/docs/connector-shopify). Response: `202`.

## See also

- [Event types](https://www.patterna.nl/docs/reference-event-types)
- [Error codes](https://www.patterna.nl/docs/reference-error-codes)
- [API overview](https://www.patterna.nl/docs/api-overview)
