REST API reference

Every control-plane endpoint: method, path, parameters, request and response bodies, and the errors each one returns.

Conventions

  • Basehttps://<your-console-host>/api, or http://localhost:8080/api against a stack you started yourself. The control plane is served from the same origin as the dashboard, so browser tooling needs no CORS setup.
  • Auth — every endpoint except POST /api/auth/login requires Authorization: Bearer <jwt>. API keys do not work here; see Authentication.
  • Tenant — scoped reads use the tenant on your token, never a parameter. There is no way to ask for another tenant's data.
  • Times — ISO-8601 instants in UTC. null means "not set" (no cooldown, no expiry, not revoked).
  • Errors — RFC 7807 application/problem+json. The curated reason is in detail.
jsonerror shape
{"type":"about:blank","title":"Not Found","status":404,"detail":"resource not found"}
StatusWhen
400Malformed input the endpoint validates itself — an unknown resource kind, a corrupt cursor.
401Missing, malformed, or expired token; or wrong login credentials. Log in again.
403The token is not bound to a tenant, targets another tenant, or its tenant is suspended/deleted.
404The addressed thing does not exist for your tenant.
409The write conflicts with existing state.
429Login throttled. Honour Retry-After.

Authentication

POST/api/auth/login

Exchanges admin credentials for a control-plane JWT. The only public endpoint under /api.

jsonrequest
{"username":"admin","password":"…"}
json200 OK
{"token":"eyJhbGciOiJIUzI1NiJ9…","tokenType":"Bearer","expiresInSeconds":3600}

Errors. 401 invalid credentials for a wrong username, a wrong password, and an unconfigured console — the three are indistinguishable on purpose. 429 once this source IP has been throttled.

Pool state

GET/api/pools/resources

KPI summary plus one row per resource your tenant's pool knows — registered, blocklisted, or merely seen in a cell. Rows are ordered by kind then value.

json200 OK
{
  "summary": {
    "registered": 42,
    "blocklisted": 1,
    "totalCells": 96,
    "cellsByState": {"HEALTHY": 81, "COOLING": 12, "RECOVERING": 3, "BLOCKLISTED": 0}
  },
  "resources": [
    {
      "kind": "PROXY",
      "value": "proxy-1.example.net:8080",
      "registered": true,
      "blocked": false,
      "blockedUntil": null,
      "blockPermanent": false,
      "contexts": 2,
      "state": "COOLING",
      "score": -34.0,
      "recentWindow": [true, true, false, false]
    }
  ]
}

A row aggregates the resource's cells into a representative rollup, and the rule is "surface the weakest context" — that is the one an operator needs to see first:

  • state — the worst-severity cell state (BLOCKLISTED > COOLING > RECOVERING > HEALTHY), or BLOCKLISTED outright if the resource is blocked, or HEALTHY if it has no cells yet.
  • score — the lowest score across the cells; null when there are none.
  • recentWindow — success flags (oldest → newest) of the worst-scoring cell's window; an empty array when there are no cells. Severity and worst score are computed independently, so a row can read COOLING while showing the window of whichever context is dragging its score down.
  • contexts — how many cells the resource holds. 0 means registered but never reported on.
  • blockedUntil — the expiry of a temporary block; null when unblocked or permanent, so read it together with blockPermanent.

GET/api/pools/resources/{kind}/{value}

One resource expanded into its per-context cells, sorted by context.

Path paramValues
kindPROXY · ACCOUNT · SESSION (case-insensitive)
valueThe resource value, URL-encoded.
json200 OK
{
  "kind": "PROXY",
  "value": "proxy-1.example.net:8080",
  "registered": true,
  "blocked": false,
  "blockedUntil": null,
  "blockPermanent": false,
  "cells": [
    {
      "context": "checkout-us",
      "score": -34.0,
      "consecutiveFailures": 2,
      "consecutiveSuccesses": 0,
      "windowSize": 10,
      "state": "COOLING",
      "cooldownUntil": "2026-07-29T10:41:02Z",
      "updatedAt": "2026-07-29T09:41:02Z"
    },
    {
      "context": "search-eu",
      "score": 35.0,
      "consecutiveFailures": 0,
      "consecutiveSuccesses": 7,
      "windowSize": 10,
      "state": "HEALTHY",
      "cooldownUntil": null,
      "updatedAt": "2026-07-29T09:44:18Z"
    }
  ]
}

