Developer docs

The Active Defense API

Forward a suspected scam message, get the persona's approved reply back, pull the indicators the scammer leaked into your own tools, and check any IBAN, wallet, number or domain against them. One key.

Machine-readable: OpenAPI 3.1 · Base URL: https://activedefenseapi.com

Your key

Every call carries your API key in the X-API-Key header. Sign in to the console and open Integration: it shows your tenant id and key, renews the key (the old one stops working at once) and makes a separate signing secret for the callbacks.

1 · Forward a suspected scam message

POST /api/ingest/webhook. One honeypot conversation per sourceIdentifier: your stable id for the scammer's thread. The persona drafts a reply; it waits for approval in the console unless autopilot is on.

curl -X POST https://activedefenseapi.com/api/ingest/webhook \
  -H "X-API-Key: $AD_API_KEY" -H "Content-Type: application/json" \
  -d '{"sourceIdentifier": "user-1234", "content": "Hello dear, I need 500 EUR for the customs fee."}'

Answers 200 with {"webhookEventId", "sourceIdentifier", "status": "Accepted", "receivedAt"}, 400 without sourceIdentifier or content, 401 without a valid key. Optional: personaName picks another persona for a new conversation.

2 · Get the approved reply back

Pull

GET /api/ingest/replies?since=0&max=100 returns the replies after your cursor and the next cursor. Store nextCursor and pass it as since next time; replies stay available across restarts.

curl "https://activedefenseapi.com/api/ingest/replies?since=0&max=100" -H "X-API-Key: $AD_API_KEY"

{"replies": [{"seq": 1, "sourceIdentifier": "user-1234", "messageId": "…", "content": "Oh no, customs again?", "at": "…"}],
 "nextCursor": 1}

Push (callback)

Set a callback URL on Integration and every approved reply is POSTed there, signed. A callback that does not get a 2xx is tried again after 1, 5 and 30 minutes, 2 and 6 hours, so treat messageId as the idempotency key. The console logs every attempt and sends a signed test ("test": true) on request.

POST https://your-system.example/ad-callback
X-AD-Timestamp: 1760000000
X-AD-Signature: sha256=5f0c…
Content-Type: application/json

{"tenantId": "…", "sourceIdentifier": "user-1234", "messageId": "…", "reply": "Oh no, customs again?", "timestamp": "1760000000"}

X-AD-Signature is sha256= + hex(HMAC-SHA256(secret, timestamp + "." + raw body)). The secret is your signing secret, or your API key until you make one. Check the timestamp against your clock (here: at most 5 minutes apart) so a recorded request cannot be replayed, and compare in constant time.

// TypeScript (Node)
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, timestamp: string, rawBody: string, signature: string): boolean {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = Buffer.from("sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"));
  const given = Buffer.from(signature);
  return expected.length === given.length && timingSafeEqual(expected, given);
}
# Python
import hashlib, hmac, time

def verify(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:
        return False
    digest = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest("sha256=" + digest, signature)
// C#
static bool Verify(string secret, string timestamp, string rawBody, string signature)
{
    if (!long.TryParse(timestamp, out var at) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - at) > 300) return false;
    var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}"));
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes("sha256=" + Convert.ToHexStringLower(hash)), Encoding.UTF8.GetBytes(signature));
}

3 · WhatsApp through Twilio

Point your Twilio number's inbound webhook at POST /api/ingest/whatsapp/{apiKey}. Text, photos and voice messages become turns of the conversation with that number; the endpoint answers with empty TwiML at once. With a Twilio auth token configured, only requests with a valid X-Twilio-Signature count.

4 · Your indicator feed

Everything your conversations harvested (links, crypto wallets, bank drops, app hashes), with your API key as Authorization: Bearer, as the password of Basic auth or in X-API-Key:

  • TAXII 2.1 API root /taxii2/t/{tenantId}/, one collection (OpenCTI and any TAXII client), with added_after, match[id], match[type], limit and next
  • STIX 2.1 bundle /feeds/t/{tenantId}/stix.json
  • CSV /feeds/t/{tenantId}/iocs.csv: type, value, first_seen, last_seen, confidence, chats, days_active (first to last sighting), fresh (seen within 7 days), enrichment (what is publicly known, in one line)
