# API overview

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

Source: https://www.patterna.nl/docs/api-overview

The Patterna API is JSON over HTTPS. Every path below is relative to `https://patterna-api.orven.am/v1`. The full, rendered contract lives in the [API reference](https://www.patterna.nl/docs/api). 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://patterna-api.orven.am/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](https://www.patterna.nl/docs/rotate-keys-and-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](https://www.patterna.nl/docs/api-serve), [Ingest](https://www.patterna.nl/docs/api-ingest), [Admin](https://www.patterna.nl/docs/api-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](https://www.patterna.nl/docs/api-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:

```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](https://www.patterna.nl/docs/reference-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](https://www.patterna.nl/docs/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. `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](https://www.patterna.nl/docs/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://patterna-api.orven.am/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](https://www.patterna.nl/docs/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](https://www.patterna.nl/docs/status).
