How to build a correct CipherOwl API client — OAuth2 client-credentials token flow, token caching and refresh-on-401, rate-limit backoff, required headers, and base URLs — with copy-paste clients in Python, Go, TypeScript, and Java.
This page is the contract for talking to the CipherOwl API correctly. If you are an AI agent or writing an integration, implement a client that follows the rules below once, then reuse it for every endpoint — every CipherOwl API shares the same auth, headers, and error and rate-limit semantics. The examples here use the SRR API (Screening, Risk & Reporting), the focus of this guide.
The contract
| Concern | Rule |
|---|---|
| Auth | OAuth 2.0 client credentials. Exchange client_id + client_secret for a Bearer access token. |
| Token endpoint | POST https://svc.cipherowl.ai/oauth/token with audience: "svc.cipherowl.ai", grant_type: "client_credentials". |
| Token lifetime | The response has expires_in (seconds). Cache the token and reuse it until it is about to expire. |
| Refresh | There is no refresh_token. To "refresh", request a new token the same way. Also re-request on any 401. |
| Base URLs | https://svc.cipherowl.ai for Screening, Risk Reason, and the Report API. |
| Required headers | Authorization: Bearer <token> and Accept: application/json on every request. |
| Rate limits | On 429, back off and retry, honoring the Retry-After header when present. |
| Chain keys | Use the chain family key evm or tron. EVM network names like ethereum resolve to evm for Screening and Risk Reason; the Report API requires evm. |
Build the client, not one-off callsDo not paste a raw token into individual requests. A token expires (default 24h) and the shared gateway rate-limits bursts. A client that caches the token, refreshes on
401, and backs off on429is the difference between an integration that works once and one that keeps working.
Step 1 — Get an access token
Exchange your credentials for a token. Sign up at the CipherOwl application to obtain client_id and client_secret.
curl --request POST \
--url https://svc.cipherowl.ai/oauth/token \
--header 'content-type: application/json' \
--data '{
"client_id": "{YOUR_CLIENT_ID}",
"client_secret": "{YOUR_CLIENT_SECRET}",
"audience": "svc.cipherowl.ai",
"grant_type": "client_credentials"
}'{
"access_token": "{YOUR_ACCESS_TOKEN}",
"scope": "{YOUR_SCOPES}",
"expires_in": 86400,
"token_type": "Bearer"
}Note what is not there: no refresh_token. When the token expires (or any request returns 401), call this same endpoint again.
Step 2 — Call the API
Attach the token as a Bearer credential and ask for JSON.
curl --request GET \
--url 'https://svc.cipherowl.ai/api/screen/v1/chains/evm/addresses/0x93df2c9c8786242b8fdca3f96b3914b8b4a9f704?config=co-sandbox' \
--header 'accept: application/json' \
--header 'Authorization: Bearer {YOUR_ACCESS_TOKEN}'Step 3 — Handle expiry, 401, and 429
A correct client handles three things automatically:
- Expiry — cache the token and its
expires_in; fetch a new one shortly before it lapses (refreshing ~60s early avoids edge-of-expiry failures). 401 Unauthorized— the token was rejected (expired or revoked). Discard it, fetch a new one, and retry the request once.429 Too Many Requests— you are being rate-limited. Wait and retry, honoringRetry-After(seconds) when present; otherwise use exponential backoff.
A complete client
Each client below caches the token, refreshes on 401, backs off on 429, and exposes screen() and report() helpers. The same get(path, base) method works for every CipherOwl endpoint — pass the base URL (svc.cipherowl.ai) and any path from the API Reference.
import threading
import time
import requests
AUTH_URL = "https://svc.cipherowl.ai/oauth/token"
API_BASE = "https://svc.cipherowl.ai"
REPORT_BASE = "https://svc.cipherowl.ai" # Report API host
AUDIENCE = "svc.cipherowl.ai"
class CipherOwlClient:
def __init__(self, client_id: str, client_secret: str):
self._id = client_id
self._secret = client_secret
self._token = None
self._expires_at = 0.0
self._lock = threading.Lock()
def _fetch_token(self) -> None:
resp = requests.post(
AUTH_URL,
json={
"client_id": self._id,
"client_secret": self._secret,
"audience": AUDIENCE,
"grant_type": "client_credentials",
},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
self._token = data["access_token"]
# client_credentials has no refresh_token: "refresh" = re-request.
# Refresh 60s early to avoid edge-of-expiry failures.
self._expires_at = time.time() + data["expires_in"] - 60
def _bearer(self) -> str:
with self._lock:
if not self._token or time.time() >= self._expires_at:
self._fetch_token()
return self._token
def get(self, path: str, params: dict | None = None, base: str = API_BASE) -> dict:
retried_auth = False
for attempt in range(5):
resp = requests.get(
base + path,
params=params,
headers={
"Authorization": f"Bearer {self._bearer()}",
"Accept": "application/json",
},
timeout=30,
)
if resp.status_code == 401 and not retried_auth:
with self._lock:
self._token = None # force a fresh token, then retry once
retried_auth = True
continue
if resp.status_code == 429:
try:
wait = float(resp.headers.get("Retry-After", 2 ** attempt))
except ValueError:
wait = float(2 ** attempt) # Retry-After may be an HTTP date
time.sleep(wait)
continue
resp.raise_for_status()
return resp.json()
raise RuntimeError("exhausted retries")
def screen(self, chain: str, address: str, config: str = "co-sandbox") -> dict:
return self.get(
f"/api/screen/v1/chains/{chain}/addresses/{address}",
params={"config": config},
)
def report(self, chain: str, address: str, config: str = "co-sandbox") -> dict:
# The Report API lives on a different host (REPORT_BASE).
return self.get(
f"/api/report/v1/risk-assessment/chains/{chain}/addresses/{address}",
params={"config": config},
base=REPORT_BASE,
)
client = CipherOwlClient("YOUR_CLIENT_ID", "YOUR_CLIENT_SECRET")
print(client.screen("evm", "0x93df2c9c8786242b8fdca3f96b3914b8b4a9f704"))package cipherowl
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"sync"
"time"
)
const (
authURL = "https://svc.cipherowl.ai/oauth/token"
apiBase = "https://svc.cipherowl.ai"
reportBase = "https://svc.cipherowl.ai" // Report API host
audience = "svc.cipherowl.ai"
)
type Client struct {
id, secret string
http *http.Client
mu sync.Mutex
token string
expiresAt time.Time
}
func New(id, secret string) *Client {
return &Client{id: id, secret: secret, http: &http.Client{Timeout: 30 * time.Second}}
}
func (c *Client) fetchToken() error {
body, _ := json.Marshal(map[string]string{
"client_id": c.id,
"client_secret": c.secret,
"audience": audience,
"grant_type": "client_credentials",
})
resp, err := c.http.Post(authURL, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("token request failed: %s", resp.Status)
}
var t struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&t); err != nil {
return err
}
c.token = t.AccessToken
// No refresh_token in client_credentials: refresh = re-request. Refresh 60s early.
c.expiresAt = time.Now().Add(time.Duration(t.ExpiresIn-60) * time.Second)
return nil
}
func (c *Client) bearer() (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token == "" || time.Now().After(c.expiresAt) {
if err := c.fetchToken(); err != nil {
return "", err
}
}
return c.token, nil
}
// Get calls any CipherOwl path on the given base, refreshing the token on 401 and backing off on 429.
func (c *Client) Get(base, path string) ([]byte, error) {
retriedAuth := false
for attempt := 0; attempt < 5; attempt++ {
tok, err := c.bearer()
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, base+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
data, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, err
}
switch {
case resp.StatusCode == http.StatusUnauthorized && !retriedAuth:
c.mu.Lock()
c.token = "" // force a fresh token, then retry once
c.mu.Unlock()
retriedAuth = true
case resp.StatusCode == http.StatusTooManyRequests:
wait := time.Duration(1<<attempt) * time.Second
if ra, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
wait = time.Duration(ra) * time.Second
}
time.Sleep(wait)
case resp.StatusCode >= 200 && resp.StatusCode < 300:
return data, nil
default:
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, data)
}
}
return nil, fmt.Errorf("exhausted retries")
}
func (c *Client) Screen(chain, address, config string) ([]byte, error) {
return c.Get(apiBase, fmt.Sprintf("/api/screen/v1/chains/%s/addresses/%s?config=%s", chain, address, config))
}
func (c *Client) Report(chain, address, config string) ([]byte, error) {
return c.Get(reportBase, fmt.Sprintf("/api/report/v1/risk-assessment/chains/%s/addresses/%s?config=%s", chain, address, config))
}const AUTH_URL = "https://svc.cipherowl.ai/oauth/token";
const API_BASE = "https://svc.cipherowl.ai";
const REPORT_BASE = "https://svc.cipherowl.ai"; // Report API host
const AUDIENCE = "svc.cipherowl.ai";
export class CipherOwlClient {
private token?: string;
private expiresAt = 0;
private tokenPromise?: Promise<void>;
constructor(private clientId: string, private clientSecret: string) {}
private async fetchToken(): Promise<void> {
const res = await fetch(AUTH_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
client_id: this.clientId,
client_secret: this.clientSecret,
audience: AUDIENCE,
grant_type: "client_credentials",
}),
});
if (!res.ok) throw new Error(`token request failed: ${res.status}`);
const data = await res.json();
this.token = data.access_token;
// No refresh_token in client_credentials: refresh = re-request. Refresh 60s early.
this.expiresAt = Date.now() + (data.expires_in - 60) * 1000;
}
private async bearer(): Promise<string> {
if (!this.token || Date.now() >= this.expiresAt) {
// Coalesce concurrent refreshes into a single token request.
if (!this.tokenPromise) {
this.tokenPromise = this.fetchToken().finally(() => {
this.tokenPromise = undefined;
});
}
await this.tokenPromise;
}
return this.token!;
}
// get() calls any CipherOwl path on the given base, refreshing on 401 and backing off on 429.
async get(path: string, base = API_BASE): Promise<any> {
let retriedAuth = false;
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(base + path, {
headers: {
Authorization: `Bearer ${await this.bearer()}`,
Accept: "application/json",
},
});
if (res.status === 401 && !retriedAuth) {
this.token = undefined; // force a fresh token, then retry once
retriedAuth = true;
continue;
}
if (res.status === 429) {
const retryAfter = Number(res.headers.get("retry-after")); // NaN if absent or an HTTP date
const wait = Number.isFinite(retryAfter) ? retryAfter : 2 ** attempt;
await new Promise((r) => setTimeout(r, wait * 1000));
continue;
}
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
return res.json();
}
throw new Error("exhausted retries");
}
screen(chain: string, address: string, config = "co-sandbox") {
return this.get(`/api/screen/v1/chains/${chain}/addresses/${address}?config=${config}`);
}
// The Report API lives on a different host (REPORT_BASE).
report(chain: string, address: string, config = "co-sandbox") {
return this.get(
`/api/report/v1/risk-assessment/chains/${chain}/addresses/${address}?config=${config}`,
REPORT_BASE,
);
}
}import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Instant;
import java.util.Map;
public class CipherOwlClient {
private static final String AUTH_URL = "https://svc.cipherowl.ai/oauth/token";
private static final String API_BASE = "https://svc.cipherowl.ai";
private static final String REPORT_BASE = "https://svc.cipherowl.ai"; // Report API host
private static final String AUDIENCE = "svc.cipherowl.ai";
private final String clientId, clientSecret;
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper mapper = new ObjectMapper();
private String token;
private Instant expiresAt = Instant.EPOCH;
public CipherOwlClient(String clientId, String clientSecret) {
this.clientId = clientId;
this.clientSecret = clientSecret;
}
private synchronized void fetchToken() throws Exception {
String body = mapper.writeValueAsString(Map.of(
"client_id", clientId,
"client_secret", clientSecret,
"audience", AUDIENCE,
"grant_type", "client_credentials"));
HttpResponse<String> res = http.send(
HttpRequest.newBuilder(URI.create(AUTH_URL))
.header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(),
HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException("token request failed: " + res.statusCode());
JsonNode data = mapper.readTree(res.body());
token = data.get("access_token").asText();
// No refresh_token in client_credentials: refresh = re-request. Refresh 60s early.
expiresAt = Instant.now().plusSeconds(data.get("expires_in").asLong() - 60);
}
private synchronized String bearer() throws Exception {
if (token == null || Instant.now().isAfter(expiresAt)) fetchToken();
return token;
}
/** get() calls any CipherOwl path on the given base, refreshing on 401 and backing off on 429. */
public JsonNode get(String base, String path) throws Exception {
boolean retriedAuth = false;
for (int attempt = 0; attempt < 5; attempt++) {
HttpResponse<String> res = http.send(
HttpRequest.newBuilder(URI.create(base + path))
.header("Authorization", "Bearer " + bearer())
.header("Accept", "application/json").GET().build(),
HttpResponse.BodyHandlers.ofString());
if (res.statusCode() == 401 && !retriedAuth) {
synchronized (this) { token = null; } // force a fresh token, then retry once
retriedAuth = true;
continue;
}
if (res.statusCode() == 429) {
long wait = 1L << attempt;
try {
wait = res.headers().firstValue("retry-after").map(Long::parseLong).orElse(wait);
} catch (NumberFormatException ignored) {
// Retry-After may be an HTTP date; fall back to exponential backoff.
}
Thread.sleep(wait * 1000);
continue;
}
if (res.statusCode() / 100 != 2) throw new RuntimeException("HTTP " + res.statusCode() + ": " + res.body());
return mapper.readTree(res.body());
}
throw new RuntimeException("exhausted retries");
}
public JsonNode screen(String chain, String address, String config) throws Exception {
return get(API_BASE, "/api/screen/v1/chains/" + chain + "/addresses/" + address + "?config=" + config);
}
public JsonNode report(String chain, String address, String config) throws Exception {
return get(REPORT_BASE, "/api/report/v1/risk-assessment/chains/" + chain + "/addresses/" + address + "?config=" + config);
}
}Errors and status codes
Handle these consistently in your client:
| Status | Meaning | What the client should do |
|---|---|---|
200 | Success | Parse the JSON body. |
400 | Invalid request (bad chain, address, or config) | Do not retry; fix the request. The body explains what was wrong. |
401 | Token expired, revoked, or missing | Fetch a new token and retry once. |
403 | Authenticated, but not permitted (e.g. a config your plan can't use) | Do not retry; the credentials lack access. |
429 | Rate-limited | Back off (honor Retry-After) and retry. |
5xx | Server error | Surface the error. The sample clients don't retry 5xx; a transient 503 / 502 on a GET is safe to retry if you opt in. |
See Status Codes for the full list.
Next
You now have a working client. Use it to run a real compliance case end-to-end in the SRR Investigation Workflow (Sandbox) — authenticate, screen an address, triage the risk, pull the evidence, and generate a report, all for free against the sandbox.