> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.portreeve.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.portreeve.com/_mcp/server.

# REST API

The SDK covers three of these endpoints (`verdict`, `feedback`, and
`attachUser`); the API is six endpoints, under
`https://api.portreeve.com/v1`:

| Method | Path | Key | What it does |
|---|---|---|---|
| `POST` | `/v1/verdict` | `sk_…` | Screen an event, get a verdict. |
| `GET` | `/v1/events` | `sk_…` | List stored events, newest first, with filters and cursor pagination. |
| `GET` | `/v1/events/{id}` | `sk_…` | Retrieve a stored event + its verdict. |
| `PATCH` | `/v1/events/{id}` | `sk_…` | Attach your `external_user_id` to an event after the fact ([late user binding](/integration/keys-first-verdict#your-auth-provider-mints-the-user-id)). Write-once. |
| `POST` | `/v1/feedback` | `sk_…` | Report an outcome about a subject — an event, an account (`external_user_id`), or an email. |
| `POST` | `/v1/device` | `pk_…` | Exchange fingerprint components for a `device_token`. Called from the browser by `@portreeve/browser`; you should not need to call it yourself. |

There is also an unauthenticated, unmetered `GET /health` — it reports *our*
dependency health and is not a substitute for monitoring your own verdict
latency.

Example verdict call:

```bash
curl https://api.portreeve.com/v1/verdict \
  -H "authorization: Bearer $PORTREEVE_SECRET_KEY" \
  -H "content-type: application/json" \
  -H "idempotency-key: signup-usr_9f2c" \
  -d '{
    "event_type": "signup",
    "external_user_id": "usr_9f2c",
    "email": "jane@example.com",
    "ip": "203.0.113.7"
  }'
```

`POST /v1/feedback` takes
`{"subject": {"kind": "event_id" | "external_user_id" | "email", "value": "..."}, "outcome": "confirmed_abuse" | "false_positive" | "chargeback", "note"?: string}`
and answers `{"ok": true}`. The subject kind set is deliberately closed —
feedback convicts, and identifiers that cannot safely convict (IPs) will never
be accepted; new safe kinds become enum values, not new routes. Subject values
ride in the body, never a URL path, so PII stays out of access logs.

## `GET /v1/events/{id}`

```json
{
  "id": "evt_01J8Z3K9",
  "event_type": "signup",
  "verdict": "review",
  "reasons": [{ "code": "identity_cluster", "detail": { "linked_accounts": 4 } }],
  "policy_version": "2026-08-22.0",
  "created_at": "2026-08-20T14:03:11.402Z"
}
```

Same fields as the verdict response plus `event_type` and `created_at`
(RFC 3339, UTC). It returns what we *stored* at decision time, so
`policy_version` is the policy that judged the event, not today's.

- **Frozen as `StoredEvent` in the published contract** (0.7.0) — parse it
  tolerantly (ignore unknown fields) like every other response, since frozen
  still means "may gain fields additively". `external_user_id` appears when it
  was sent at verdict time or attached later.
- There is no SDK method for it. It is a REST-only convenience for dashboards
  and support tooling; the verdict response you already hold has everything the
  enforcement path needs.

## `GET /v1/events` — the list

Newest-first, for support tooling and reconciliation:

```bash
curl "https://api.portreeve.com/v1/events?limit=50&event_type=signup&verdict=review" \
  -H "authorization: Bearer $PORTREEVE_SECRET_KEY"
```

Response: `{ "events": [StoredEvent, …], "next_cursor": "…" }` — pass
`next_cursor` back as `cursor` until it stops appearing. The cursor is opaque;
treat it as a token. `limit` is 1–100 (default 50); `event_type` and
`verdict` filter exact-match. Like everything else, the list is scoped to the
calling key's tenant and mode.

## Scoping: unknown, other-tenant, and other-mode all return `404`

Events are scoped to the calling key's **tenant and mode**. Asking for an event
that belongs to another tenant, or a `live` event with an `sk_test_` key (or
vice versa), returns `404 not_found` — **not** `403`. Deliberate: a `403` would
confirm the id exists. If a `GET /v1/events/{id}` you expect to work 404s,
check which mode the key is for before assuming the event is missing.

## Rate limits

**600 requests per minute per key**, counted in a fixed 60-second window,
shared across all our instances (a restart does not reset your budget). The
bucket is keyed on the API key itself, not your IP:

- test and live keys have separate budgets;
- your traffic is never affected by anyone else's;
- requests carrying no plausible key share a per-IP bucket;
- `GET /health` is exempt.

Over the limit you get `429` with the standard envelope:

```json
{ "error": "rate_limited", "message": "Rate limit exceeded" }
```

Back off and retry — a `429` means the request was **not** processed, so
nothing was counted against your velocity signals and no event was stored. If
you are retrying a verdict, keep the same `Idempotency-Key`. If 600/min is
genuinely too low for your traffic, tell us — but note it is currently a single
limit applied to every key, not a per-tenant one, so raising it is a
conversation rather than a switch.

## Idempotency and `409 idempotency_conflict`

Pass an `Idempotency-Key` header on `POST /v1/verdict`. Keys are scoped to
your tenant and mode and are remembered for **24 hours**.

- The SDK sends one on every *verdict* call, auto-generated unless you supply
  `idempotencyKey` or `dedupeKey`. It does not send one on feedback, which dedupes on its own.
- `Idempotency-Key` is only honoured on `POST /v1/verdict`; sending it to any
  other endpoint is harmless and ignored.
- A replay with the same key and the **same body** returns the original verdict
  verbatim and does not re-count velocity signals.
- A replay with the same key and a **different body** is the only thing that
  produces `409 idempotency_conflict`:

```json
{ "error": "idempotency_conflict", "message": "Idempotency-Key was already used with a different request body" }
```

The body comparison is over the exact JSON we received — stricter than
"logically the same event": a changed `user_agent`, an added `context` key, or
a field emitted in a different order can all trip it. If you see this, you are
almost always reusing a key derived from something too coarse (a user id, an
order id) across two genuinely different calls. Derive keys per *attempt*, not
per entity, or let the SDK generate them.

An unchanged explicit key/body conflict will not be resolved by retrying it.
The SDK's `dedupeKey` option handles changed bodies by deriving a stable fallback
key from the logical action and serialized body. That body gets a fresh screening,
while its subsequent identical retries replay. Explicit `idempotencyKey` leaves
409 handling to you. Both paths share the original call's timeout budget.

## Errors

Error responses are `{"error": "<code>", "message": "..."}`, sometimes with a
`details` object (`invalid_request` carries the failing field paths there).

| Code | Status | Means |
|---|---|---|
| `invalid_request` | `400` | The payload failed contract validation, or an unknown top-level key was sent. `details.issues` names the fields. |
| `unauthorized` | `401` | Missing, malformed, revoked, or wrong-type key (`pk_` on a secret route or vice versa). |
| `not_found` | `404` | No such route, or no such event *for this tenant and mode*. |
| `idempotency_conflict` | `409` | See above. |
| `conflict` | `409` | The event already has a different `external_user_id` (the `PATCH /v1/events/{id}` binding is write-once). Not retryable. |
| `rate_limited` | `429` | See above. |
| `internal` | `500`, `503` | Our problem. `503` on `/health` means a dependency is down. |

Like reason codes, this set can grow: treat unknown error codes as
retryable-with-caution `internal`-class errors.