Build a CipherOwl Client

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

ConcernRule
AuthOAuth 2.0 client credentials. Exchange client_id + client_secret for a Bearer access token.
Token endpointPOST https://svc.cipherowl.ai/oauth/token with audience: "svc.cipherowl.ai", grant_type: "client_credentials".
Token lifetimeThe response has expires_in (seconds). Cache the token and reuse it until it is about to expire.
RefreshThere is no refresh_token. To "refresh", request a new token the same way. Also re-request on any 401.
Base URLshttps://svc.cipherowl.ai for Screening, Risk Reason, and the Report API.
Required headersAuthorization: Bearer <token> and Accept: application/json on every request.
Rate limitsOn 429, back off and retry, honoring the Retry-After header when present.
Chain keysUse 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 calls

Do 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 on 429 is 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:

  1. Expiry — cache the token and its expires_in; fetch a new one shortly before it lapses (refreshing ~60s early avoids edge-of-expiry failures).
  2. 401 Unauthorized — the token was rejected (expired or revoked). Discard it, fetch a new one, and retry the request once.
  3. 429 Too Many Requests — you are being rate-limited. Wait and retry, honoring Retry-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:

StatusMeaningWhat the client should do
200SuccessParse the JSON body.
400Invalid request (bad chain, address, or config)Do not retry; fix the request. The body explains what was wrong.
401Token expired, revoked, or missingFetch a new token and retry once.
403Authenticated, but not permitted (e.g. a config your plan can't use)Do not retry; the credentials lack access.
429Rate-limitedBack off (honor Retry-After) and retry.
5xxServer errorSurface 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.