Patterna Docs

Ingest scope

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

Developers 6 min read View as Markdown

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"

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://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" }
      }
    ]
  }'
  • 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. 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.
  • 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.

PUT /catalog/items#

Upserts up to 500 items. The key is external_id plus kind.

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 } ]
      }
    ]
  }'
  • 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 -sS "https://patterna-api.orven.am/v1/catalog/items?kind=document&status=published&limit=100" \
  -H "Authorization: Bearer $PATTERNA_INGEST_KEY"
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.
  • 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 -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" }'

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 -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 }
    ]
  }'

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.

See also#

esc
  • Type to search every page, heading and code sample.