curl https://activedefenseapi.com/feeds/t/$AD_TENANT_ID/iocs.csv -H "Authorization: Bearer $AD_API_KEY"
curl https://activedefenseapi.com/taxii2/t/$AD_TENANT_ID/collections/ -H "Authorization: Bearer $AD_API_KEY" \
  -H "Accept: application/taxii+json;version=2.1"

The Active Defense feed (add-on) carries what scammers handed to our own decoy accounts, as it comes: TAXII 2.1 root /taxii2/feed/, STIX /feeds/feed/stix.json, CSV /feeds/feed/iocs.csv, with the same key. It answers while a feed tier is booked and paid; otherwise 402 says why. The basic tier reads the CSV only, 24 hours delayed (TAXII and STIX answer 402 with the reason feed-tier); live and enterprise read every format as it comes, and a subscription for Active Defense includes the live feed.

5 · Check a value

POST /api/check looks up to 100 values a call in what scammers handed to Active Defense's decoys and in your own feed: IBANs, BICs, crypto wallets, phone numbers, e-mail addresses, links, domains, messenger handles (@name, t.me/name, wa.me/49…) and SHA-256 hashes, written as people write them. A domain also finds the links and addresses on it.

curl -X POST https://activedefenseapi.com/api/check \
  -H "X-API-Key: $AD_API_KEY" -H "Content-Type: application/json" \
  -d '{"values": ["DE89 3704 0044 0532 0130 00", "+57 300 123 4567", "evil.example"]}'

{"checkedAt": "…", "results": [
  {"value": "DE89 3704 0044 0532 0130 00", "kind": "iban", "known": true, "risk": 91,
   "firstSeen": "2026-09-02T14:12:00Z", "lastSeen": "2026-10-09T18:40:00Z", "chats": 3, "fresh": true,
   "rings": ["5f0c…"],
   "matches": [{"feed": "activedefense", "match": "exact", "type": "BankDrop",
                "value": "IBAN:DE89370400440532013000", "chats": 3, "firstSeen": "…", "lastSeen": "…", "confidence": 0.9}],
   "enrichment": {"summary": "bank Commerzbank; DE; BIC COBADEFFXXX", "source": "Bundesbank", "country": "DE",
                  "organisation": "Commerzbank", "kind": "bank", "code": "COBADEFFXXX", "…": "…"}},
  {"value": "+57 300 123 4567", "kind": "phone", "known": false, "risk": 0, "chats": 0, "matches": [],
   "enrichment": {"summary": "mobile; CO", "country": "CO", "kind": "mobile", "…": "…"}},
  {"value": "evil.example", "kind": "domain", "known": true, "risk": 52, "matches": [{"match": "domain", "type": "Url", "…": "…"}], "…": "…"}]}

Each result says how the value was read (kind), whether it is known, a risk from 0 to 100, first and last mention, in how many chats, whether it is fresh (mentioned within 7 days), the rings it belongs to (the same id in two results means the same operator) and every match with its feed (activedefense or own) and closeness (exact, or domain for the same site). enrichment carries what is publicly known: the bank of an IBAN, the country and kind of a number, what a wallet received, when a domain was registered and where it is hosted. lists names the public lists that know a match as well (URLhaus, OpenPhish, ScamSniffer, MEW darklist, checked daily). A risk of 0 means we have no evidence; it is no proof that a value is safe.

Every checked value is counted for your workspace (Integration shows the month's count). More than 100 values or one longer than 512 characters answer 400; a resting plan 402.

Plans and limits

Without a plan your workspace is a sandbox of a few conversations. A pilot or a subscription lifts that. When a plan does not take a new conversation, POST /api/ingest/webhook answers 402 with {"error": "plan-limit", "plan", "reason", "detail"} instead of dropping the message; running conversations go on. An invoice left open too long pauses new conversations, the autopilot and the feeds until it is paid; nothing is deleted.

Start with a workspace

Sign in to the console: your workspace, key and a sandbox persona are ready at once.