REST API
The SDK covers two of these endpoints (verdict and feedback); the API is
four 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/events/{id}/feedback takes
{"outcome": "confirmed_abuse" | "false_positive" | "chargeback", "note"?: string}
and answers {"ok": true}.
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.
- This is the one payload with no schema in the published contract. Every other request and response on this API is a frozen contract type; this one is assembled by hand in the route. It is stable and we treat changes to it as breaking, but parse it tolerantly (ignore unknown fields) rather than strictly, exactly as you would a response that might gain fields.
- 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.
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
idempotencyKey. 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.
A 409 is never worth retrying. It is not transient; the same request will
always produce it.
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.