Backend API
Call Patterna from your backend with scoped keys, and sign the events that affect ranking.
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; 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.
# 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"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")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 (
productionby 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_idin a body is ignored. - Every request sends
Authorization: Bearer <key>. Every response carriesX-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 -sS -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
}'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 /interactionsslate, 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 /interactionsr = 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 /interactionsIn non_profiled mode, send session_id and leave out user_id. See 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 -sS -X POST "https://api.patterna.nl/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" }
]
}'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" },
]);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
}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() # 202Budget: 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.
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'sfallback.by_anchorlist 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, fornon_profiledrequests. They are left out of the slate in every mode and are not stored; inpersonalizedandcohortmode membership also comes fromcommunity_joined/community_leftevents.fill_counton 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 withfill: true).control_sourceon editorial slates:request,by_anchororsurface, 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.
ingest: events and catalog#
Send canonical events, up to 500 per call. event_id is the idempotency key, so a resend is safe.
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_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" }
}
]
}'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);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)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.
{ "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 -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_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."
}
]
}'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);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)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/itemsfrom your content system on every create and edit, andPOST /catalog/items/{external_id}/statuswitharchivedwhen 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, andDELETE /catalog/items/{external_id}?kind=<kind>removes an item for good. The listing includes archived items; deleted ones are gone. The delete answers200withexternal_id,kind,deleted,tags_removedandreferences_removed; slates already logged keep the id.
admin: surfaces and the ledger#
curl -sS "https://api.patterna.nl/v1/surfaces" \
-H "Authorization: Bearer $PATTERNA_ADMIN_KEY"const surfaces = await rest<Array<{ key: string; status: string; active_flow_version: number | null }>>(
"PATTERNA_ADMIN_KEY",
"GET",
"/surfaces",
);
console.table(surfaces);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)r = admin.get("/surfaces")
r.raise_for_status()
for s in r.json():
print(s["key"], s["status"], s["active_flow_version"])curl -sS "https://api.patterna.nl/v1/proof/ledger?surface=home_articles&from=2026-09-01&to=2026-09-30" \
-H "Authorization: Bearer $PATTERNA_ADMIN_KEY"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");
}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)
}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 /eventstakes only aningestkey (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
signatureis 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 withno_signing_secret. - Sign on your backend. A signing secret inside an app bundle is a public secret.
The signed form:
- Take the event object as sent, minus the
signaturefield. - 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.
signature= lowercase hex HMAC-SHA256 over those UTF-8 bytes.- 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.
# 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}'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!);// 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)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.
Next#
- SDK TypeScript and SDK Go wrap all of the above with timeouts, fallback and the consent gate.
- API overview covers errors, idempotency and rate limits. The full reference is at https://staging.patterna.nl/docs/api.