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
https://api.ofacalert.comX-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:
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.
| Plan | API access | Daily quota |
|---|---|---|
| Free | — | No API |
| Pro | ✓ | 1,000 / day |
| Team | ✓ | 10,000 / day |
CORS is enabled, so browser-side calls work too.
Endpoints
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.
| Param | Type | Req. | Description |
|---|---|---|---|
| chain | string | yes | Chain ticker, e.g. ETH, BTC, USDT. Case-insensitive. |
| address | string | yes | The address to screen. |
curl "https://api.ofacalert.com/api/v1/sanctions/check\ ?chain=ETH&address=0x2f389ce8bd8ff92de3402ffce4691d17fc4f6535" \ -H "X-Api-Key: ofac_xxxxxxxxxxxxxxxxxxxxxxxx"
{
"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": "ETH",
"address": "0x1111111111111111111111111111111111111111",
"is_sanctioned": false,
"status": null,
"first_seen_at": null,
"entity": null
}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.
| Param | Type | Req. | Description |
|---|---|---|---|
| addresses | array | yes | 1–100 objects, each { chain, address }. |
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" }
]
}'[
{
"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
}
]The sanctions-change event stream. Poll it to drive your own pipeline — every add, remove, or modification we detect, newest first.
| Param | Type | Req. | Description |
|---|---|---|---|
| limit | int | no | 1–200, default 50. |
| event_type | string | no | Filter: entity_added, entity_removed, entity_modified, address_added, address_removed. |
| chain | string | no | Filter to one chain, e.g. ETH. |
curl "https://api.ofacalert.com/api/v1/events/recent\ ?limit=50&event_type=address_added" \ -H "X-Api-Key: ofac_xxxxxxxxxxxxxxxxxxxxxxxx"
[
{
"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.
| Status | Meaning |
|---|---|
| 401 | Missing or invalid X-Api-Key. |
| 403 | Key is valid but your plan has no API access (Free tier). |
| 422 | Malformed request — bad chain/address length or batch over 100. |
| 429 | Daily 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
- Open your dashboard → Notification channels.
- Pick Webhook, paste your HTTPS endpoint, and click Add.
- 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_type—entity_added,entity_removed,entity_modified,address_added, oraddress_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).
{
"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}." + rawRequestBodyCompute 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