When a new merchant asks "do we need OFAC screening for crypto payments?", the answer is the same as for any USD-denominated financial service: yes, and the surface area is larger than most engineers expect. Unlike card networks, which push compliance responsibility upstream to Visa and Mastercard, a crypto gateway operator is a direct participant in every transaction. If a sanctioned wallet sends USDC to your deposit address, that event lands on a public ledger — one that regulators can and do audit.
This post covers what OFAC actually requires, the three moments where you need to screen in a crypto checkout, how to integrate a risk-score API, and how to build a blocking flow that satisfies a compliance audit.
What OFAC actually requires#
The Office of Foreign Assets Control administers the Specially Designated Nationals and Blocked Persons List (SDN list). US persons, and any company processing USD-denominated transactions from US servers, cannot transact with SDN-listed entities. "Transact" includes receiving a payment.
Two rules matter most for crypto gateways:
Direct SDN match. An address on the SDN list itself is a clear block. OFAC publishes wallet addresses alongside individual names — as of 2025, the SDN list includes thousands of cryptocurrency addresses across Bitcoin, Ethereum, TRON, and other networks. The 2022 sanctioning of Tornado Cash contract addresses established that OFAC treats smart contract addresses exactly as it treats individual wallets.
The 50% rule. An entity that is 50% or more owned or controlled by an SDN-listed party is also considered sanctioned, even if not named explicitly. For crypto, this means cluster analysis matters: a wallet whose transaction history shows it is primarily funded by SDN-listed addresses may itself be implicated even before OFAC adds it to the list.
OFAC violations are strict liability. Intent is not a defense. A documented compliance program, good-faith screening, and prompt voluntary self-disclosure of any inadvertent contact are the factors that determine whether a violation results in a multi-million-dollar civil penalty or a cautionary letter.
The three screening moments#
A crypto payment passes through at least three identifiable moments where an address is known and screening is appropriate:
1. Payment intent creation. The merchant's backend calls
POST /v1/payment-intents with an optional payer_address. If the
checkout UI collected the shopper's wallet address before initiating
payment, screen it here. This is the cheapest block: the intent never
leaves created status and no on-chain funds are involved yet.
2. On-chain fund receipt. When the blockchain indexer detects funds
landing at the deposit address, the sender address is confirmed in the
transaction. Screen the confirmed sender address before advancing the
intent from awaiting_payment to pending_confirmation. This catches the
case where payer_address was not supplied at creation, or was spoofed.
3. Payout to beneficiary. If your gateway disburses funds to a third-party wallet — a payout to a contractor, a peer-to-peer transfer — screen the destination address before signing the outbound transaction. This is the mirror image of the inbound check and is just as legally required.
Integrating a risk-score API#
Chainalysis KYT,
TRM Labs, and
Elliptic all provide REST APIs that return a
risk score and structured exposure data for a given address. The mechanics
differ, but the shape of the call is the same: send a (chain, address)
pair and receive a categorical risk verdict plus a numeric exposure
breakdown.
Here is a simplified wrapper around the Chainalysis KYT v2 address-risk endpoint as used internally at BchainPay:
interface RiskResult {
address: string;
chain: string;
cluster_name: string | null;
risk_score: number; // 0–100
risk_category: "low" | "medium" | "high" | "severe";
direct_match: boolean; // address is on an SDN list
exposure: {
source: { category: string; value_usd: number }[];
destination: { category: string; value_usd: number }[];
};
}
async function screenAddress(
chain: string,
address: string,
): Promise<RiskResult> {
const res = await fetch(
`https://api.chainalysis.com/api/risk/v2/entities/${address}`,
{
headers: {
"Token": process.env.CHAINALYSIS_API_KEY!,
"Accept": "application/json",
"X-Chain": chainToKytNetwork(chain), // "ETHEREUM", "TRON", …
},
},
);
if (!res.ok) throw new Error(`KYT ${res.status}: ${await res.text()}`);
return res.json();
}At intent-creation time, use the address-risk endpoint as shown above — you do not yet have a transaction hash. At on-chain confirmation time, switch to the transfer-risk endpoint and include the transaction hash, because transfer-level queries return richer exposure data and are the standard for the on-chain screening record that auditors expect.
The blocking flow#
When a screen returns direct_match: true or risk_score >= HARD_BLOCK_THRESHOLD,
the intent transitions to blocked immediately. Nothing advances until a
human compliance officer or legal counsel makes a decision:
const risk = await screenAddress(intent.chain, confirmedSender);
if (risk.direct_match || risk.risk_score >= HARD_BLOCK_THRESHOLD) {
await db.query(
`UPDATE payment_intents
SET status = 'blocked',
blocked_reason = $2,
risk_score = $3,
version = version + 1,
updated_at = now()
WHERE id = $1
AND version = $expected_version`,
[intent.id, "OFAC_SDN_MATCH", risk.risk_score],
);
await emitWebhook(intent.merchant_id, {
event: "payment_intent.blocked",
data: {
id: intent.id,
reason: "OFAC_SDN_MATCH",
risk_score: risk.risk_score,
direct_match: risk.direct_match,
},
});
return; // do not advance the intent
}The payment_intent.blocked webhook gives the merchant's compliance team
the data they need to file a voluntary self-disclosure to OFAC if funds
landed before the block fired. Keep the funds frozen in the deposit address
until counsel advises — do not automatically return them (see the note on
freeze-and-hold below).
For risk_category: "high" but no direct match, the intent transitions
to pending_review rather than blocked. A human compliance reviewer sees
it in the queue within a defined SLA; the merchant sees status: pending_review
on the API and receives a payment_intent.review_required webhook:
{
"id": "pi_01HY9KFZRBA2Q…",
"status": "pending_review",
"risk": {
"score": 72,
"category": "high",
"direct_match": false,
"flagged_at": "2026-04-27T09:14:22Z"
}
}Setting thresholds#
There is no universal threshold — risk appetite varies by merchant type. These are the BchainPay defaults for new accounts:
| Condition | Action |
|---|---|
risk_score 0–29, direct_match: false |
Advance normally |
risk_score 30–69, direct_match: false |
pending_review |
risk_score 70+, direct_match: false |
blocked |
direct_match: true, any score |
blocked, OFAC hold |
Merchants processing institutional volumes often lower the review threshold to 20 to catch more edge cases earlier. High-throughput consumer merchants sometimes raise it to 40 to reduce false positives, but this must be documented, approved by counsel, and noted in the compliance program description — it is a risk acceptance decision, not an engineering one.
Handling false positives#
Risk-score APIs produce false positives. Address reuse, legitimate mixing services, exchange hot-wallet misattribution, and stale cluster data all generate elevated scores on clean wallets. Without a handling process, over-blocking alienates legitimate customers and creates friction your competition will exploit.
The pending_review state exists for this reason. The manual-review queue
exposes:
- The full risk score breakdown, including which exposure categories triggered the elevated score
- Block explorer links for the flagged address and its primary inbound counterparties
- Any KYC data the merchant collected for the payer
A compliance analyst can override pending_review to either confirmed
(allow) or blocked (deny) with a documented rationale. The override is
written to an immutable compliance_actions audit log, not just to the
intent record:
INSERT INTO compliance_actions (
payment_intent_id,
analyst_id,
decision,
rationale,
risk_score_at_review,
created_at
) VALUES (
$1,
$2,
'allow',
'Cluster misattribution: address is Binance hot wallet,
not SDN-linked. Confirmed via Chainalysis cluster label.',
72,
now()
);That log is what you show an auditor who asks "why was this high-risk payment allowed?" Without it, the decision looks arbitrary; with it, it is defensible.
Do not auto-return blocked funds#
A failure mode that surprises engineers: when a sanctioned wallet sends funds and the payment is blocked post-receipt, the instinct is to return the funds to the sender automatically. This is usually wrong.
Under OFAC guidance, returning funds to a sanctioned wallet without prior OFAC authorization is itself a sanctionable transaction. The correct process is to hold the funds, notify counsel, and file a voluntary self-disclosure if funds were received before the block fired. OFAC publishes a self-disclosure procedure and routinely reduces penalties for companies that report proactively.
This has a direct engineering implication: your blocked state must not
trigger any automatic sweep or refund logic. The treasury auto-sweep job
must explicitly skip blocked intents, and any refund endpoint must
reject requests for blocked intents with an informative error:
if (intent.status === 'blocked') {
return {
status: 403,
body: {
code: "intent_blocked_compliance_hold",
message: "This payment intent is under compliance hold. "
+ "Contact your compliance team before taking action.",
},
};
}Latency and API unavailability#
Screening adds latency. KYT-style APIs return in 80–400 ms under normal load. For the intent-creation check, this is acceptable inline. For the on-chain confirmation check, you are already waiting for block finality — a 400 ms screening call is invisible to the payer.
The failure mode to plan for: the screening API is unavailable. We treat
a screening API timeout as a soft hold, not an auto-approve. The intent
stays in awaiting_screening, a background job retries with exponential
back-off capped at three attempts over five minutes, and the merchant
sees status: pending_screening. If all retries exhaust, the intent moves
to pending_review for manual disposition:
# Retry logic in pseudocode:
# attempt 1 — immediate
# attempt 2 — 30 s delay
# attempt 3 — 2 min delay
# → exhausted: promote to pending_review, alert compliance teamAuto-approving on timeout is the path of least resistance and the one regulators treat harshest if a violation surfaces later. The retry-then- review policy should be in your written compliance program, not just a comment in the code.
Key takeaways#
- OFAC is strict liability. "We didn't know" is not a defense. A documented screening program is the primary mitigation factor when an inadvertent contact is discovered.
- Screen at three points: intent creation (if payer address is known), on-chain confirmation (mandatory), and outbound payout disbursement.
- Direct SDN match is a hard block; elevated risk score is a review queue. Do not collapse both into one action — false positives need a human path out, and the audit trail matters.
- Never auto-return blocked funds. Returning value to a sanctioned wallet without OFAC authorization may itself be a violation. Hold, disclose, and await counsel.
- Log every override in an immutable audit table. The ability to answer "why was this high-risk payment allowed?" separates a defensible compliance program from a liability.
- Auto-approving on API timeout is the wrong default. Hold with retry; if retries exhaust, escalate to manual review.