Batch Sanctions Screening API for CSV and XLSX Uploads
Upload a CSV or XLSX to POST /api/v1/batch with an X-API-Key header, poll the job, then export results as CSV. Up to 100 rows and 2 MB per file.
POST /api/v1/batch takes one multipart file, CSV or XLSX with a required name column, and queues every row as its own check. The ceilings are 100 rows and 2 MB per file, so ten thousand names is a hundred uploads; that is the honest shape of this endpoint. Upload with a key holding batch:write, poll with batch:read. The 202 response carries job_id, total_rows, credits_charged, search_type and status, and the credits are reserved for the whole file at that moment, before a single row has run. A worker then takes rows one at a time, which is why processed_rows, success_rows and error_rows move while you poll GET /api/v1/batch/{job_id}, and why elapsed time tracks the row count rather than anything clever. There is no completion webhook and no SDK, so the loop stays in your code until the status is terminal, and GET /api/v1/batch/{job_id}/export then streams the finished CSV. Each row is billed at the same price as a single call (€0.39 for sanctions_check, €0.99 for adverse_media, €5.90 for full_search) and comes back as result_json with the findings written out rather than as a score. Each row is also stored as its own snapshot, which is why an export of a year-old job still reproduces what that row returned at the time.
What this workflow covers
SCOPE- The upload is multipart/form-data with a single file field. CSV and XLSX pass; anything else returns 415, a file over 2 MB returns 413, and a file with no usable rows returns 400.
- Column schema: name is required, country is optional, search_type is optional and defaults to sanctions_check. Header names are lowercased and trimmed, so Name and NAME both work; a file without a name column returns 400 'Missing required name column'.
- search_type accepts sanctions_check, adverse_media and full_search, plus the input aliases aml_check (for sanctions_check) and the deprecated deep_research (for full_search). Every row in one file must resolve to the same type or the upload returns 400.
- Limits are 100 rows and 2 MB per file, echoed back on the job record in limits_applied. Rows are de-duplicated on lowercased name plus country before queueing, so total_rows can come back lower than your line count.
- The 202 body is job_id, total_rows, credits_charged, search_type, status and message. credits_charged is the unit price times the row count, reserved before the job runs, so read GET /api/v1/account/credits before you send a large file.
- GET /api/v1/batch/{job_id} returns id, status, total_rows, processed_rows, success_rows, error_rows, credits_charged, source_filename, search_type, created_at and completed_at. Job status is one of pending, running, completed, failed or canceled.
- GET /api/v1/batch/{job_id}/rows pages through rows with ordinal, name, country, status, result_json and error_message, filtered with status and paged with page and page_size (max 100). Row status is one of queued, processing, done, error or skipped.
- GET /api/v1/batch/{job_id}/export streams a CSV with the columns ordinal, name, country, search_type, status, error_message and result_json. errors_only=true narrows it to the failed rows. The endpoint returns 400 until job status is completed.
- result_json holds the screening payload for that name: entity_processed, the per-jurisdiction *_sanctions_details fields, criminal_watchlists_details, pep_details and final_report_content. No per-list request filter exists, so every row covers sanctions, PEP and criminal watchlists.
- There is no idempotency key. Re-uploading the same file creates a second job and reserves credits again.
- Rate limits apply to the batch endpoints as to every other v1 route: free 10/minute and 100/day, standard 60 and 1,000, premium 300 and 10,000, enterprise 1,000 and 100,000, with X-RateLimit-* headers on every response and Retry-After on a 429.
- Errors share the v1 envelope of error, message and status_code. Expect 401 for a missing or invalid key, 403 when the key lacks batch:write or batch:read, 404 for an unknown job_id, 400 for file and row problems, 413 for size, 415 for file type and 429 for rate limits.
Code samples
APIname,country,search_type
Test Person,PL,sanctions_check
Example Trading Company,DE,sanctions_check
Second Test Person,,sanctions_check
# name required. Blank names are skipped, not rejected.
# country optional. Used as a jurisdiction hint.
# search_type optional, defaults to sanctions_check.
# Accepted: sanctions_check | adverse_media | full_search
# Aliases: aml_check -> sanctions_check
# deep_research -> full_search (deprecated)
# All rows in one file must resolve to the SAME type.
# Duplicate (name, country) pairs are removed before queueing.# 1. Create the job (multipart upload)
curl -X POST https://screenveritai.com/api/v1/batch \
-H "X-API-Key: svai_your_key_here" \
-F "file=@entities.csv"
# HTTP/1.1 202 Accepted
# {
# "job_id": "c41f7b90-2ad8-4e35-9f61-77b0c9d2e401",
# "total_rows": 3,
# "credits_charged": 3,
# "search_type": "sanctions_check",
# "status": "pending",
# "message": "Batch job created successfully. Poll the GET endpoint for status."
# }
# 2. Poll the job
curl https://screenveritai.com/api/v1/batch/c41f7b90-2ad8-4e35-9f61-77b0c9d2e401 \
-H "X-API-Key: svai_your_key_here"
# 3. Inspect individual rows while it runs
curl "https://screenveritai.com/api/v1/batch/c41f7b90-2ad8-4e35-9f61-77b0c9d2e401/rows?status=error&page=1&page_size=20" \
-H "X-API-Key: svai_your_key_here"
# 4. Export once status is "completed" (400 before that)
curl -o results.csv \
"https://screenveritai.com/api/v1/batch/c41f7b90-2ad8-4e35-9f61-77b0c9d2e401/export" \
-H "X-API-Key: svai_your_key_here"{
"id": "c41f7b90-2ad8-4e35-9f61-77b0c9d2e401",
"status": "running",
"total_rows": 3,
"processed_rows": 2,
"success_rows": 2,
"error_rows": 0,
"credits_charged": 3,
"source_filename": "entities.csv",
"search_type": "sanctions_check",
"created_at": "2026-09-04T09:31:12.004000+00:00",
"completed_at": null
}
// GET /api/v1/batch/{job_id}/rows
{
"rows": [
{
"ordinal": 1,
"name": "Test Person",
"country": "PL",
"status": "done",
"result_json": {
"entity_processed": "Test Person",
"ofac_sanctions_details": "## OFAC Sanctions Findings\n\n**Status:** *No relevant listing found* ...",
"criminal_watchlists_details": "",
"pep_details": "",
"final_report_content": ""
},
"error_message": null
},
{
"ordinal": 3,
"name": "Second Test Person",
"country": null,
"status": "queued",
"result_json": null,
"error_message": null
}
],
"page": 1,
"page_size": 20
}import os
import time
import requests
BASE = "https://screenveritai.com/api/v1"
HEADERS = {"X-API-Key": os.environ["SCREENVERITAI_API_KEY"]}
# 1. Upload. Keep the local file: nothing on the server maps back to your
# own row references, and there is no idempotency key on re-upload.
with open("entities.csv", "rb") as handle:
created = requests.post(
f"{BASE}/batch",
headers=HEADERS,
files={"file": ("entities.csv", handle, "text/csv")},
timeout=60,
)
created.raise_for_status() # 400 rows/type, 413 size, 415 file type
job = created.json()
job_id = job["job_id"]
print(job["total_rows"], "rows queued,", job["credits_charged"], "credits reserved")
# 2. Poll. Terminal statuses: completed, failed, canceled.
while True:
status_response = requests.get(f"{BASE}/batch/{job_id}", headers=HEADERS, timeout=30)
status_response.raise_for_status()
state = status_response.json()
print(state["status"], state["processed_rows"], "/", state["total_rows"])
if state["status"] in {"completed", "failed", "canceled"}:
break
time.sleep(10)
if state["status"] != "completed":
raise RuntimeError(f"batch {job_id} ended as {state['status']}")
# 3. Export. errors_only=true returns just the rows with status "error".
export = requests.get(f"{BASE}/batch/{job_id}/export", headers=HEADERS, timeout=120)
export.raise_for_status()
with open(f"batch-{job_id}.csv", "wb") as out:
out.write(export.content)
if state["error_rows"]:
errors = requests.get(
f"{BASE}/batch/{job_id}/export",
headers=HEADERS,
params={"errors_only": "true"},
timeout=60,
)
errors.raise_for_status()
print("re-screen these rows:", errors.text.splitlines()[1:])Key statistics
DATA- Rows per batch upload
- 100 maximum
- ScreenVeritAI API v1 batch limits
- File size per batch upload
- 2 MB maximum, CSV or XLSX
- ScreenVeritAI API v1 batch limits
- Batch price per row
- Same as a single check — from €0.39
- ScreenVeritAI pricing
Compliance glossary
TERMS- job_id
- The UUID returned when a batch upload is accepted. It addresses the job at GET /api/v1/batch/{job_id}, its rows at /rows and the CSV at /export, and it names the exported file (batch-<job_id>.csv).
- Row status
- The per-name state inside a batch: queued before a worker claims it, processing while the check runs, done when a result was stored, error when the check failed, skipped when it was not run. Only error rows carry error_message.
- Disposition
- The decision recorded against a candidate match once a person has looked at it: confirmed, dismissed as a false positive, or escalated. A batch export carries findings and never dispositions; those are entered in the portal against the stored screening for each row.
- Point-in-time evidence
- Each batch row is stored as its own snapshot of the screening as it stood that day, with the sources checked and the findings returned. The snapshot is not recalculated when a list changes, so a later export of an old job still reproduces the original result.
Authoritative references
SOURCES- 01ScreenVeritAI API v1 — interactive documentation
ScreenVeritAI
- 02ScreenVeritAI API v1 — OpenAPI schema
ScreenVeritAI
- 03Specially Designated Nationals and Blocked Persons List (SDN)
U.S. Department of the Treasury, OFAC
- 04EU Consolidated Financial Sanctions List
European Commission
Frequently asked questions
Q&A- Can I dry-run a file to see what the parser makes of it?
- No. There is no sandbox and no dry-run flag, and credits for the whole file are reserved the moment the job is created. Send a three-row CSV first. €1.17 buys you the column parsing, the de-duplication and the export shape, and it is the cheapest possible way to discover that your header row says Full Name.
- The job has been running for twenty minutes. Is it stuck?
- Read processed_rows against total_rows before you decide. The API returns no estimate for a batch, a worker takes rows one at a time, and a full_search file spends longer per row than a sanctions_check one, so a long run is usually just a long run. Poll every ten seconds; this is not a foreground operation and a tighter loop tells you nothing new.
- We only report OFAC. Can the CSV say so?
- The search_type column picks the product, not the list, and there is no per-list filter anywhere in the API. Every row is screened against sanctions, PEP and criminal watchlists whatever the file says, and you pull the domain you report on out of that row's result_json. It is also why narrowing the scope would not make the row any cheaper.
- Should I normalise names before uploading, or let full_search do it?
- Depends on the file. sanctions_check matches what you submitted, so if you stay on it, clean the source. Setting search_type to full_search in the CSV buys transliteration, alias matching, query expansion and an explainable review of every candidate at €5.90 a row. For a supplier register of Latin-script company names, sanctions_check plus a tidy-up on your side is the better trade; for a list of transliterated personal names it is not.
- Is there a callback when the job finishes, or do I sit in a loop?
- You sit in a loop. There is no completion webhook, no callback and no SDK to hide the loop inside. Poll GET /api/v1/batch/{job_id} until the status is completed, failed or canceled, then call /export. If your runtime dislikes long-lived loops, store the job_id and let a scheduled task pick it up later; the job runs server-side and does not care whether your process is still watching.
- I uploaded the same file twice by accident. What now?
- You have two jobs and two reservations. There is no idempotency key and nothing deduplicates across uploads, only within a single file. Keep your own reference column in the source and reconcile on ordinal plus name after the export, because ordinal is the only thing tying an exported row back to the line you sent.
- Is there a discount for sending a hundred rows at once?
- No. A batch row costs what a single call costs: €0.39 for sanctions_check, €0.99 for adverse_media, €5.90 for full_search. There is no batch surcharge and no platform fee in either direction, and credits_charged on the create response is simply the unit price times the row count, so you can reconcile an invoice straight from that number.