Errors. 400 invalid resource kind or value for an unknown kind or a blank value. 404 resource not found when the pool has never seen this resource — that is, it has no cells, is not registered, and is not blocked.

GET/api/pools/resources/{kind}/{value}/score-history

The resource's sampled score curve, one ascending-time series per context. This is what the dashboard's 24-hour chart draws.

Query paramDefaultNotes
hours24How far back to read. Clamped to [1, 720] (30 days), so an out-of-range value is silently corrected rather than rejected — and no caller can trigger an unbounded scan.
json200 OK
{
  "contexts": [
    {"context": "checkout-us",
     "points": [{"at":"2026-07-29T08:00:00Z","score":15.0},
                {"at":"2026-07-29T08:01:00Z","score":-15.0}]},
    {"context": "search-eu",
     "points": [{"at":"2026-07-29T08:00:00Z","score":30.0}]}
  ]
}

Scores are sampled on a timer (once a minute), not written on every report, so the curve is a sampled view rather than a full history of outcomes. An unknown resource returns 200 with an empty contexts array — there is simply no series. Samples are retained for seven days; see FAQ.

POST/api/pools/resources/{kind}/{value}/block

Blocklists a resource — the operator override that isolates it from selection in every context at once. Returns 204 No Content.

Query paramDefaultNotes
permanentfalsetrue blocks with no expiry, released only by an explicit unblock.
seconds3600TTL of a temporary block. Ignored when permanent=true; a zero or negative value falls back to the default.
bashtemporary and permanent
curl -sS -X POST "https://$RP_HOST/api/pools/resources/proxy/proxy-1/block?seconds=7200" \
  -H "Authorization: Bearer $RP_JWT"

curl -sS -X POST "https://$RP_HOST/api/pools/resources/proxy/proxy-1/block?permanent=true" \
  -H "Authorization: Bearer $RP_JWT"

The block emits RESOURCE_BLOCKLISTED, so it lands in the audit trail and the live stream with the rest of the timeline. Blocking an already-blocked resource replaces the entry. 400 for an invalid kind or value.

DELETE/api/pools/resources/{kind}/{value}/block

Releases a resource from the blocklist. Returns 204 No Content, and emits RESOURCE_UNBLOCKED only if it really was blocked — so unblocking something that is not blocked is a harmless no-op rather than a phantom audit entry. 400 for an invalid kind or value.

Releasing does not reset reputation. The resource returns to selection with its cells exactly as they were, so a cell that is still cooling stays benched for its own context until its cooldown expires.

Audit events

GET/api/events

One page of your tenant's audit trail, newest first, with keyset (cursor) pagination — the cost of a page is flat no matter how far back you have scrolled.

Query paramDefaultNotes
cursorOpaque, URL-safe. Absent means "start at the latest"; otherwise the page immediately older than the cursor.
limit50Page size, clamped to [1, 500].
json200 OK
{
  "events": [
    {"seq": 918, "eventType": "RESOURCE_COOLED", "resourceKind": "PROXY",
     "resourceValue": "proxy-1.example.net:8080", "context": "checkout-us",
     "occurredAt": "2026-07-29T09:41:02Z", "until": "2026-07-29T10:41:02Z", "cause": "BLOCKED"},
    {"seq": 917, "eventType": "RESOURCE_LEASED", "resourceKind": "PROXY",
     "resourceValue": "proxy-1.example.net:8080", "context": "checkout-us",
     "occurredAt": "2026-07-29T09:40:58Z", "until": "2026-07-29T09:41:28Z", "cause": null}
  ],
  "nextCursor": "OTE3"
}
FieldMeaning
seqThe ledger's total order. Strictly decreasing within and across pages.
eventTypeRESOURCE_LEASED · LEASE_RELEASED · RESOURCE_COOLED · RESOURCE_RECOVERED · RESOURCE_BLOCKLISTED · RESOURCE_UNBLOCKED
contextThe cell's context, or null for resource-level events such as a blocklist change.
untilCooldown end, lease expiry, or block expiry depending on the type; null when the type has no deadline (or the block is permanent).
causeThe FailureType behind a RESOURCE_COOLED; null otherwise.
nextCursorPass it back as cursor for the next older page. null means this was the last page.

