{"openapi":"3.0.3","info":{"title":"Centinel API","description":"Validate an HTTP request and get a decision on how to handle it: allow, block, or redirect. Call `/validate` from your backend for every protected request. Query your own event data with read-only SQL through `/query`.","version":"1.0.0","contact":{"name":"Centinel Analytica","url":"https://docs.centinelanalytica.com"}},"servers":[{"url":"https://validator.centinelanalytica.com","description":"Production"}],"security":[{"apiKey":[]}],"paths":{"/query":{"post":{"summary":"Query event data","description":"Runs one read-only ClickHouse SQL statement against `events_distributed` and returns the rows. The server limits every statement to the events of your own organisation.\n\nThe response format is tab-separated values unless the statement ends with a `FORMAT` clause, for example `FORMAT JSONEachRow`. Put a `created_at` filter in each query: the column is the sort key, and a bounded range avoids the 30-second execution limit.\n\nLimits: one statement per request, a 64 KB body, 30 seconds of execution, 1,000,000 rows, and a 4 MiB answer. `SET` statements and `SETTINGS` clauses are rejected. The endpoint sends no CORS headers, so call it from a server.\n\nSee the [Analytics Query API guide](/api/analytics) for the event schema and example queries.","operationId":"queryEvents","tags":["Analytics"],"servers":[{"url":"https://api.centinelanalytica.com","description":"Production"}],"security":[{"analyticsKey":[]}],"requestBody":{"required":true,"description":"One SQL statement. Query string parameters are ignored.","content":{"text/plain":{"schema":{"type":"string","minLength":1,"maxLength":65536},"example":"SELECT toDate(created_at) AS day, action, count() AS requests FROM events_distributed WHERE created_at > now() - INTERVAL 7 DAY AND request_id != toUUID('00000000-0000-0000-0000-000000000000') GROUP BY day, action ORDER BY day FORMAT JSONEachRow"}}},"responses":{"200":{"description":"The rows, in the format that the statement selected. The default is `TabSeparated` without a header row. The response is not compressed.","content":{"text/tab-separated-values":{"schema":{"type":"string"},"example":"2026-09-21\tallow\t18234\n2026-09-21\tredirect\t912\n"},"application/x-ndjson":{"schema":{"type":"string"},"example":"{\"day\":\"2026-09-21\",\"action\":\"allow\",\"requests\":\"18234\"}\n"}}},"400":{"description":"The request is malformed or the body is empty or larger than 64 KB. ClickHouse also answers a rejected statement with a plain-text error such as `Code: 62. DB::Exception: Syntax error`.","content":{"text/plain":{"schema":{"type":"string"}}}},"401":{"description":"The API key is missing, malformed, unknown, revoked, or expired. The body is empty."},"405":{"description":"The method is not `POST`."},"417":{"description":"The request sent an `Expect` header that the server does not implement."},"502":{"description":"The answer exceeded 4 MiB (`clickhouse response too large`) or the database relay failed. Split the query into smaller time windows.","content":{"text/plain":{"schema":{"type":"string"}}}},"default":{"description":"A ClickHouse error, as plain text with a code and an identifier, for example `Code: 47. DB::Exception: ... (UNKNOWN_IDENTIFIER)`.","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/validate":{"post":{"summary":"Validate a request","description":"Returns a decision for one HTTP request: `allow`, `block`, or `redirect`. Call this endpoint from your backend for every protected request.\n\nSee the [integration guide](/install/validation) for backend handling patterns.","operationId":"validateRequest","tags":["Validation"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateRequest"},"example":{"url":"https://example.com/article","method":"GET","ip":"1.2.3.4","referrer":"https://example.com","cookie":"<_centinel-cookie-value>","headers":{"Host":"example.com","User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36","Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8","Accept-Language":"en-US,en;q=0.9","Sec-Fetch-Dest":"document","Sec-Fetch-Mode":"navigate","Sec-Fetch-Site":"none"}}}}},"responses":{"200":{"description":"Valid request. The `decision` field carries the verdict, and `status_code` carries the HTTP status to send. Rate limiting is signalled in-band here; `/validate` never answers HTTP 429.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateResponse"},"examples":{"allow":{"summary":"Allow","value":{"success":true,"decision":"allow","status_code":200}},"allowWithCrawler":{"summary":"Allow with crawler metadata","value":{"success":true,"decision":"allow","crawler":{"id":"4a7d7c8a-3fcb-4b91-8f7e-3f6ac1f5b6c1","name":"Googlebot","access_allowed":true,"category":"LLM","rsl_category":"ai-all"},"status_code":200}},"block":{"summary":"Block","value":{"success":true,"decision":"block","status_code":403,"response_html":"PGh0bWw+PGJvZHk+QmxvY2tlZDwvYm9keT48L2h0bWw+","headers":{"Content-Type":"text/html; charset=utf-8","X-Content-Type-Options":"nosniff","X-Frame-Options":"DENY"},"block_reasons":["datacenter_ip"],"block_reason_categories":["suspicious_network"]}},"redirect":{"summary":"Redirect (interstitial challenge)","value":{"success":true,"decision":"redirect","status_code":403,"response_html":"PGh0bWw+PGJvZHk+Q2hhbGxlbmdlPC9ib2R5PjwvaHRtbD4=","cookies":[{"name":"_centinel","value":"<session-uuid>","domain":"","path":"/","secure":true,"same_site":"Lax","max_age":86400}],"headers":{"Content-Type":"text/html; charset=utf-8","Content-Security-Policy":"script-src 'nonce-<per-response>'","X-Content-Type-Options":"nosniff"}}},"rateLimited":{"summary":"Rate limited (in-band on a 200)","value":{"success":true,"decision":"block","status_code":403,"response_html":"PGh0bWw+PGJvZHk+QmxvY2tlZDwvYm9keT48L2h0bWw+","block_reasons":["rate_limit_exceeded"],"block_reason_categories":["suspicious_network"],"rate_limited":true,"retry_after":30}}}}}},"400":{"description":"Request rejected. Fail closed on this status.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateError","description":"One of `missing_fields`, `invalid_request`, or `invalid_ip`."},"example":{"success":false,"error":{"type":"missing_fields","message":"Missing required fields: URL and IP are required"}}}}},"401":{"description":"Missing or invalid `x-api-key` header, as `missing_api_key` or `invalid_api_key`. Fail closed on this status.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateError"},"example":{"success":false,"error":{"type":"invalid_api_key","message":"Invalid API key"}}}}},"500":{"description":"Validator error. Fail open on this status so your site stays available.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateError"},"example":{"success":false,"error":{"type":"internal_server_error","message":"Internal server error"}}}}},"503":{"description":"The validation pipeline exceeded its time budget. Fail open on this status.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateError"},"example":{"success":false,"error":{"type":"validation_timeout","message":"Validation process timed out, please retry"}}}}}}}},"/health":{"get":{"summary":"Health check","description":"Liveness and readiness check for a Centinel service. Both hosts accept a health-authorized customer `x-api-key` or the internal `x-health-check-token`. The collector also accepts a case-insensitive `User-Agent` match for `Amazon-Route53-Health-Check-Service`.\n\nUse `https://validator.centinelanalytica.com/health` for the validator and `https://collector.centinelanalytica.com/health` for the collector. Each returns `200` with `{\"status\":\"ok\"}` when ready. Internal authorization can also return `centinel_stack_version`. The service returns `403` when authorization fails.","operationId":"healthCheck","tags":["Health"],"security":[{"apiKey":[]},{"healthCheckToken":[]}],"servers":[{"url":"https://validator.centinelanalytica.com","description":"Validator"},{"url":"https://collector.centinelanalytica.com","description":"Collector"}],"responses":{"200":{"description":"Service is up and ready.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"},"example":{"status":"ok"}}}},"403":{"description":"Authorization failed. The body is empty."},"503":{"description":"Authorized but not ready to serve yet. The body is empty."}}}}},"components":{"securitySchemes":{"analyticsKey":{"type":"http","scheme":"bearer","description":"A read-only analytics key (`sk_api_ro_...`) created in the dashboard. Server-only: it reads all of your event data."},"apiKey":{"type":"apiKey","in":"header","name":"x-api-key","description":"Your secret API key. Server-only — never call /validate from a browser."},"healthCheckToken":{"type":"apiKey","in":"header","name":"x-health-check-token","description":"Internal health-check credential."}},"schemas":{"HealthResponse":{"type":"object","required":["status"],"properties":{"status":{"type":"string","enum":["ok"],"description":"`ok` when the service is healthy.","example":"ok"},"centinel_stack_version":{"type":"string","description":"Stack version. Present only with internal health authorization."}}},"ValidateRequest":{"type":"object","required":["url","ip"],"properties":{"url":{"type":"string","format":"uri","description":"Full URL the user requested.","example":"https://example.com/article"},"method":{"type":"string","description":"HTTP method (GET, POST, etc). Optional, but strongly recommended for detection accuracy.","example":"GET"},"ip":{"type":"string","description":"Real client IP. If you are behind a CDN, use X-Forwarded-For (not the proxy IP).","example":"1.2.3.4"},"headers":{"type":"object","additionalProperties":{"type":"string"},"description":"All request headers. More headers improve detection accuracy. Optional, but detection accuracy drops sharply without them."},"referrer":{"type":"string","description":"Value of the `Referer` request header.","example":"https://example.com"},"cookie":{"type":"string","description":"The `_centinel` cookie value, as a bare UUID rather than the whole `Cookie` header. A malformed value is silently ignored. If omitted, the validator falls back to extracting `_centinel` from the `Cookie` request header."}}},"ValidateResponse":{"type":"object","required":["success","decision"],"properties":{"success":{"type":"boolean","description":"`true` on a valid response.","example":true},"decision":{"type":"string","enum":["allow","block","redirect"],"description":"The validation decision. A `block` rendered from a custom block page is reported as `redirect`, so read `status_code` rather than inferring intent from this field."},"status_code":{"type":"integer","enum":[200,302,401,403,404,418,429,451,460,503],"description":"The HTTP status to return to the client. When present, send this value instead of a hardcoded status. It is 200 on standard `allow` responses and 403 on standard `block` and `redirect` responses unless a policy rule or tenant setting overrides it. Treat an absent value as an internal or intercept edge case, and apply your documented safe fallback.","example":403},"headers":{"type":"object","additionalProperties":{"type":"string"},"description":"HTTP headers to set on your response. A response can include them on any decision, including an `allow` that contains a validator-owned response such as managed `robots.txt`. Guard for a missing field before you iterate."},"response_html":{"type":"string","description":"Base64-encoded UTF-8 response body. It can contain HTML or validator-owned plain text such as managed `robots.txt`. Read `headers.Content-Type` before you serve it. It usually exists on `block` and `redirect`. If a `redirect` lacks this field, deny the request with `status_code` and a safe fallback body. Do not pass the request to your application."},"cookies":{"type":"array","items":{"$ref":"#/components/schemas/Cookie"},"description":"Cookies to set on your response. Can appear on any decision."},"crawler":{"$ref":"#/components/schemas/Crawler","description":"Crawler metadata when the request is identified as a known crawler and crawler metadata is enabled for your tenant. `category` and `rsl_category` are independent catalog values, so treat both as open strings."},"block_reasons":{"type":"array","items":{"type":"string"},"description":"Identifiers of the signals that drove a `block`. Still populated in monitor mode, where the decision is reported as `allow`. Treat it as an open list of strings."},"block_reason_categories":{"type":"array","items":{"type":"string","enum":["bot_identity","automated_browser","header_anomaly","fingerprint_mismatch","suspicious_network","challenge_failure","crawler_impersonator","unknown"]},"description":"Higher-level grouping of `block_reasons`. One of `bot_identity`, `automated_browser`, `header_anomaly`, `fingerprint_mismatch`, `suspicious_network`, `challenge_failure`, `crawler_impersonator`, or `unknown`."},"country_code":{"type":"string","description":"ISO 3166-1 alpha-2 country resolved for the client IP. Present only on `allow`, and only when country enrichment is enabled for your tenant."},"rate_limited":{"type":"boolean","description":"`true` when the decision was driven by a rate-limit hit."},"retry_after":{"type":"integer","description":"Seconds the client should wait before retrying when `rate_limited` is `true`."}}},"Cookie":{"type":"object","required":["name","value","domain","path","secure","same_site","max_age"],"properties":{"name":{"type":"string","example":"_centinel"},"value":{"type":"string","example":"<session-value>"},"domain":{"type":"string","example":"","description":" Empty means host-only; do not substitute your own domain."},"path":{"type":"string","example":"/"},"secure":{"type":"boolean","description":"Set the `Secure` attribute.","example":true},"same_site":{"type":"string","description":"Value for the `SameSite` attribute.","example":"Lax"},"max_age":{"type":"integer","description":"Lifetime in seconds for the `Max-Age` attribute. Preserve this value to retain the validator-specified cookie persistence lifetime.","example":86400}}},"Crawler":{"type":"object","required":["id","name","access_allowed"],"properties":{"id":{"type":"string","description":"Crawler identifier (UUID).","example":"4a7d7c8a-3fcb-4b91-8f7e-3f6ac1f5b6c1"},"name":{"type":"string","description":"Human-readable crawler name.","example":"Googlebot"},"access_allowed":{"type":"boolean","description":"`true` only when the identified crawler source IP is verified and the crawler is allowlisted for your tenant. Use the top-level decision to enforce the validation result."},"category":{"type":"string","description":"Catalog crawler type, copied unchanged. This is not the policy crawler.category value.","example":"LLM"},"rsl_category":{"type":"string","description":"Catalog RSL category, copied unchanged. It is independent of category and policy matching does not use it.","example":"ai-all"}}},"ValidateError":{"type":"object","required":["success","error"],"properties":{"success":{"type":"boolean","example":false},"error":{"type":"object","required":["type","message"],"properties":{"type":{"type":"string","description":"Stable error identifier (e.g., `invalid_request`, `invalid_api_key`).","example":"invalid_request"},"message":{"type":"string","description":"Human-readable error description."}}}}}}}}