Skip to main content

Integration Guide

This guide walks you through integrating Chainara's threat intelligence API into your application from scratch. By the end you'll have a working integration for wallet screening, threat feed synchronisation, and real-time webhook delivery.

Prerequisites: An active Chainara account with API access. The Threat Intelligence Feed and webhooks require an Enterprise plan. Contact your Chainara account manager if you need access.


1. Create an API key

Navigate to API Keys in the Platform sidebar and click Generate New Key.

Give it a descriptive name tied to the integration you're building (for example: exchange-screening-prod, siem-feed, compliance-tool). Using one key per integration makes it easy to rotate or revoke without affecting unrelated systems.

caution

The full key is shown once only at creation time. Copy it immediately and store it in a secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault) or at minimum an environment variable. Never hardcode it in source code.

Your key will look like: ek_... All API requests require the X-API-Key header:

X-API-Key: ek_YOUR_API_KEY

2. Make your first request

Base URL: https://{tenant}-platform.chainara.io/api/v2

Replace {tenant} with your tenant name (e.g. ripple-platform.chainara.io).

Verify your key is working with a health check:

curl "https://{tenant}-platform.chainara.io/api/v2/health" \
-H "X-API-Key: ek_YOUR_API_KEY"

Expected response:

{ "status": "ok", "version": "v2" }

3. Wallet screening

The most common integration: check a wallet address before allowing a transaction, withdrawal, or deposit.

Single wallet lookup

# XRPL wallet (blockchain_id = 1)
curl "https://{tenant}-platform.chainara.io/api/v2/wallets/1/rfFzQaMjeGn6sWkYhw5soUjnDigFN72Mpu" \
-H "X-API-Key: ek_YOUR_API_KEY"
import requests

BASE_URL = "https://{tenant}-platform.chainara.io/api/v2"
HEADERS = {"X-API-Key": "ek_YOUR_API_KEY"}

def check_wallet(blockchain_id: int, address: str) -> dict:
r = requests.get(
f"{BASE_URL}/wallets/{blockchain_id}/{address}",
headers=HEADERS,
timeout=10,
)
r.raise_for_status()
return r.json()

wallet = check_wallet(1, "rfFzQaMjeGn6sWkYhw5soUjnDigFN72Mpu")
print(wallet["risk_score"]) # 0–100
print(wallet["risk_level"]) # "low" | "medium" | "high" | "critical"
print(wallet["is_blacklisted"]) # bool
const BASE_URL = "https://{tenant}-platform.chainara.io/api/v2";
const API_KEY = process.env.CHAINARA_API_KEY!;

async function checkWallet(blockchainId: number, address: string) {
const res = await fetch(`${BASE_URL}/wallets/${blockchainId}/${address}`, {
headers: { "X-API-Key": API_KEY },
});
if (!res.ok) throw new Error(`Chainara API error: ${res.status}`);
return res.json();
}

const wallet = await checkWallet(1, "rfFzQaMjeGn6sWkYhw5soUjnDigFN72Mpu");
console.log(wallet.risk_score); // 0–100
console.log(wallet.is_blacklisted); // boolean

Risk score only (faster)

If you only need the score, use the /risk-score endpoint to avoid transferring the full wallet record:

curl "https://{tenant}-platform.chainara.io/api/v2/wallets/1/{address}/risk-score" \
-H "X-API-Key: ek_YOUR_API_KEY"
r = requests.get(
f"{BASE_URL}/wallets/1/{address}/risk-score",
headers=HEADERS,
timeout=5,
)
score = r.json()
# {"risk_score": 85, "risk_level": "high", "is_blacklisted": true}

Screening decision logic

def should_block(address: str, blockchain_id: int = 1) -> bool:
"""Returns True if the transaction should be blocked."""
try:
score = requests.get(
f"{BASE_URL}/wallets/{blockchain_id}/{address}/risk-score",
headers=HEADERS,
timeout=5,
).json()
return score["is_blacklisted"] or score["risk_score"] >= 75
except Exception:
# Fail open: log and allow if API is unreachable
logger.warning("Chainara unreachable for %s", address)
return False
async function shouldBlock(address: string, blockchainId = 1): Promise<boolean> {
const res = await fetch(
`${BASE_URL}/wallets/${blockchainId}/${address}/risk-score`,
{ headers: { "X-API-Key": API_KEY } }
);
const { is_blacklisted, risk_score } = await res.json();
return is_blacklisted || risk_score >= 75;
}
tip