Errors. 400 invalid cursor for a cursor you did not get from this endpoint. Do not construct cursors — the encoding is not part of the contract.

bashwalking the whole trail
cursor=""
while :; do
  page=$(curl -sS "https://$RP_HOST/api/events?limit=500&cursor=$cursor" \
           -H "Authorization: Bearer $RP_JWT")
  echo "$page" | jq -c '.events[]'
  cursor=$(echo "$page" | jq -r '.nextCursor // empty')
  [ -z "$cursor" ] && break
done

Usage

GET/api/usage

Your tenant's metered usage: the last 30 days of granted-lease counts, the current calendar month's total, and the most recently sampled pool size. Days are UTC.

json200 OK
{
  "monthLeaseTotal": 128400,
  "poolSize": 42,
  "dailyLeases": [
    {"date": "2026-07-28", "count": 5120},
    {"date": "2026-07-29", "count": 3980}
  ]
}

A lease is counted when it is granted, so an Acquire that returned granted: false does not count. Counts are accumulated in memory and flushed on a timer (once a minute), so the current day's number trails live traffic slightly. Days with no activity are absent from the array rather than present with a zero.

API keys

All three endpoints take a tenantId path parameter, and it must be the tenant your token is bound to — otherwise 403 forbidden, checked before existence so the response cannot reveal whether another tenant exists. A tenantId that does not exist answers 404 tenant not found. Storage, rotation, and revocation semantics are in Authentication.

POST/api/tenants/{tenantId}/api-keys

Mints a data-plane API key. Returns 201 Created. The body is optional; omit it for an unlabelled key.

jsonrequest (optional)
{"label":"worker-01"}
json201 Created
{
  "id": "5f1c2b40-…",
  "rawToken": "rp_9Q3xK7bT…",
  "label": "worker-01",
  "prefix": "rp_9Q3xK7bT",
  "createdAt": "2026-07-29T09:12:44Z"
}

GET/api/tenants/{tenantId}/api-keys

All of the tenant's keys, oldest first — never key material, only the non-secret display prefix. A non-null revokedAt means the key no longer authenticates.

json200 OK
[
  {"id":"5f1c2b40-…","label":"worker-01","prefix":"rp_9Q3xK7bT",
   "createdAt":"2026-07-29T09:12:44Z","revokedAt":null}
]

DELETE/api/tenants/{tenantId}/api-keys/{keyId}

Revokes an active key by its id. Returns 204 No Content. Effective immediately — the next gRPC call with that key fails.

Errors. 404 api key not found covers all three of "unknown id", "already revoked", and "belongs to another tenant", without distinguishing them.

gRPC data plane, for reference

Not REST, but listed here so the whole surface is in one place. Service io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor, authenticated with x-api-keymetadata, served on port 9093 of the app container — published on 127.0.0.1 only, so you reach it from a stack you started rather than from a hosted hostname. Message shapes and runnable calls are in Quickstart.

RPCRequest → responseNotes
RegisterResourceId → emptyIdempotent. Makes the resource eligible for selection.
AcquireContextgranted, leasegranted: false with no lease is a normal answer, not an error.
ReportResourceId, Context, Outcome → emptyThe only call that moves reputation, and the only one that creates a cell.
RenewLeaseHandlerenewed, leaseRefuses a blocklisted resource — the lease then lapses at its TTL.
ReleaseLeaseHandlereleasedOnly the current holder's fencing token can release.
SubscribeEventsempty → stream PoolEventLive, tenant-scoped. The durable equivalent is GET /api/events.

gRPC status codes

StatusWhen
UNAUTHENTICATEDMissing, unknown, or revoked key; or the key's tenant is not active.
UNAVAILABLECredentials could not be checked (store unreachable). Retryable.
INVALID_ARGUMENTMalformed request — an unset kind, a blank resource value, a missing context.
RESOURCE_EXHAUSTEDThe call would create new pool state (a new resource on Register, a new cell on Report) past the service-wide budget. Calls that only touch existing state are never affected — see FAQ.