API overview
Scopes, errors, idempotency, rate limits and request ids in one page.
The Patterna API is JSON over HTTPS. Every path below is relative to https://api.patterna.nl/v1. The full, rendered contract lives in the API reference. This page explains the rules that hold for every call. The OpenAPI file is binding; if a page disagrees, the page is wrong.
Authentication#
Every request carries a scoped key as a Bearer token. The tenant comes from the key, never from the body.
curl -sS https://api.patterna.nl/v1/surfaces \
-H "Authorization: Bearer $PATTERNA_ADMIN_KEY"The prefix tells you the scope: pk_s_ for serve, pk_i_ for ingest, pk_a_ for admin. Patterna stores only a SHA-256 hash of each key. The secret is shown once, when you create it. Lost a secret? Revoke the key and create a new one. See Rotate keys and the signing secret.
Scopes#
There are three key scopes. Give each system the smallest scope it needs. An admin key passes every scope; a serve or ingest key used on another scope's path gets 401 wrong_scope.
| Scope | Prefix | Paths | Who holds it |
|---|---|---|---|
serve | pk_s_ | POST /recommendations, POST /interactions | your backend, in the request path |
ingest | pk_i_ | POST /events, /catalog/items and below, PUT /users/{user_id}/mode, PUT /users/{user_id}/profile, DELETE /users/{user_id} | your backend, connectors, catalog sync |
admin | pk_a_ | GET /users/{user_id}, /users/{user_id}/trace, /surfaces and below, /experiments, /proof/*, /connectors, /keys and below, /audit, /settings | the panel, operator scripts |
| none | GET /health | anyone | |
| own authentication | POST /webhooks/posthog (X-Patterna-Token), POST /webhooks/shopify/{tenant} (Shopify HMAC) | PostHog, Shopify |
The contract groups endpoints under five tags (serve, ingest, catalog, proof, admin). The tags are for reading. Keys have three scopes: catalog calls use an ingest key, proof calls use an admin key.
The user endpoints that change state (mode, profile, DELETE) take an ingest key, because the customer's backend owns consent and deletion. An admin key works there too. Reading a user (GET /users/{user_id}, the trace) needs an admin key.
Per-scope pages: Serve, Ingest, Admin.
Responses and errors#
| Status | Meaning |
|---|---|
200 | done, body follows |
201 | created (surface, flow version, experiment, key) |
202 | accepted (events, interactions, webhooks, deletion, export). A deletion is already done when you get the 202; see Admin. |
204 | done, no body (key revoked) |
400 | bad_request: a missing or invalid field, any unknown JSON field, an empty body or a body over 2 MiB |
401 | unauthorized (missing or revoked key) or wrong_scope (key of another scope); webhooks: webhook_not_configured, bad_signature |
403 | forbidden_role (a panel session whose role may not do this), forbidden_scope (an admin key minting an admin key) |
404 | not_found, unknown_surface, unknown_request, unknown_flow_version, item_not_found. An unknown route also answers 404 not_found in the error body below. |
409 | surface_off, experiment_running, deletion_scheduled, exists |
422 | a valid request the engine cannot honour: mode_conflict, no_candidates, pii_rejected, unknown_item, displayed_position_required, bad_tester_id |
429 | rate_limited, with Retry-After: 1; quota_exceeded when the plan's monthly serve requests are used (details.quota, used, resets_at) |
500 | internal; quote the request_id |
The body is decoded strictly: a field the contract does not define is a 400, not ignored. Upgrade the engine before you send a field from a newer contract.
Every error has the same body:
{
"code": "mode_conflict",
"message": "non_profiled requests must not carry a user_id",
"request_id": "6f1d0c2e-1d3b-4c51-9a55-3a8f0b8c2d11",
"details": {}
}codeis stable. Branch on it, never onmessage.messageis for people and may change.request_idmatches theX-Request-Idheader.detailsis optional and depends on the code.
All known codes, with what to do about each, are in Error codes.
Idempotency#
There is no Idempotency-Key header. Each write has its own natural key.
- Events.
event_idis unique per tenant. Sending the sameevent_idagain is counted underduplicatesand stored once. Derive it from your source id, for example{request_id}:{item_id}:seen. - Catalog.
PUT /catalog/itemsupserts byexternal_idandkind. Rerunning a backfill is safe. - Interactions. One row per request id, item and type; a repeat is ignored. The SDKs retry once on 5xx and network errors.
- Recommendations. Never retry
POST /recommendations. Every call logs an impression with propensities, so a retry is a second impression. On a timeout, show your own list and report the abandonment (the SDKs do it; see Timeouts and the ledger).
Rate limits#
The serve and ingest scopes have a token bucket per key:
| Scope | Sustained | Burst |
|---|---|---|
serve | 200 requests per second | 400 |
ingest | 50 requests per second | 100 |
admin | no limit |
When a bucket is empty you get 429 rate_limited with Retry-After: 1. Responses carry no other rate-limit headers. There is no per-user limit in the engine today; a batch of events counts as one request. Need more? Ask; the numbers are per deployment.
Monthly quota#
A plan can carry a monthly request quota (request_quota_monthly). Every POST /recommendations sent with a key counts, in every mode and arm, shadow included. At 90% of the quota the tenant gets one quota_warning notification for that month (Settings shows the count next to the plan). Past it the engine answers 429 quota_exceeded on POST /recommendations until the 1st of the next month, 00:00 UTC; details carries quota, used and resets_at. Nothing else is blocked: interactions, events and the catalog keep working. What your client does: show your own list, as for any 429, and do not retry the call; the SDKs' fallback does this. The count follows the key-usage flush, so with several engine instances it can lag by about ten seconds. Your quota is on Settings → Plan; ask us to raise it.
Request ids#
Every response carries X-Request-Id, errors included. Send your own (up to 64 characters) and the engine uses it; otherwise it mints one. Log it next to your own trace id. Quote it when you ask for help: it finds the exact request in the engine logs.
A slate also has a request_id in its body. That is the id you echo back in /interactions. Send a UUID you mint as X-Request-Id on POST /recommendations and it becomes the slate's request_id, so your client knows the id before the answer arrives and can report an abandoned request. The SDKs do this.
Latency budgets#
POST /recommendations: p99 60 ms from cache, 250 ms live, measured at the gateway. The engine gives up at its own budget (SERVE_BUDGET, 120 ms by default) and answers the fallback list, flagged.- Your client: give up after 150 ms and show your own list. The SDKs default to this.
Recommendis never retried. - Send what is left of your budget as the header
X-Deadline-Msor the body fielddeadline_ms(10 to 10000; when both are sent the shorter wins). The engine answers within its own budget or the deadline minus a tenth for the network, whichever is shorter. The SDKs send 120 ms inside their 150 ms timeout. See Timeouts and the ledger. - Other calls: the SDKs use 5 s per attempt.
Versions#
Two numbers, one scheme. Engine 0.3.3 is what GET https://api.patterna.nl/v1/health reports as engine_version; it is also the version of the OpenAPI contract, so the engine and the contract always carry the same number (release next to it is the build). SDK 0.4.1 is the current release of the Go, TypeScript and Python clients; the install lines are on the SDK pages. The OpenAPI file is binding; if a page disagrees, the page is wrong. The Changelog says what each engine version changed; "round" there is the internal name of a release and always has the engine version beside it. Everything on these pages works on the engine in production; a "since engine 0.3" marker only says when a feature arrived. See Status.