Use risk_score >= 75 as your block threshold and risk_score >= 50 as a "flag for manual review" threshold. Adjust based on your risk tolerance. is_blacklisted: true should always hard-block.

Blockchain IDs

IDChainSymbolNetwork TypeStatus
1XRPLXRPxrplAvailable
2StellarXLMstellarAvailable
3RLUSDRLUSDxrplAvailable
4FlareFLRevmAvailable
5BitcoinBTCbitcoinAvailable
6EthereumETHevmAvailable
7BSCBNBevmExperimental
8PolygonMATICevmExperimental
9ArbitrumARBevmExperimental
10AvalancheAVAXevmExperimental
11SUISUIsuiExperimental
Experimental chains

Chains marked Experimental are wired into the API and threat feed but the in-product UI surfaces (wallet pages, fund-flow graphs, dashboards) are still being polished. You can submit fraud reports, ingest indicators, and receive webhook deliveries for them today; some interactive views may show a beta warning before opening. Available chains have full UI parity.

Wallet response schema

{
"address": "rfFzQaMjeGn6sWkYhw5soUjnDigFN72Mpu",
"blockchain_id": 1,
"risk_score": 100,
"risk_level": "critical",
"confidence": 1.0,
"is_blacklisted": true,
"severity_tier": "blacklisted",
"classification": "Scam Recipient",
"first_seen": "2025-08-14T00:00:00Z",
"last_active": "2026-02-10T14:22:00Z",
"signals": [
{
"type": "ripple_flagged",
"description": "Flagged by Ripple. Known bad actor.",
"weight": 0.35
}
],
"fraud_reports": [],
"associated_domains": []
}

4. Threat feed: initial bulk load

Before setting up real-time delivery, do a full snapshot pull to load the current threat database into your system.

# Full snapshot: all indicator types
curl "https://{tenant}-platform.chainara.io/api/v2/feed/snapshot" \
-H "X-API-Key: ek_YOUR_API_KEY"

# Blacklisted wallets only (XRPL)
curl "https://{tenant}-platform.chainara.io/api/v2/feed/snapshot?types=wallet&severity_tier=blacklisted&blockchain=xrpl" \
-H "X-API-Key: ek_YOUR_API_KEY"

# Malicious domains only, high confidence
curl "https://{tenant}-platform.chainara.io/api/v2/feed/snapshot?types=domain&min_confidence=80" \
-H "X-API-Key: ek_YOUR_API_KEY"

Snapshot response envelope

{
"schema_version": "1.0",
"type": "snapshot",
"generated_at": "2026-01-15T14:30:00.000Z",
"source": "chainara",
"total_count": 12750,
"next_cursor": "eyJvZmZzZXQiOjEwMDB9",
"indicators": [ ... ]
}

Paginating large snapshots

The feed returns up to 10,000 results per page. Use next_cursor to page through the full dataset:

def load_full_snapshot(types: str = None, since: str = None) -> list:
"""Pull all pages of the snapshot and return every indicator."""
all_indicators = []
params = {}
if types:
params["types"] = types
if since:
params["since"] = since

while True:
r = requests.get(
f"{BASE_URL}/feed/snapshot",
headers=HEADERS,
params=params,
)
r.raise_for_status()
data = r.json()
all_indicators.extend(data["indicators"])

if not data.get("next_cursor"):
save_last_sync_timestamp(data["generated_at"])
break

params["cursor"] = data["next_cursor"]

return all_indicators
async function loadFullSnapshot(params: Record<string, string> = {}) {
const indicators: any[] = [];
let cursor: string | undefined;

do {
const query = new URLSearchParams({ ...params, ...(cursor ? { cursor } : {}) });
const res = await fetch(`${BASE_URL}/feed/snapshot?${query}`, {
headers: { "X-API-Key": API_KEY },
});
const data = await res.json();
indicators.push(...data.indicators);
cursor = data.next_cursor;

if (!cursor) saveLastSyncTimestamp(data.generated_at);
} while (cursor);

return indicators;
}

