REST API
The SDK covers three of these endpoints (verdict, feedback, and
attachUser); the API is six endpoints, under
https://api.portreeve.com/v1:
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:
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}
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
StoredEventin 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_idappears 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:
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 /healthis exempt.
Over the limit you get 429 with the standard envelope:
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
idempotencyKeyordedupeKey. It does not send one on feedback, which dedupes on its own. Idempotency-Keyis only honoured onPOST /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:
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).
Like reason codes, this set can grow: treat unknown error codes as
retryable-with-caution internal-class errors.