Ingest scope
Send events, keep the catalog in sync, receive webhooks.
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.
# Keys come from Connectors or Governance. Each is shown once and starts with pk_s_, pk_i_ or pk_a_.
export PATTERNA_SERVE_KEY="paste-the-serve-key"
export PATTERNA_INGEST_KEY="paste-the-ingest-key"
export PATTERNA_ADMIN_KEY="paste-the-admin-key"import { PatternaClient } from "patterna-sdk";
export const API_BASE = "https://api.patterna.nl/v1";
// Serve and ingest calls, plus user mode, profile and deletion, go through the SDK.
export const patterna = new PatternaClient({
baseUrl: API_BASE,
keys: {
serve: process.env.PATTERNA_SERVE_KEY,
ingest: process.env.PATTERNA_INGEST_KEY,
admin: process.env.PATTERNA_ADMIN_KEY,
},
});
// Endpoints the SDK leaves out (admin, proof, catalog reads) use plain fetch.
export async function rest<T = unknown>(
keyEnv: string,
method: string,
path: string,
body?: unknown,
): Promise<T | undefined> {
const res = await fetch(`${API_BASE}${path}`, {
method,
headers: {
authorization: `Bearer ${process.env[keyEnv]}`,
...(body === undefined ? {} : { "content-type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
if (!res.ok) {
throw new Error(`Patterna ${method} ${path}: ${res.status} ${text} (request id ${res.headers.get("x-request-id")})`);
}
return text ? (JSON.parse(text) as T) : undefined;
}package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
patterna "github.com/payam-h/patterna-sdk/go"
)
const apiBase = "https://api.patterna.nl/v1"
// newClient builds the SDK client: serve and ingest calls, plus user mode, profile and deletion.
func newClient() (*patterna.Client, error) {
return patterna.New(patterna.Config{
BaseURL: apiBase,
ServeKey: os.Getenv("PATTERNA_SERVE_KEY"),
IngestKey: os.Getenv("PATTERNA_INGEST_KEY"),
AdminKey: os.Getenv("PATTERNA_ADMIN_KEY"),
Logger: slog.Default(),
})
}
// rest calls the endpoints the SDK leaves out (admin, proof, catalog reads).
func rest(ctx context.Context, keyEnv, method, path string, body, out any) error {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, apiBase+path, rd)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv(keyEnv))
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
msg, _ := io.ReadAll(resp.Body)
return fmt.Errorf("patterna %s %s: %d %s (request id %s)", method, path, resp.StatusCode, msg, resp.Header.Get("X-Request-Id"))
}
if out == nil || resp.StatusCode == http.StatusNoContent {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}import os
import httpx
API_BASE = "https://api.patterna.nl/v1"
def client(key_env: str) -> httpx.Client:
return httpx.Client(
base_url=API_BASE,
headers={"Authorization": f"Bearer {os.environ[key_env]}"},
timeout=5.0, # ingest and admin budget; recommend passes its own 150 ms
)
serve = client("PATTERNA_SERVE_KEY")
ingest = client("PATTERNA_INGEST_KEY")
admin = client("PATTERNA_ADMIN_KEY")POST /events#
Up to 500 events per call. Idempotent on event_id. The full list of types and their props is in Event types.
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" }
}
]
}'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);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)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 setsreceived_atitself.user_idorsession_id: innon_profiledmode onlysession_idis allowed. Auser_idcontaining@rejects the event.source:backend,sdk,posthog,ga4,shopify,csvorsynthetic.context: onlylocale,app_version,platform,hour_bucket,weekday,region,subscription_tier,tenure_days. Any other key is dropped and counted indropped_fields(a personal-data name such aslatituderejects the event withpii_rejectedinstead).props: only the keys listed for the type in Event types. Any other key, and any string over 256 characters, is dropped and counted. Forrec_*events sendkindand, after a device re-sort,displayed_position.coach_product_shownandcoach_product_tappedcarryproduct_id,conversation_idandposition(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 (thepss_string as is; a secret from before engine 0.2 has nopss_prefix and must be rotated once) and test vectors are in Rotate keys and the signing secret.synthetic:truefor generated rows. They never reach the ledger.
Response 202#
{
"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 whoseevent_idwas already stored.rejected: per event, theevent_id, areason(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 stablecode(below). The rest of the batch is stored.pii_rejected: how many events were rejected for personal data.dropped_fields: pertype.prop(orcontext.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.
PUT /catalog/items#
Upserts up to 500 items. The key is external_id plus kind.
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 } ]
}
]
}'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);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)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,eventortip.title: the editorial title only.status:draft,scheduled,publishedorarchived. Defaultpublished.attributes:price_cents,currency,difficulty,duration_min,categories[],phase_fit[],image_url,url. Keys likeemail,nameoripreject the call with422 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#
{
"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 -sS "https://api.patterna.nl/v1/catalog/items?kind=document&status=published&limit=100" \
-H "Authorization: Bearer $PATTERNA_INGEST_KEY"// 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);// 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)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"]){
"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 inexternal_idorder.next_cursor: pass it ascursorfor the next page.nullon the last page.tags[].source: where the tag came from.approved: falsemarks an LLM health-claim tag waiting for an editor. See Fix a tag.enrichment.status:pending,done,failedorskipped.enrichment.updated_atis 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 -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" }'await patterna.setItemStatus("document", "doc_1842", {
status: "scheduled",
published_at: "2026-10-20T06:00:00Z",
});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
}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 -sS -X DELETE "https://api.patterna.nl/v1/catalog/items/doc_1842?kind=document" \
-H "Authorization: Bearer $PATTERNA_INGEST_KEY"Response 200:
{ "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 -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 }
]
}'await patterna.setItemTags("document", "doc_1842", [
{ tag: "magnesium", weight: 1, approved: true },
{ tag: "claim:verbetert-slaap", weight: 0.6, approved: true },
]);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
}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. 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).
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. Response: 202.