Skip to main content
OFACAlert

Developer reference

OFAC Sanctions API

Programmatic access to the same dataset that powers the live dashboard. Screen addresses in bulk, poll the sanctions-change event stream, and push alerts into your own compliance pipeline. JSON in, JSON out.

Already using Chainalysis' free Sanctions API for ad-hoc lookups? Keep it — it's great at that (GET-only, single address). This API adds what compliance teams build on: batch screening, a recent-events stream, and signed webhooks for the addresses you watch.

Quickstart

Base URLhttps://api.ofacalert.com
Auth headerX-Api-Key: ofac_…

Every path below is relative to the base URL. Generate and rotate your key from the dashboard (paid plans only). Send it on every request.

Your first call — screen one address:

Request
curl "https://api.ofacalert.com/api/v1/sanctions/check\
?chain=ETH&address=0x2f389ce8bd8ff92de3402ffce4691d17fc4f6535" \
  -H "X-Api-Key: ofac_xxxxxxxxxxxxxxxxxxxxxxxx"

Authentication & quota

Every /api/v1 call must include an X-Api-Key header. One HTTP request counts as one quota unit (a batch of 100 addresses is still a single unit). Quotas reset daily at 00:00 UTC.

PlanAPI accessDaily quota
FreeNo API
Pro1,000 / day
Team10,000 / day

CORS is enabled, so browser-side calls work too.

Endpoints

GET/api/v1/sanctions/check

Single-address lookup — the keyed equivalent of the homepage check widget. Returns whether the address is on the OFAC SDN list, plus the listing entity.

ParamTypeReq.Description
chainstringyesChain ticker, e.g. ETH, BTC, USDT. Case-insensitive.
addressstringyesThe address to screen.
Request
curl "https://api.ofacalert.com/api/v1/sanctions/check\
?chain=ETH&address=0x2f389ce8bd8ff92de3402ffce4691d17fc4f6535" \
  -H "X-Api-Key: ofac_xxxxxxxxxxxxxxxxxxxxxxxx"
Response · sanctioned
{
  "chain": "ETH",
  "address": "0x2f389ce8bd8ff92de3402ffce4691d17fc4f6535",
  "is_sanctioned": true,
  "status": "active",
  "first_seen_at": "2021-09-21T00:00:00Z",
  "entity": {
    "external_id": "31368",
    "name": "SUEX OTC, S.R.O.",
    "entity_type": "Entity",
    "country": "Russia",
    "programs": ["CYBER2"]
  }
}
Response · clean (not found)
{
  "chain": "ETH",
  "address": "0x1111111111111111111111111111111111111111",
  "is_sanctioned": false,
  "status": null,
  "first_seen_at": null,
  "entity": null
}
POST/api/v1/sanctions/check

Batch screening — the core value over ad-hoc lookups. Submit up to 100 addresses in one request (still one quota unit). Results are returned as an array in the same order as the input.

ParamTypeReq.Description
addressesarrayyes1–100 objects, each { chain, address }.
Request
curl https://api.ofacalert.com/api/v1/sanctions/check \
  -H "X-Api-Key: ofac_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "addresses": [
      { "chain": "ETH", "address": "0x2f389ce8bd8ff92de3402ffce4691d17fc4f6535" },
      { "chain": "BTC", "address": "1NDyJtNTjmwk5xPNhjgAMu4HDHigtobu1s" }
    ]
  }'
Response
[
  {
    "chain": "ETH",
    "address": "0x2f389ce8bd8ff92de3402ffce4691d17fc4f6535",
    "is_sanctioned": true,
    "status": "active",
    "first_seen_at": "2021-09-21T00:00:00Z",
    "entity": {
      "external_id": "31368", "name": "SUEX OTC, S.R.O.",
      "entity_type": "Entity", "country": "Russia", "programs": ["CYBER2"]
    }
  },
  {
    "chain": "BTC",
    "address": "1NDyJtNTjmwk5xPNhjgAMu4HDHigtobu1s",
    "is_sanctioned": false,
    "status": null,
    "first_seen_at": null,
    "entity": null
  }
]
GET/api/v1/events/recent

The sanctions-change event stream. Poll it to drive your own pipeline — every add, remove, or modification we detect, newest first.

ParamTypeReq.Description
limitintno1–200, default 50.
event_typestringnoFilter: entity_added, entity_removed, entity_modified, address_added, address_removed.
chainstringnoFilter to one chain, e.g. ETH.
Request
curl "https://api.ofacalert.com/api/v1/events/recent\
?limit=50&event_type=address_added" \
  -H "X-Api-Key: ofac_xxxxxxxxxxxxxxxxxxxxxxxx"
Response
[
  {
    "id": 84213,
    "event_type": "address_added",
    "detected_at": "2026-05-31T14:02:11Z",
    "entity_external_id": "31368",
    "entity_name": "SUEX OTC, S.R.O.",
    "entity_country": "Russia",
    "entity_programs": ["CYBER2"],
    "chain": "ETH",
    "address": "0x2f389ce8bd8ff92de3402ffce4691d17fc4f6535",
    "fields_changed": ["status"]
  }
]

Errors

Errors use standard HTTP status codes with a JSON detail body.

StatusMeaning
401Missing or invalid X-Api-Key.
403Key is valid but your plan has no API access (Free tier).
422Malformed request — bad chain/address length or batch over 100.
429Daily quota exceeded. Resets at 00:00 UTC.

Webhooks