Snapshot query parameters

ParameterTypeDescription
typesstringComma-separated: domain, wallet, domain_wallet_pair, fraud_report, community_report
severity_tierstringblacklisted or suspicious (wallets only)
min_confidenceintegerReturn only indicators with confidence >= this value (0–100)
blockchainstringxrpl, stellar, bitcoin, ethereum
sinceISO 8601Only return indicators added or updated after this timestamp
cursorstringPagination cursor from previous response. Cursors are valid for 1 hour. If your pagination loop takes longer, restart with a fresh snapshot request.
limitintegerResults per page. Max 10,000. Default 1,000

5. Incremental sync (polling)

After the initial bulk load, sync only new and updated indicators by passing the stored generated_at timestamp as since:

import requests, json
from pathlib import Path

BASE_URL = "https://{tenant}-platform.chainara.io/api/v2"
HEADERS = {"X-API-Key": "ek_YOUR_API_KEY"}
STATE_FILE = Path("/var/lib/myapp/chainara_sync_state.json")

def load_state() -> dict:
if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text())
return {}

def save_state(state: dict):
STATE_FILE.write_text(json.dumps(state))

def incremental_sync():
state = load_state()
params = {}
if last_sync := state.get("last_sync"):
params["since"] = last_sync

r = requests.get(f"{BASE_URL}/feed/snapshot", headers=HEADERS, params=params)
r.raise_for_status()
data = r.json()

new_count = 0
for indicator in data["indicators"]:
ingest_indicator(indicator) # your function
new_count += 1

save_state({"last_sync": data["generated_at"]})
print(f"Synced {new_count} indicators. Next sync from: {data['generated_at']}")

# Run on a cron every 5–15 minutes
incremental_sync()
import fs from "fs";

const STATE_FILE = "/var/lib/myapp/chainara_sync_state.json";

function loadState(): { lastSync?: string } {
try { return JSON.parse(fs.readFileSync(STATE_FILE, "utf8")); }
catch { return {}; }
}

function saveState(state: object) {
fs.writeFileSync(STATE_FILE, JSON.stringify(state));
}

async function incrementalSync() {
const { lastSync } = loadState();
const params = lastSync ? `?since=${lastSync}` : "";

const res = await fetch(`${BASE_URL}/feed/snapshot${params}`, {
headers: { "X-API-Key": API_KEY },
});
const data = await res.json();

for (const indicator of data.indicators) {
await ingestIndicator(indicator); // your function
}

saveState({ lastSync: data.generated_at });
console.log(`Synced ${data.total_count} indicators`);
}

Failure recovery

If your sync process fails mid-run, do not worry about missing data. Because you only update the stored last_sync timestamp after successfully processing all indicators, the next run will re-request from the same since value. This makes the sync idempotent: receiving a duplicate indicator is safe as long as your ingest function uses upsert rather than insert.

If your system was offline for an extended period, perform a full snapshot re-load (omit the since parameter) to reset to a known-good state.


Webhooks are the preferred approach for production. Chainara pushes new indicators to your endpoint within seconds of verification, with no polling required.

Register your endpoint

curl -X POST "https://{tenant}-platform.chainara.io/api/v2/webhooks" \
-H "X-API-Key: ek_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/webhooks/chainara",
"event_types": ["indicator_added", "indicator_updated", "indicator_removed"],
"indicator_types": ["wallet", "domain", "domain_wallet_pair"],
"description": "Production SIEM feed"
}'
r = requests.post(
f"{BASE_URL}/webhooks",
headers={**HEADERS, "Content-Type": "application/json"},
json={
"url": "https://your-app.example.com/webhooks/chainara",
"event_types": ["indicator_added", "indicator_updated", "indicator_removed"],
"indicator_types": ["wallet", "domain", "domain_wallet_pair"],
"description": "Production SIEM feed",
},
)
webhook = r.json()
# Store webhook["id"] and webhook["signing_secret"]; you'll need both
print(webhook["data"]["id"])
print(webhook["data"]["signing_secret"]) # SAVE THIS: shown once

