REST API

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

MethodPathKeyWhat it does
POST/v1/verdictsk_…Screen an event, get a verdict.
GET/v1/events/{id}sk_…Retrieve a stored event + its verdict.
POST/v1/events/{id}/feedbacksk_…Report an outcome.
POST/v1/devicepk_…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:

$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/events/{id}/feedback takes {"outcome": "confirmed_abuse" | "false_positive" | "chargeback", "note"?: string} and answers {"ok": true}.

GET /v1/events/{id}

1{
2 "id": "evt_01J8Z3K9",
3 "event_type": "signup",
4 "verdict": "review",
5 "reasons": [{ "code": "identity_cluster", "detail": { "linked_accounts": 4 } }],
6 "policy_version": "2026-08-22.0",
7 "created_at": "2026-08-20T14:03:11.402Z"
8}

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_foundnot 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:

1{ "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. 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:
1{ "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.

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

CodeStatusMeans
invalid_request400The payload failed contract validation, or an unknown top-level key was sent. details.issues names the fields.
unauthorized401Missing, malformed, revoked, or wrong-type key (pk_ on a secret route or vice versa).
not_found404No such route, or no such event for this tenant and mode.
idempotency_conflict409See above.
rate_limited429See above.
internal500, 503Our 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.