Skip to navigation

REST API

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

MethodPathKeyWhat it does
POST/v1/verdictsk_…Screen an event, get a verdict.
GET/v1/eventssk_…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). Write-once.
POST/v1/feedbacksk_…Report an outcome about a subject — an event, an account (external_user_id), or an email.
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/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}

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

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:

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

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.
conflict409The event already has a different external_user_id (the PATCH /v1/events/{id} binding is write-once). Not retryable.
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.