Store the signing_secret from the response. You need it to verify signatures on every delivery.

Delivery formats

The optional format field controls how the payload is shaped. Defaults to json.

FormatUse case
jsonDefault. Full envelope, includes X-Chainara-Signature and X-Chainara-Timestamp headers for verification.
tinesFlatter payload optimised for Tines stories. Omits envelope metadata. Signature headers are not sent.
slackSlack Block Kit message. Post directly to a Slack incoming webhook URL.
teamsMicrosoft Teams Adaptive Card format.
discordDiscord embed format.

For Tines, set "format": "tines" and point url at your Tines webhook URL:

curl -X POST "https://{tenant}-platform.chainara.io/api/v2/webhooks" \
-H "X-API-Key: ek_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-team.tines.com/webhook/...",
"format": "tines",
"event_types": ["indicator_added", "indicator_updated", "indicator_removed"],
"indicator_types": ["wallet", "domain", "domain_wallet_pair"],
"description": "Tines SIEM feed"
}'

The tines payload looks like:

{
"source": "chainara",
"event": "indicator_added",
"entity_type": "wallet",
"indicator_type": "wallet",
"identifier": "rfFzQaMjeGn6sWkYhw5soUjnDigFN72Mpu",
"blockchain": "xrpl",
"risk_score": 100,
"risk_level": "critical",
"threat_type": "scam",
"tags": ["scam_recipient"],
"delivered_at": "2026-03-16T14:35:12.000Z",
"details": null
}

Webhook payload format

{
"schema_version": "1.0",
"type": "feed_update",
"event": "indicator_added",
"entity_type": "wallet",
"entity_id": "bl-1-rfFzQaMjeGn6sWkYhw5soUjnDigFN72Mpu",
"indicator_type": "wallet",
"delivered_at": "2026-03-16T14:35:12.000Z",
"identifier": "rfFzQaMjeGn6sWkYhw5soUjnDigFN72Mpu",
"risk_score": 100,
"risk_level": "critical",
"blockchain": "xrpl",
"source_tier": "blacklisted",
"tags": ["scam_recipient"],
"threat_type": "scam",
"relationship": "drain_target",
"details": null
}
FieldDescription
risk_score, risk_level, blockchain, threat_typeRich entity data, included on every delivery so your SIEM/ticketing tool does not need a follow-up API call to score the indicator.
source_tierblacklisted (confirmed malicious, block hard) or suspicious (flagged for monitoring). Lets you route deliveries to different queues or severity bands.
relationshipOnly present on domain_wallet_pair deliveries. Currently drain_target: the domain is the phishing front, the wallet is where stolen funds land.
tagsFree-form labels (e.g. scam_recipient, rippleflagged, fraud_verified).
Webhook delivery deduplication

Chainara automatically deduplicates webhook deliveries: if the same indicator is detected multiple times in rapid succession, only the first delivery is sent. This prevents alert fatigue without needing dedup logic on your side. If you need every raw event, use the snapshot API with since={timestamp} polling instead.

Fan-out for multi-entity reports

A single fraud report that references both a domain and several wallets generates separate webhook deliveries, one per entity. Example: a report with one domain and three wallets fires four webhooks (one domain event plus three wallet events, plus domain_wallet_pair events for each domain↔wallet pairing). Process each delivery independently. Do not assume one report equals one delivery.

Verify signatures: always do this

Every delivery includes an X-Chainara-Signature header. Verify it before processing any payload to prevent spoofed requests:

import hmac
import hashlib
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = "your_signing_secret_from_registration"

@app.route("/webhooks/chainara", methods=["POST"])
def handle_chainara_webhook():
signature = request.headers.get("X-Chainara-Signature", "")
timestamp = request.headers.get("X-Chainara-Timestamp", "")
body = request.get_data(as_text=True) # raw string

# Signature covers "{timestamp}.{body}", not body alone
message = f"{timestamp}.{body}"
expected = hmac.new(
WEBHOOK_SECRET.encode(),
message.encode(),
hashlib.sha256,
).hexdigest()

if not hmac.compare_digest(expected, signature):
abort(400, "Invalid signature")

data = request.get_json()
process_indicator(data)

return "", 200
import express from "express";
import crypto from "crypto";

