SpendPreflight
Practical guides · October 29, 2026

Batch-screen crypto addresses for OFAC in Google Sheets, Python or Zapier

Send one small batch, keep each result beside its source time, and send possible matches or unavailable results to review.

We may earn a commission from Apify referrals.

By SpendPreflight, the operator of the tools below. Informational screening, not legal advice or a compliance certification.

A non-match is not sanctions clearance. This workflow checks exact digital-currency address fields in our existing OFAC SDN snapshot. It does not assess ownership, the 50 Percent Rule, transaction exposure, geography or other sanctions lists.

Choose a route before importing your list

The shared request and result

Use full public identifiers, never seed phrases, private keys, names or explorer URLs. These two public identifiers demonstrate syntax only; they are not recommended payees or a promised screening outcome.

{
  "addresses": [
    "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
    "0x0000000000000000000000000000000000000000"
  ]
}

POST this object to https://api.spendpreflight.com/v1/ofac-address-check with Content-Type: application/json. Send only addresses; do not add names, metadata or URL parameters. The whole batch is rejected if any item is invalid. Order and duplicate entries are preserved. EVM identifiers are compared without case; other identifiers compare exactly. This is not checksum or chain validation.

HTTP 200 returns count and an ordered results array. For every row retain address, address_match, match, snapshot_built_at, checked_at, source, scope and disclaimer when reviewing the full JSON. A true flag needs review against the official record; false means only that the identifier was not listed in that snapshot. Missing flags, errors and timeouts mean no determination.

The server requires more than 1,000 SDN entries and a valid, nonfuture snapshot build time no older than 26 hours. That is a freshness boundary, not instantaneous OFAC updates. Invalid input returns 400, a busy free service returns 429, and unavailable fresh data returns 503. GET and HEAD do not screen. The anonymous 30-requests/minute counter is shared per server instance, not an allocated per-user quota. Stop and review errors; do not turn them into non-matches. Authoritative schema.

Google Sheets: one manual run, not a recalculating formula

  1. Use a new sheet for public addresses only. Put these headers in A1:F1: address, status, snapshot_built_at, checked_at, scope, disclaimer. Reserve columns B–F for results; the script replaces them for the selected rows.
  2. Paste addresses below A1. Open Extensions → Apps Script, replace the starter function with the code below and save it.
  3. Select 1–10 populated address cells in column A, starting below the header. In Apps Script choose screenOfacSelection and run it manually. Review Google's authorization prompt in your own account; external requests and spreadsheet access are required. Organization policies or Google quotas may block execution.
  4. Read the status and timestamps in B–F. The script first replaces selected rows' previous results with NO DETERMINATION, then writes results only after the entire response validates. It does not create a trigger, send mail or move funds.
// Select 1-10 populated cells in column A, below the header. Run manually.
function screenOfacSelection() {
  const range = SpreadsheetApp.getActiveRange();
  const n = range.getNumRows();
  if (range.getColumn() !== 1 || range.getRow() < 2 ||
      range.getNumColumns() !== 1 || n < 1 || n > 10) {
    throw new Error('Select 1-10 populated address cells in column A, below the header.');
  }
  const addresses = range.getDisplayValues().map(row => row[0].trim());
  const output = range.offset(0, 1, n, 5);
  // Replace old results before making a request, so a failure cannot leave stale success.
  output.setValues(addresses.map(() => ['NO DETERMINATION', '', '', '', '']));
  if (addresses.some(a => !/^[A-Za-z0-9]{14,256}$/.test(a) ||
      (/^0x/i.test(a) && !/^0x[0-9a-fA-F]{40}$/.test(a)))) {
    throw new Error('Invalid address or blank cell; no request sent.');
  }
  const response = UrlFetchApp.fetch('https://api.spendpreflight.com/v1/ofac-address-check', {
    method: 'post', contentType: 'application/json',
    payload: JSON.stringify({addresses}), muteHttpExceptions: true, followRedirects: false
  });
  if (response.getResponseCode() !== 200) {
    throw new Error('No determination: HTTP ' + response.getResponseCode() + '. Review before retrying.');
  }
  const data = JSON.parse(response.getContentText());
  if (data.count !== n || !Array.isArray(data.results) || data.results.length !== n) {
    throw new Error('No determination: incomplete response.');
  }
  const values = data.results.map((r, i) => {
    const expected = addresses[i].startsWith('0x') ? addresses[i].toLowerCase() : addresses[i];
    if (r.address !== expected || typeof r.address_match !== 'boolean' ||
        ['snapshot_built_at', 'checked_at', 'scope', 'disclaimer'].some(k => typeof r[k] !== 'string' || !r[k])) {
      throw new Error('No determination: unexpected result.');
    }
    return [r.address_match ? 'EXACT MATCH: REVIEW' : 'NOT LISTED IN THIS SNAPSHOT',
      r.snapshot_built_at, r.checked_at, r.scope, r.disclaimer];
  });
  output.setValues(values);
}

Use a manual function rather than a custom spreadsheet formula: recalculation can produce unexpected requests. Review possible matches in the official OFAC search; this compact sheet is not a substitute for the full associated record. Google documents the request options and authorization scope in UrlFetchApp.

