# Backend API

> Call Patterna from your backend with scoped keys, and sign the events that affect ranking.

Source: https://www.patterna.nl/docs/connector-backend-api

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://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")
```

## 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://patterna-api.orven.am/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://www.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://patterna-api.orven.am/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://www.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://www.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://patterna-api.orven.am/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://www.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://patterna-api.orven.am/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://patterna-api.orven.am/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://patterna-api.orven.am/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://www.patterna.nl/docs/rotate-keys-and-signing-secret).

## Next

- [SDK TypeScript](https://www.patterna.nl/docs/connector-sdk-typescript) and [SDK Go](https://www.patterna.nl/docs/connector-sdk-go) wrap all of the above with timeouts, fallback and the consent gate.
- [API overview](https://www.patterna.nl/docs/api-overview) covers errors, idempotency and rate limits. The full reference is at https://www.patterna.nl/docs/api.