const app = express();
const WEBHOOK_SECRET = process.env.CHAINARA_WEBHOOK_SECRET!;

app.post(
"/webhooks/chainara",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["x-chainara-signature"] as string;
const timestamp = req.headers["x-chainara-timestamp"] as string;

// Signature covers "{timestamp}.{body}", not body alone
const message = `${timestamp}.${req.body.toString()}`;
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(message)
.digest("hex");

if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
return res.status(400).send("Invalid signature");
}

const data = JSON.parse(req.body.toString());
processIndicator(data);

res.status(200).send("ok");
}
);
caution

Use express.raw() (not express.json()) before signature verification. You need the raw bytes to compute the HMAC correctly. Parsing JSON first changes the byte representation.

Webhook events

EventMeaning
indicator_addedNew threat verified and added to the feed
indicator_updatedExisting indicator updated with new evidence or risk score change
indicator_removedIndicator removed: false positive confirmed or domain taken down
takedown_submittedA flagged domain was submitted to a takedown service (Google Web Risk, Netcraft, URLhaus, SmartScreen, OTX, VirusTotal). Useful for forwarding to Slack/Teams/Tines so analysts know action has been taken.
tip

Subscribe to takedown_submitted separately from indicator_added if you want operational visibility (e.g. an analyst Slack channel) without flooding it with every detection event.

Test your endpoint

# Send a test delivery immediately
curl -X POST "https://{tenant}-platform.chainara.io/api/v2/webhooks/{id}/test" \
-H "X-API-Key: ek_YOUR_API_KEY"

# View delivery history and response codes
curl "https://{tenant}-platform.chainara.io/api/v2/webhooks/{id}/deliveries" \
-H "X-API-Key: ek_YOUR_API_KEY"
note

Test deliveries use a synthetic indicator. risk_score, risk_level, blockchain, and tags will be null. This is expected. Real deliveries are fully hydrated from live data.

Retry policy

If your endpoint returns non-2xx or times out (>10s), Chainara retries automatically:

AttemptDelay
130 seconds
22 minutes
310 minutes
41 hour
5Final attempt

If your endpoint is down for an extended period, use GET /feed/snapshot?since={timestamp} to catch up on missed events once it recovers.

Rotate the signing secret

curl -X POST "https://{tenant}-platform.chainara.io/api/v2/webhooks/{id}/rotate-secret" \
-H "X-API-Key: ek_YOUR_API_KEY"

Update CHAINARA_WEBHOOK_SECRET in your environment with the new secret. During rotation, briefly accept both old and new signatures to avoid dropped deliveries.


7. Processing indicators

A complete indicator processor that handles all five indicator types:

def process_indicator(indicator: dict):
itype = indicator["type"]

if itype == "wallet":
handle_wallet(indicator)
elif itype == "domain":
handle_domain(indicator)
elif itype == "domain_wallet_pair":
handle_domain_wallet_pair(indicator)
elif itype == "fraud_report":
handle_fraud_report(indicator)
elif itype == "community_report":
handle_community_report(indicator)

def handle_wallet(w: dict):
if w["severity_tier"] == "blacklisted":
# Hard block: do not allow transactions
blocklist.add(w["value"], reason=w.get("description"))
elif w["severity_tier"] == "suspicious":
# Flag for monitoring: don't block but alert on activity
watchlist.add(w["value"], risk_score=w["risk_score"])

def handle_domain(d: dict):
# Block DNS resolution / outbound requests to this domain
domain_blocklist.add(d["value"], risk_level=d["risk_level"])

def handle_domain_wallet_pair(pair: dict):
# Highest-value signal: a confirmed scam site and its drain wallet
log_connection(
domain=pair["domain"],
wallet=pair["wallet"],
relationship=pair["relationship"], # "drain_target"
confidence=pair["confidence"],
)
type Indicator = {
type: "wallet" | "domain" | "domain_wallet_pair" | "fraud_report" | "community_report";
[key: string]: any;
};