Python: a bounded POST with CSV output

Save the following as screen_ofac.py. It uses Python 3's standard library, sends one request with a 15-second timeout, and has no automatic retry. It validates every response row before writing any CSV. Keep the scope and timestamps with exported rows.

"""One free request for 1-10 public addresses; no automatic retries."""
import csv
import json
import re
import sys
import urllib.request

URL = "https://api.spendpreflight.com/v1/ofac-address-check"


def screen(addresses):
    addresses = [a.strip() for a in addresses]
    if not 1 <= len(addresses) <= 10:
        raise ValueError("Select 1-10 addresses; do not split a large list to bypass limits")
    for a in addresses:
        if not re.fullmatch(r"[A-Za-z0-9]{14,256}", a) or (
            a.lower().startswith("0x") and not re.fullmatch(r"0x[0-9a-fA-F]{40}", a)
        ):
            raise ValueError("Invalid public address; no request sent")
    request = urllib.request.Request(
        URL, data=json.dumps({"addresses": addresses}).encode(), method="POST",
        headers={"Content-Type": "application/json", "Accept": "application/json"},
    )
    with urllib.request.urlopen(request, timeout=15) as response:
        if response.status != 200:
            raise ValueError("No determination: unexpected HTTP status")
        raw = response.read(131073)
    if len(raw) > 131072:
        raise ValueError("No determination: response too large")
    data = json.loads(raw)
    rows = data.get("results")
    if data.get("count") != len(addresses) or not isinstance(rows, list) or len(rows) != len(addresses):
        raise ValueError("No determination: incomplete response")
    for address, row in zip(addresses, rows):
        expected = address.lower() if address.startswith("0x") else address
        if (row.get("address") != expected or type(row.get("address_match")) is not bool
                or any(not isinstance(row.get(k), str) or not row[k]
                       for k in ["snapshot_built_at", "checked_at", "source", "scope", "disclaimer"])):
            raise ValueError("No determination: unexpected result; review the raw response")
    return rows


if __name__ == "__main__":
    try:
        results = screen(sys.argv[1:])
    except Exception:
        sys.exit("No determination. Check input, connectivity and HTTP status; do not treat errors as non-matches.")
    # Output starts only after every result has passed validation.
    writer = csv.DictWriter(sys.stdout, fieldnames=["address", "address_match", "snapshot_built_at",
                                                   "checked_at", "source", "scope", "disclaimer"],
                            extrasaction="ignore")
    writer.writeheader()
    writer.writerows(results)

Run it with one to ten addresses. This example produces CSV on standard output; redirecting it creates a local file under your control.

python screen_ofac.py 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 0x0000000000000000000000000000000000000000 > results.csv

A nonzero exit means no determination. Do not import an empty or partially written file as successful screening. This starter checks identifiers only; it does not iterate a large CSV, submit payments or implement a production compliance program.

Zapier: send raw JSON without flattening the array

For an existing eligible Zapier plan, add Webhooks by Zapier → Custom Request after your chosen trigger. Use these fields:

Inspect a test response before enabling downstream actions. Keep the returned row count, address order, timestamps and scope. Require an actual boolean flag; missing, null, non-200 or unavailable responses go to review. Preserve one result per input when mapping line items. Do not automatically approve, freeze, reject or transfer funds. Limit upstream batches to ten and avoid unbounded triggers or blind replay.

Zapier documents Custom Request's raw JSON behavior and plan availability. These instructions and the payload were checked against that documentation and offline request fixtures; we did not create or execute a live Zap.

Apify: an alternative for bounded larger lists

Open our Crypto Address Sanctions Screener. In its input editor, use the same addresses JSON, expanded to at most 100 items. Review the current USD event price, permissions and your budget before you choose to start a run. The Actor is paid; the free API examples above do not require it.

Inspect run status and every output row, including its source timestamp and unknown fields, before exporting JSON or CSV. A failed or timed-out run is not a non-match; a client timeout may leave a platform run active, so inspect that run instead of starting another blindly. Apify stores output under its own platform retention policy; input cleanup is not a promise that the platform retains nothing.

For programmatic integration, Apify's official API workflow describes starting a run, observing its status and reading its dataset. Use your own existing connection or a protected bearer header; never put a platform token in a public spreadsheet, shared Zap field, URL or source repository. This guide does not start an Actor or change its builds, prices or permissions.

Keep the review boundary visible

OFAC says listed digital-currency identifiers are not exhaustive, and describes its own address search as exact matching. See FAQ 562 and FAQ 594. For ownership and broader review limits, read our official-source screening guide.

The free API processes addresses against an existing SDN snapshot without a chain lookup, public report or product-specific query/result store. Your spreadsheet, CSV, Zap history and Apify output are separate copies under their own access and retention controls. Keep only the evidence your process needs. Privacy by product · Name and domain screening scope.

Examples checked October 29, 2026: Python and Sheets code against offline success/error fixtures; Zapier payload and instructions against official docs; Actor input against its existing source schema. No live Google authorization, Zap, Actor execution or payment was performed for this guide.