API Access: Programmatic Data for Your Automation
Stylo.Bot exposes a versioned REST API (/api/v1/*) so your own systems can pull the traffic intelligence the dashboard shows: per-domain summaries, fleet health, threat-feed advisories, and license entitlement, without a human logging in.
The API is read-mostly by design. Programmatic keys get reads plus management of their own keys. Config edits and policy changes stay behind the operator console.
Live docs
Browse the interactive API reference — every commercial control-plane endpoint on this page, schema and all, in a Scalar UI you can click through. It isn't wired to a live backend here (we don't run production API traffic against our own marketing site), so "Try it" calls need a server URL — point it at your own control plane or gateway and it calls the real thing.
Every install also serves its own interactive documentation built from its own live OpenAPI document, no server-URL edit needed since it's already talking to itself. Your key goes in the Authorize field.
- Gateway installs:
https://<your-gateway>/docs/api— the per-domain traffic surface plus, on the open-source build, the wider FOSS/api/v1surface - Control plane installs (commercial):
https://<your-control-plane>/docs/api— fleet, license, threat feed, and key management
Two products, two auth stories
| Open source (self-hosted) | Commercial (portal) | |
|---|---|---|
| Header | X-SB-Api-Key: <your-key> |
Authorization: Bearer sbk_... |
| Where the key comes from | your install's config (BotDetection:ApiKeys) |
your portal account |
| Granularity | one key, full read access to the surface it's configured for | capability scopes per key, optionally domain-bound |
| Where to manage | your own config | the portal account area |
The two never mix: a portal sbk_ key only works against a commercial install; a config-defined X-SB-Api-Key only works against the install that defines it.
Creating an API key (commercial)
- Log in to the portal with your account.
- Open Account -> API tokens (or your org's API keys page).
- Create a key: give it a name, pick the scopes it needs, and optionally bind it to specific domains.
- Copy the plaintext now. Keys are stored hashed. The value is shown exactly once and can never be recovered. If you lose it, rotate or revoke it from the same page.
Scopes
| Scope | What it grants |
|---|---|
traffic:read |
per-domain traffic summaries, top bots, endpoint stats |
fleet:read |
fleet gateways + fleet health + endpoint/detection-rate rollups |
threats:read |
threat advisories + CVE fingerprints |
license:read |
your entitlement (tier, features, domains, expiry) |
keys:manage |
create, rotate, and revoke keys on your own account — never beyond your own scopes or domains |
artifacts:read and artifacts:download are accepted at key creation but don't unlock anything yet; no artifact-download endpoints are live in this version.
A key with no scopes at all is an operator key (full access, no domain restriction) — that's how the platform's own tooling authenticates; portal-issued keys always carry an explicit scope list. Omit scopes when creating a key and it defaults to license:read only.
Calling the API
# Commercial: your entitlement
curl -H "Authorization: Bearer sbk_..." \
"https://your-control-plane/api/v1/license"
# Commercial: what this key can do
curl -H "Authorization: Bearer sbk_..." \
"https://your-control-plane/api/v1/me"
# Commercial: fleet health
curl -H "Authorization: Bearer sbk_..." \
"https://your-control-plane/api/v1/fleet/health"
# Commercial: threat advisories, newest first, paged
curl -H "Authorization: Bearer sbk_..." \
"https://your-control-plane/api/v1/threats/advisories?limit=50"
# Open source: per-domain summary using your config-defined key
curl -H "X-SB-Api-Key: your-configured-key" \
"https://your-gateway/api/v1/summary"
GET /api/v1/me on the control plane returns exactly this shape (real response, captured from a running instance):
{
"data": {
"key": {
"id": "2dc8e00f3ccc",
"label": "demo-key",
"accountId": "acct_demo",
"scopes": ["traffic:read", "fleet:read", "threats:read", "license:read", "keys:manage"],
"domains": [],
"isOperatorKey": false
},
"license": {
"tier": "starter",
"isTrial": false,
"expiresAt": "2026-09-16T14:52:39.778291+00:00",
"features": ["stylobot.config-editor.live", "stylobot.config-editor.global"]
}
},
"meta": { "generatedAt": "2026-08-17T14:52:39.778515+00:00" }
}
Conventions
- Envelope: single resources are
{ "data": ..., "meta": { "generatedAt": ... } }; collections add"pagination": { "limit": ..., "nextCursor": ... }.nextCursor: nullmeans the last page — pass the cursor back as?cursor=...to continue. Field names are camelCase throughout. - Errors: RFC 7807 Problem Details —
{ "type", "title", "status", "detail", "code" }, with arequestIdfield on most (not all) error responses for support correlation.codeis the discriminant:unauthorized,insufficient_scope,rate_limited,domain_not_licensed,invalid_cursor,not_found,validation_failed. - Status codes:
401no or invalid credential,403valid key but missing scope,402valid key but the domain or feature isn't on your license,429rate limited. - Rate limits: every response under
/api/v1carriesX-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset(unix seconds). Over the limit you get429with aRetry-Afterheader. Defaults are 60/minute and 1,000/hour per key, tighter of the two wins; your account can be issued higher limits. Limits are enforced per instance (per-node, not cluster-wide) in this version. - Paging:
?limit=(1-500, default 100 — an out-of-range value falls back to 100, it is not clamped to 500) and?cursor=(opaque, never hand-build one). Not every collection endpoint pages — some return their full result set in one page (nextCursoralwaysnull) because the underlying rollup is already bounded. Check the endpoint reference below. - Request ids: most responses carry
X-Request-ID, echoed asrequestIdin error bodies where present.
Endpoint reference (commercial)
All commercial endpoints require Authorization: Bearer sbk_... and the scope listed. Hosted on your control plane unless marked "gateway".
| Method & path | Scope | Paged? | Returns |
|---|---|---|---|
GET /api/v1/me |
any key | — | key context + your license state |
GET /api/v1/health |
fleet:read |
— | { status: "ok", version } |
GET /api/v1/license |
license:read |
— | { orgId, tier, isTrial, isDemo, expiresAt, features, domains, maxNodes } |
GET /api/v1/license/entitlements |
license:read |
no | one row per licensed domain: { domain, licensed, reason, tier } |
GET /api/v1/license/entitlements/{domain} |
license:read |
— | same shape, one domain; reason is licensed, not_in_license, or unconfigured |
GET /api/v1/fleet/gateways?limit&cursor&status |
fleet:read |
yes | { gatewayId, version, status, lastHeartbeatAt } per gateway |
GET /api/v1/fleet/health |
fleet:read |
— | { gateways: { total, online, offline }, oldestHeartbeatAt, version } |
GET /api/v1/fleet/endpoint-stats?limit |
fleet:read |
no | { path, requests, botRequests, botPct } |
GET /api/v1/fleet/detection-rate?bucket&window |
fleet:read |
no | { buckets: [{ at, requests, detected }] } |
GET /api/v1/threats/advisories?severity&status&limit&cursor |
threats:read |
yes | advisory summaries: { id, sourceFeed, externalId, title, severity, publishedAt, status } |
GET /api/v1/threats/advisories/{id} |
threats:read |
— | full advisory record, or 404 |
GET /api/v1/threats/fingerprints?status&limit&cursor |
threats:read |
yes | { id, advisoryId, status, confidence, clusterId, clusterLabel, updatedAt } |
GET /api/v1/threats/fingerprints/active |
threats:read |
no | currently-active fingerprints only |
GET /api/v1/keys |
keys:manage |
yes | your account's own keys (never the plaintext) |
POST /api/v1/keys |
keys:manage |
— | creates a key, returns the plaintext once |
DELETE /api/v1/keys/{id} |
keys:manage |
— | 204, revokes the key |
POST /api/v1/keys/{id}/rotate |
keys:manage |
— | new plaintext once, old key stops working immediately |
GET /api/v1/domains?limit (gateway) |
traffic:read |
no | observed domains: { domain, requests, bots, licensed } |
GET /api/v1/domains/{domain}/traffic/summary?period (gateway) |
traffic:read |
— | { domain, period, periodStart, periodEnd, requests, verdicts: { bot, human, unknown }, botPercentage } |
GET /api/v1/domains/{domain}/traffic/timeseries?period&bucket (gateway) |
traffic:read |
no | { at, requests, bot, human } buckets |
GET /api/v1/domains/{domain}/traffic/topbots?window&limit (gateway) |
traffic:read |
no | { name, signature, requests, sharePct } |
GET /api/v1/domains/{domain}/traffic/endpoints?window&limit (gateway) |
traffic:read |
no | { method, path, requests, botRequests, botPct, last_seen } — note last_seen is the one field that isn't camelCase; everything else on this route is |
The /api/v1/domains* group needs a gateway with PostgreSQL persistence enabled — SQLite-only installs don't have it yet. A domain outside your license returns 402 with code: "domain_not_licensed" rather than data.
GET /api/v1/me is a control-plane-only path. Your gateway also answers GET /api/v1/me, but that's a separate, older endpoint (key/detector-policy context, not scopes or license) — hit the control plane for the shape shown above.
Example error
Asking for a scope your key doesn't have:
{
"type": "https://stylo.bot/errors/insufficient_scope",
"title": "Insufficient scope",
"status": 403,
"detail": "This API key does not have the required scope: fleet:read.",
"code": "insufficient_scope",
"requestId": "0HNNSDSNK8GMS"
}
What is NOT in the API
Programmatic keys are read-only by design, aside from managing their own siblings under keys:manage. Config edits, policy changes, install tokens, compliance and report surfaces stay behind the operator console. Release-artifact downloads aren't wired into this API version.