function processIndicator(indicator: Indicator) {
switch (indicator.type) {
case "wallet":
if (indicator.severity_tier === "blacklisted") {
blocklist.add(indicator.value);
} else {
watchlist.add(indicator.value, indicator.risk_score);
}
break;
case "domain":
domainBlocklist.add(indicator.value);
break;
case "domain_wallet_pair":
logInfrastructureLink(indicator.domain, indicator.wallet);
break;
case "fraud_report":
case "community_report":
ingestIntelReport(indicator);
break;
}
}

8. Submit threat reports (contribute intelligence)

If your platform discovers fraud, submit it back to Chainara to enrich the shared intelligence:

curl -X POST "https://{tenant}-platform.chainara.io/api/v2/fraud-reports" \
-H "X-API-Key: ek_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"blockchain_id": 1,
"wallet_address": "rHbiqV4hJCSJfmHBX2RGxrHEp1d5RN1jdQ",
"scam_type": "fake_giveaway",
"description": "Wallet promoted via a fake XRP doubling scheme on Twitter/X",
"domain": "xrp-airdrop-bonus.live",
"evidence_urls": ["https://twitter.com/example/status/123456"]
}'
r = requests.post(
f"{BASE_URL}/fraud-reports",
headers={**HEADERS, "Content-Type": "application/json"},
json={
"blockchain_id": 1,
"wallet_address": "rHbiqV4hJCSJfmHBX2RGxrHEp1d5RN1jdQ",
"scam_type": "fake_giveaway",
"description": "Fake XRP giveaway promoted via Twitter DMs",
"domain": "xrp-airdrop-bonus.live",
},
)
print(r.json()) # {"id": "fr-...", "status": "pending"}

Scam type values: fake_giveaway, phishing, investment_scam, rug_pull, money_laundering, ponzi, exchange_hack, mixer

Submitted reports enter the queue with status: pending and are reviewed by Chainara analysts. Once verified, the wallet's risk score is recalculated and the indicator flows back through the feed to all subscribers.

Reports referencing multiple wallets across chains

A single fraud report often involves more than one wallet. For example, a scam page may drain XRP, ETH, and BTC. Use the wallets[] format to submit them all at once with the correct blockchain_id per wallet:

curl -X POST "https://{tenant}-platform.chainara.io/api/v2/fraud-reports" \
-H "X-API-Key: ek_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"scam_type": "phishing",
"domain": "fake-ripple-airdrop.com",
"description": "Phishing site draining wallets across multiple chains",
"wallets": [
{ "address": "rXRPdrainer...", "blockchain_id": 1 },
{ "address": "0xETHdrainer...", "blockchain_id": 6 },
{ "address": "bc1qBTCdrainer...", "blockchain_id": 5, "destinationTag": null }
]
}'
FieldDescription
wallets[].addressThe wallet address (10–150 chars, alphanumeric + :_-)
wallets[].blockchain_idThe blockchain for that specific wallet (see Blockchain IDs)
wallets[].destinationTagOptional, for chains that support memos / dest tags (XRPL, Stellar)

The legacy wallet_address and walletAddresses[] fields still work for backwards compatibility, but wallets[] is preferred when wallets span multiple chains. Each wallet generates its own webhook fan-out delivery on verification (see Fan-out for multi-entity reports).

Bulk ingest your own threat data

If you have an existing threat database, use the ingest endpoints to import it in bulk:

# Bulk wallets
curl -X POST "https://{tenant}-platform.chainara.io/api/v2/ingest/wallets" \
-H "X-API-Key: ek_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"wallets": [
{ "blockchain_id": 1, "address": "rADDR1...", "reason": "Giveaway scam", "confidence": 0.9 },
{ "blockchain_id": 1, "address": "rADDR2...", "reason": "Phishing drain", "confidence": 0.85 }
]
}'

# Bulk domains
curl -X POST "https://{tenant}-platform.chainara.io/api/v2/ingest/domains" \
-H "X-API-Key: ek_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"domains": [
{ "domain": "xrp-phish-example.xyz", "category": "phishing" },
{ "domain": "fake-ripple-airdrop.com", "category": "fake_giveaway" }
]
}'

For large datasets, use the async batch endpoint:

# Submit async batch
curl -X POST "https://{tenant}-platform.chainara.io/api/v2/ingest/batch" \
-H "X-API-Key: ek_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "records": [ ... ] }'

