Patterna Docs

API overview

Scopes, errors, idempotency, rate limits and request ids in one page.

Developers 6 min read View as Markdown

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
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.

ScopePrefixPathsWho holds it
servepk_s_POST /recommendations, POST /interactionsyour backend, in the request path
ingestpk_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
adminpk_a_GET /users/{user_id}, /users/{user_id}/trace, /surfaces and below, /experiments, /proof/*, /connectors, /keys and below, /audit, /settingsthe panel, operator scripts
noneGET /healthanyone
own authenticationPOST /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#

StatusMeaning
200done, body follows
201created (surface, flow version, experiment, key)
202accepted (events, interactions, webhooks, deletion, export). A deletion is already done when you get the 202; see Admin.
204done, no body (key revoked)
400bad_request: a missing or invalid field, any unknown JSON field, an empty body or a body over 2 MiB
401unauthorized (missing or revoked key) or wrong_scope (key of another scope); webhooks: webhook_not_configured, bad_signature
403forbidden_role (a panel session whose role may not do this), forbidden_scope (an admin key minting an admin key)
404not_found, unknown_surface, unknown_request, unknown_flow_version, item_not_found. An unknown route also answers 404 not_found in the error body below.
409surface_off, experiment_running, deletion_scheduled, exists
422a valid request the engine cannot honour: mode_conflict, no_candidates, pii_rejected, unknown_item, displayed_position_required, bad_tester_id
429rate_limited, with Retry-After: 1; quota_exceeded when the plan's monthly serve requests are used (details.quota, used, resets_at)
500internal; 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:

JSON
{
  "code": "mode_conflict",
  "message": "non_profiled requests must not carry a user_id",
  "request_id": "6f1d0c2e-1d3b-4c51-9a55-3a8f0b8c2d11",
  "details": {}
}
  • code is stable. Branch on it, never on message.
  • message is for people and may change.
  • request_id matches the X-Request-Id header.
  • details is 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_id is unique per tenant. Sending the same event_id again is counted under duplicates and stored once. Derive it from your source id, for example {request_id}:{item_id}:seen.
  • Catalog. PUT /catalog/items upserts by external_id and kind. 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:

ScopeSustainedBurst
serve200 requests per second400
ingest50 requests per second100
adminno 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. Recommend is never retried.
  • Send what is left of your budget as the header X-Deadline-Ms or the body field deadline_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.

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