A webhook lets OFAC Alert push a sanctions change to your own endpoint the moment we detect it — no polling. Everything you need to integrate is below: when it fires, the exact payload and headers, how to verify the signature, and how retries work.

When it fires

A webhook fires only for the (chain, address) pairs you watch. When OFAC adds or removes one of your watched addresses, you get a single POST — not a firehose of the full ~19k-entity SDN list. Entity-level changes (added / removed / modified) are delivered only when a channel's filters explicitly opt in via programs or event_types.

Setup

  1. Open your dashboard Notification channels.
  2. Pick Webhook, paste your HTTPS endpoint, and click Add.
  3. Click Send test to deliver a clearly-labelled sample alert. A successful test marks the channel verified.

Your endpoint must:

  • use https:// (plain HTTP is rejected),
  • resolve to a public host — private, loopback, link-local, and cloud metadata IPs are rejected (SSRF guard, re-checked at delivery time), and
  • return a 2xx status to acknowledge receipt.

The request

We send POST with Content-Type: application/json. Fields:

  • event_id — integer, stable id for this change (dedupe on it).
  • event_typeentity_added, entity_removed, entity_modified, address_added, or address_removed.
  • detected_at — ISO 8601 (UTC) detection time.
  • entity_external_id — OFAC UID of the entity (nullable).
  • entity_name, entity_country — nullable strings.
  • entity_programs — array of OFAC program codes (e.g. CYBER2).
  • chain, address — the crypto address for address-level events (both nullable for entity-level events).
  • fields_changed — array of field names that changed.
  • raw_diff — object with the raw before/after diff (nullable).
Example payload
{
  "event_id": 84213,
  "event_type": "address_added",
  "detected_at": "2026-05-31T14:02:11.530000+00:00",
  "entity_external_id": "12345",
  "entity_name": "SUEX OTC, S.R.O.",
  "entity_country": "Russia",
  "entity_programs": ["CYBER2"],
  "chain": "ETH",
  "address": "0x2f389ce8bd8ff92de3402ffce4691d17fc4f6535",
  "fields_changed": ["status"],
  "raw_diff": { "status": { "old": null, "new": "active" } }
}

Headers

  • X-OfacAlert-Timestamp — Unix time in seconds at send time.
  • X-OfacAlert-Signature — hex-encoded HMAC-SHA256 (see below).

Verifying the signature

Each channel has its own signing secret, shown beneath the channel in your dashboard (it looks like whsec_…). The signed string is exactly:

"{X-OfacAlert-Timestamp}." + rawRequestBody

Compute hex(hmac_sha256(secret, signedString)) and constant-time compare it to the X-OfacAlert-Signature header. Two rules that trip people up:

  • Verify against the raw bytes you received — not a re-serialized JSON object. Re-encoding changes key order and whitespace, so the HMAC will not match.
  • Reject stale timestamps (e.g. older than 5 minutes) so a captured request cannot be replayed.

Python

import hashlib
import hmac
import time

MAX_SKEW_SECONDS = 300  # reject anything older than 5 minutes (replay guard)

def verify(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    # 1. Replay protection — reject stale timestamps.
    try:
        ts = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(time.time() - ts) > MAX_SKEW_SECONDS:
        return False

    # 2. Recompute over the RAW bytes, not a re-serialized dict.
    signed = f"{timestamp}.".encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()

    # 3. Constant-time compare.
    return hmac.compare_digest(expected, signature)


# FastAPI / Flask: read request.body() / request.get_data() — the RAW bytes.
# Do NOT json.dumps(request.json) and sign that; key order / whitespace differ.
# raw = await request.body()
# ok = verify(raw, request.headers["X-OfacAlert-Timestamp"],
#             request.headers["X-OfacAlert-Signature"], YOUR_CHANNEL_SECRET)

Node.js

import crypto from "node:crypto";

const MAX_SKEW_SECONDS = 300; // reject anything older than 5 minutes

function verify(rawBody, timestamp, signature, secret) {
  // 1. Replay protection.
  const ts = Number.parseInt(timestamp, 10);
  if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > MAX_SKEW_SECONDS) {
    return false;
  }

  // 2. Recompute over the RAW body bytes.
  const signed = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
  const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");

  // 3. Constant-time compare (equal-length Buffers required).
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: use express.raw({ type: "application/json" }) so req.body is a Buffer.
// app.post("/hook", express.raw({ type: "application/json" }), (req, res) => {
//   const ok = verify(req.body, req.get("X-OfacAlert-Timestamp"),
//                      req.get("X-OfacAlert-Signature"), YOUR_CHANNEL_SECRET);
//   if (!ok) return res.status(401).end();
//   res.status(200).end();
// });

Idempotency

Dedupe on event_id. You may receive the same event more than once (a retry can land after you already processed the first delivery), so record handled event_ids and skip repeats.

Retries & failures

  • 2xx— acknowledged, we're done.
  • 5xx / timeout / network error — we retry once, ~2 seconds later.
  • 4xx — treated as rejected; no retry.

If your endpoint is down past the single retry, you can miss an alert — monitor it. Your dashboard's Recent alerts list shows the delivery status (sent / failed) per attempt.

Security notes

  • Always verify the signature before trusting a payload.
  • Only https:// endpoints are accepted.
  • Don't put secrets in the URL path or query string. We redact webhook URLs from error logs, but a clean URL is the safer default.
  • The signing secret is per channel. To rotate it, delete and recreate the channel — that issues a fresh secret.

Ready to integrate?

Get an API key from your dashboard — included with every paid plan.

Get an API key