# Poll until complete
curl "https://{tenant}-platform.chainara.io/api/v2/ingest/batch/{jobId}" \
-H "X-API-Key: ek_YOUR_API_KEY"
note

Ingest submissions are tagged source_tier: community and capped at a risk score of 65 until an analyst reviews and verifies them. This prevents unreviewed data from triggering hard blocks.


9. Submit domains for scanning

Three dedicated endpoints handle domain submission, each serving a different purpose:

EndpointWrites to DBQueues scannerUse case
POST /domains/reportYesYesKnown-malicious domain: record it and scan it
POST /domains/monitorNoYesFire-and-forget scan from a tip list or third-party IOC feed
POST /domains/scanYesYesEnterprise: bypass the risk-score gate and scan a suspected domain immediately
GET /domains/lookup--Check a domain's current threat record or poll after a report submission

/domains/scan is the right choice when your team already suspects a domain is malicious and wants the Intel scanner to investigate it now rather than wait for evidence to accumulate. Submissions are tagged source_tier=tenant_submitted with a baseline risk score that the scanner can raise. Resubmissions are idempotent: the existing record is updated, never downgraded.

# Enterprise: scan now, bypass risk-score gate
curl -X POST "https://{tenant}-platform.chainara.io/api/v2/domains/scan" \
-H "X-API-Key: ek_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "domain": "suspicious-ripple-airdrop.example", "blockchain_id": 1 }'

# Then poll for the scanner verdict
curl "https://{tenant}-platform.chainara.io/api/v2/domains/lookup?domain=suspicious-ripple-airdrop.example" \
-H "X-API-Key: ek_YOUR_API_KEY"

Report a malicious domain

Use this when you've identified a domain as malicious and want it in Chainara's threat database and immediately investigated by the Intel scanner.

curl -X POST "https://{tenant}-platform.chainara.io/api/v2/domains/report" \
-H "X-API-Key: ek_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"domain": "ripple-phish.com",
"blockchain_id": 1,
"threat_type": "phishing",
"confidence": 0.9,
"reason": "Impersonating Ripple login page"
}'
r = requests.post(
f"{BASE_URL}/domains/report",
headers={**HEADERS, "Content-Type": "application/json"},
json={
"domain": "ripple-phish.com",
"blockchain_id": 1, # optional, omit if unknown
"threat_type": "phishing",
"confidence": 0.9,
"reason": "Impersonating Ripple login page",
},
)
result = r.json()
print(result["status"]) # "scan_queued" or "recorded_pending_queue"
print(result["is_resubmission"]) # True if domain was already in the DB

The domain is upserted at a baseline risk score of 55, deliberately below the platform's auto-publish gate. The Intel scanner owns the final score after investigation.

blockchain_id is optional. If you don't know which blockchain the domain is associated with, omit it. The response will show blockchain_context_known: false and the domain will never be misrepresented as belonging to any specific chain.

Resubmission is safe. The same domain can be submitted multiple times. Existing risk scores are never downgraded and a total_requests counter increments in metadata.

If Pub/Sub is temporarily unavailable, the domain is still recorded in the database. The response shows status: recorded_pending_queue so you know to retry to attempt queuing.

{
"report_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"domain": "ripple-phish.com",
"blockchain_id": 1,
"blockchain": "XRPL",
"blockchain_context_known": true,
"status": "scan_queued",
"is_resubmission": false,
"domain_record_id": 158014,
"publisher": { "success": true, "message_id": "1234567890123456" },
"submitted_at": "2026-03-24T23:27:41.384Z"
}

Queue a domain for scanning only

Use /domains/monitor when you want the Intel scanner to investigate a domain but don't want anything written to Chainara's threat database. Good for processing a bulk tip list or a third-party IOC feed.

curl -X POST "https://{tenant}-platform.chainara.io/api/v2/domains/monitor" \
-H "X-API-Key: ek_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"domain": "suspicious-ripple-site.net",
"threat_type": "typosquatting",
"confidence": 0.75,
"reason": "Looks like a typosquat of ripple.com"
}'

Unlike /domains/report, this endpoint returns 503 (not 202) when Pub/Sub is unavailable. There is no DB fallback, so the caller must know it failed and retry.

Look up a domain's threat record

Poll after a report submission to see if the Intel scanner has updated the risk score, or check whether a domain is already known before submitting.

# Single lookup
curl "https://{tenant}-platform.chainara.io/api/v2/domains/lookup?domain=ripple-phish.com" \
-H "X-API-Key: ek_YOUR_API_KEY"

# Filter to a specific blockchain context
curl "https://{tenant}-platform.chainara.io/api/v2/domains/lookup?domain=ripple-phish.com&blockchain_id=1" \
-H "X-API-Key: ek_YOUR_API_KEY"

Returns a single object when exactly one record matches, or an array when the domain has been reported under multiple blockchain contexts.

def poll_domain_scan(domain: str, max_attempts: int = 10) -> dict:
"""Poll /domains/lookup until the risk score rises above baseline (55)."""
for _ in range(max_attempts):
r = requests.get(
f"{BASE_URL}/domains/lookup",
headers=HEADERS,
params={"domain": domain},
)
if r.status_code == 404:
time.sleep(5)
continue
data = r.json()
record = data if isinstance(data, dict) else data[0]
if record.get("risk_score", 0) > 55:
return record # scanner has updated the score
time.sleep(10)
return {}

Domain normalisation

All three endpoints apply the same normalisation before storing or queuing:

InputNormalised to
https://evil.com/path?x=1evil.com
http://phish.io:8080phish.io
RIPPLE-PHISH.COM.ripple-phish.com
not_a_domainrejected (underscores invalid, no TLD)
localhostrejected (single label, no TLD)

10. Error handling and rate limits

StatusMeaningAction
400Bad requestCheck address format, required fields
401UnauthorizedCheck your X-API-Key header
403ForbiddenYour plan doesn't include this endpoint
404Not foundAddress not in database: treat as unknown, not safe
429Rate limitedRespect the Retry-After header
500Server errorRetry with exponential backoff
LimiterWindowLimit
Enterprise (per-minute)1 min500 requests
Enterprise (burst)2 hrs10,000 requests
Public endpoints15 min1,000 requests
Bulk operations1 hr100 requests
Auth endpoints15 min10 requests

Monthly limit: 100,000 requests per API key.

import time

def request_with_retry(url: str, max_retries: int = 3, **kwargs) -> requests.Response:
for attempt in range(max_retries):
r = requests.get(url, headers=HEADERS, timeout=10, **kwargs)
if r.status_code == 429:
wait = int(r.headers.get("Retry-After", 60))
time.sleep(wait)
continue
if r.status_code >= 500:
time.sleep(2 ** attempt)
continue
return r
raise RuntimeError(f"Request to {url} failed after {max_retries} retries")
async function fetchWithRetry(url: string, options = {}, retries = 3): Promise<Response> {
for (let attempt = 0; attempt < retries; attempt++) {
const res = await fetch(url, { ...options, headers: { "X-API-Key": API_KEY } });
if (res.status === 429) {
const wait = parseInt(res.headers.get("Retry-After") ?? "60") * 1000;
await new Promise(r => setTimeout(r, wait));
continue;
}
if (res.status >= 500) {
await new Promise(r => setTimeout(r, 2 ** attempt * 1000));
continue;
}
return res;
}
throw new Error(`Request to ${url} failed after ${retries} retries`);
}

Exchange / wallet transaction screening

Incoming transaction

GET /wallets/{blockchain_id}/{address}/risk-score ← real-time, low latency

risk_score >= 75 OR is_blacklisted? → Block + alert
risk_score 50–74? → Flag for manual review
risk_score < 50? → Allow

Keep a local blocklist cache (Redis or in-memory) seeded from the threat feed. For addresses already in your cache, skip the API call. Refresh the cache via incremental sync every 5–15 minutes.

SIEM / threat intelligence platform

Day 1:  GET /feed/snapshot                         ← bulk load entire database
→ import all indicators into your SIEM

Ongoing:
Option A: Webhooks (recommended):
POST /webhooks ← subscribe once
→ Chainara pushes new/updated/removed events ← real-time

Option B: Polling (simpler, slightly delayed):
Every 5–15 min: GET /feed/snapshot?since=... ← incremental diff