Batch screening · GuideLast reviewed 2026-09-04
    Sanctions News Desk

    A 400-row customer book, four uploads, and the twelve rows that are neither clean nor listed

    Nobody publishes the boring part of batch screening: what the file has to look like, what comes back, and what you do with the twelve rows that are neither clean nor listed. So here is a 400-row book run end to end, with a sample CSV you can download, upload and break, and the arithmetic behind the review queue it produces. The mechanics are the same whether you are screening a customer book for AML or sweeping a vendor master for denied parties; the caveats at the end are not.

    Published By ScreenVeritAI Team
    AT A GLANCE
    Rows per upload
    100
    A 400-row book is four uploads. Above the cap the API answers HTTP 400 "Row limit exceeded (100 max)".
    File
    CSV or XLSX, max 2 MB
    UTF-8 or CP1252 for CSV; the first worksheet for XLSX; anything else returns HTTP 415.
    Columns actually read
    name, country, search_type
    name is required; country and search_type are optional. Every other column in the file is ignored — it is not screened and not stored.
    Cost of a 400-row sanctions run
    €156.00
    400 checks at €0.39. Batch bills at the same per-check rate as a single check, and credits are deducted at upload, not on completion.
    Duplicate rows
    Removed before billing
    Rows are deduplicated on (name, country), case-insensitively, before the job is created — so you are not charged twice for the same pair.

    Short answer

    BATCH

    Export your book to a CSV with a name column — optionally country and search_type — and upload it, in the portal or with one POST /api/v1/batch. A file holds at most 100 rows and 2 MB, so 400 customers is four uploads and four job ids. Each job reports total_rows, processed_rows, success_rows and error_rows while it runs; when status is completed you read the results from /rows or download them from /export. Billing happens at upload — 400 rows at sanctions_check is 400 × €0.39 = €156.00 — and duplicate (name, country) pairs are removed before you are charged. Then the actual work starts: triaging the handful of rows that came back with a candidate.

    Sample file to try it with: sample-batch-screening.csv — 36 rows of deliberately fictitious names, including a duplicate pair, an initial-only name and three transliterations of the same invented person.

    What the parser reads

    SCHEMA

    The parser reads exactly three columns, matched case-insensitively on the header row and trimmed:

    ColumnRequiredBehaviour
    nameyesThe string that is screened. Rows with an empty name are skipped silently.
    countrynoStored, returned, and part of the deduplication key. Does not narrow the match.
    search_typenosanctions_check (default), adverse_media or full_search. One value per file — a mixed file is rejected with HTTP 400.

    Everything else in the file is ignored. The sample CSV deliberately carries two extra columns, customer_id and dob, because a real export always does:

    name,country,search_type,customer_id,dob
    Example Person 01,NL,sanctions_check,CUST-1001,1979-03-14
    A. Example,PL,sanctions_check,CUST-1003,
    Aleksandr Examplov,LV,sanctions_check,CUST-1004,1968-07-21
    Example Trading Ltd,GB,sanctions_check,SUP-2002,
    Example Trading Ltd,,sanctions_check,SUP-2003,
    "Example Components Co., Ltd",CN,sanctions_check,SUP-2016,
    

    Those two columns are not screened and not stored. Keep them anyway — in your copy of the file. When a candidate comes back on row 3, customer_id is how you find the customer, and dob is the first question you ask to close or confirm the match. A date of birth in the file does not sharpen the search; it sharpens the review, which is where the time actually goes.

    Three parser behaviours to know before you send 400 rows up. Rows are collapsed on (name, country), lower-cased, so in the sample Example Trading Ltd, GB appears twice, 36 rows become 35, and you are billed for 35. But Example Trading Ltd with no country survives as a separate subject: a blank country is a distinct key, not a wildcard.

    The ordinal is your join key. Each row keeps the position it held in your file, header excluded, and after deduplication the numbering has gaps — the sample produces ordinals 1–36 with 20 missing, and that missing number is the duplicate. Do not renumber. Join your source file on ordinal.

    Quoting is generous, encoding is not. "Example Components Co., Ltd" parses correctly, but the file itself must be UTF-8 or CP1252, and a UTF-16 export from a spreadsheet returns HTTP 400 "Unsupported file encoding".

    Splitting 400 rows

    LIMITS
    awk 'NR==1 {hdr=$0; next}
         (NR-2)%100==0 {file=sprintf("upload-part-%02d.csv", int((NR-2)/100)+1); print hdr > file}
         {print > file}' customers.csv
    

    Four files, each carrying the header — a headerless chunk is rejected with "Missing required 'name' column". Upload them in order: work is claimed oldest job first and then in ordinal order, so file 1 drains before file 2 begins. That is a property to plan around, not to fight — put the highest-risk segment in file 1.

    The upload

    API

    Portal: Batch screening → File upload, drop the CSV or XLSX, and the same limits apply. By API:

    curl -X POST https://screenveritai.com/api/v1/batch \
      -H "X-API-Key: svai_2f9c1b7d4a6e8035c1d29f4b7e60a83d" \
      -F "file=@upload-part-01.csv"
    
    {
      "job_id": "b41d90c7-25ea-4f6b-9a0c-1e77d3f8b512",
      "total_rows": 100,
      "credits_charged": 100,
      "search_type": "sanctions_check",
      "status": "pending",
      "message": "Batch job created successfully. Poll the GET endpoint for status."
    }
    

    total_rows is the count after deduplication — check it against your expectation before you upload the next file. If it is lower than your row count, your export contains repeats.

    Watching the job

    STATUS
    curl https://screenveritai.com/api/v1/batch/b41d90c7-25ea-4f6b-9a0c-1e77d3f8b512 \
      -H "X-API-Key: svai_…"
    
    {
      "id": "b41d90c7-25ea-4f6b-9a0c-1e77d3f8b512",
      "status": "running",
      "total_rows": 100,
      "processed_rows": 63,
      "success_rows": 61,
      "error_rows": 2,
      "credits_charged": 100,
      "source_filename": "upload-part-01.csv",
      "search_type": "sanctions_check",
      "created_at": "2026-09-04T09:31:02.774318+00:00",
      "completed_at": null
    }
    

    A job goes pendingrunningcompleted; it becomes completed when processed_rows reaches total_rows, whatever mix of successes and errors that includes. Individual rows go queuedprocessingdone or error. error_rows are not screened rows — they are rows that failed, and they need re-running, not filing. Pull them with /export?errors_only=true.

    The results

    ROWS
    curl "https://screenveritai.com/api/v1/batch/b41d90c7-…/rows?page=1&page_size=100" \
      -H "X-API-Key: svai_…"
    

    Each row carries its result_json — the same payload a single screening produces:

    {
      "ordinal": 18,
      "name": "Example Trading Ltd",
      "country": "GB",
      "status": "done",
      "result_json": {
        "entity_processed": "Example Trading Ltd",
        "ofac_sanctions_details": "## OFAC Sanctions Findings\n\n**Status:** *Sanctioned*\n\n**Details:**\n- **Matched entity:** EXAMPLE TRADING LTD\n- **Identification:** 51234; EXAMPLE TRADING LIMITED\n- **Measures:** SDGT\n- **Dates:** 2026-08-26\n- **Authority:** Office of Foreign Assets Control\n- **Evidence URL:** https://sanctionssearch.ofac.treas.gov/Details.aspx?id=99999\n\n**Source:** *Office of Foreign Assets Control*",
        "eu_sanctions_details": "## EU Sanctions Findings\n\n**Status:** *No relevant listing found*\n\n…",
        "criminal_watchlists_details": "",
        "pep_details": "",
        "sanctions_sources": [
          { "source_id": "ofac_sanctions", "title": "OFAC Sanctions", "authority": "Office of Foreign Assets Control", "details": "…", "hit_count": 1 }
        ],
        "screening_v2_sanctions_hit_count": 1,
        "screening_v2_criminal_watchlists_hit_count": 0,
        "screening_v2_pep_hit_count": 0
      },
      "error_message": null
    }
    

    Two fields do the triage work. sanctions_sources is an array with one object per screened source and a hit_count on each — sum it and you have a sortable number for the whole run. The three …_hit_count keys are the same signal per domain. Everything else is text for the human who reviews the row: the matched spelling, the register's own reference, the programme, the listing date, the authority and its URL.

    GET /{job_id}/export returns the whole job as a CSV — ordinal, name, country, search_type, status, error_message, result_json — once the job is completed; before that it answers HTTP 400. That file is the one you attach to the run; it packs each row's payload into a single cell, so parse from /rows and archive from /export.

    Triage rubric

    REVIEW

    Write this down before the batch runs, because a rule invented after seeing the results is not a control.

    ResultActionOwnerClock
    Any sanctions hit_count > 0Stop the relationship or shipment, escalateMLRO / trade-compliance leadSame day
    Potential watchlist matchSenior review before releaseSenior analyst1 business day
    Potential PEP matchStandard review; EDD and source-of-funds if confirmedAnalyst2 business days
    All sources No relevant listing foundAuto-clear: file the result as evidenceNone
    Row status: errorRe-run the row; never file a failure as a passOpsSame day

    Four rows from the sample file, worked:

    Ordinal 18 — Example Trading Ltd, GB. hit_count: 1, status Sanctioned, programme SDGT, listing date 2026-08-26, authority OFAC, evidence URL present. This is not a review, it is an escalation: freeze, quote the register reference 51234 in the file, and route to counsel. Note also ordinal 19 — the same name with a blank country, screened separately. Both rows need a disposition; only one is your customer.

    Ordinal 3 — A. Example, PL. No sanctions candidate; pep_details returns Potential PEP match against a public officials register, identification "Andrzej Example; 1971; pl". An initial plus a common surname is the single most productive false-positive pattern there is. Ask three cheap questions — does the customer's recorded date of birth contradict 1971, does the role fit, does the register publish an identifier your file also holds — and close it as a false positive with that sentence written down.

    Ordinals 4, 5 and 6 — Aleksandr Examplov, Aleksander Eksamplow, Alexander Ekzamplov. Three spellings of one invented person, in three countries. They are three subjects to the deduplicator and may return three different result sets. The lesson is operational: screen the spellings you actually hold — the passport spelling, the contract spelling, the bank-transfer spelling — rather than picking the one that looks canonical. The same applies to ordinals 10 and 11, Mohammed Al-Example and Muhammad Al Example: two rows, two charges, possibly one human.

    Ordinal 15 — Example Person 15, FI. Every source returns No relevant listing found. Auto-clear, and file the result. Record it as "no relevant listing found in the checked sources on 4 September 2026", never as "clear" — the run can only speak about the sources it screened on the day it ran.

    How big the queue will be

    EXPECTATIONS

    There is no honest industry number for this, and any vendor quoting a false-positive rate for your book is quoting a different book. The shape, though, is knowable.

    Sanctions candidates are rare on an ordinary EU retail or SME customer list. The consolidated registers hold tens of thousands of entries against a world of counterparties, so a first run that returns zero sanctions candidates is a normal result rather than a broken configuration. What fills the queue is PEP candidates: the public PEP universe is orders of magnitude larger than the sanctions universe, and family members and close associates widen it further. The rest is name shape — initial-only names, two-token common names, transliterated names, and generic corporate strings like "Example Trading Ltd", which exists in a dozen jurisdictions.

    So do the staffing arithmetic on your own first run, not on a benchmark: if 3 percent of 400 rows raise a candidate, that is 12 reviews; at 15 percent it is 60. Measure it once, and you can budget every subsequent run.

    What the auditor will ask for

    EVIDENCE

    Not the screenshot. Per run, keep five things:

    1. The input file, unmodified, with the columns you exported and its date.
    2. The job ids and the total_rows / success_rows / error_rows counts, which prove the population screened was the population intended.
    3. The exported results, stored as the point-in-time record — never regenerated later, because a re-run answers today's question, not the one you asked at onboarding.
    4. A disposition per candidate: true match, false positive or escalate, with the analyst, the timestamp and the sentence that explains the decision.
    5. The rubric itself, dated, showing the rule existed before the results did.

    EU record-keeping obligations under the AML framework — Regulation (EU) 2024/1624 and the directives before it — run to five years from the end of the business relationship as a baseline, longer in several member states, so whatever you keep has to survive a system migration and an analyst's departure. A folder of PDFs with an index beats a screenshot in a ticket.

    Re-running after a list moves

    CADENCE

    Re-upload the same file and keep both jobs. The diff between the two runs — rows that raised a candidate this time and did not last time — is the exact effect the list movement had on your book, and it is a far smaller review queue than the first run produced. That diff, dated, is also the cleanest answer to "when did you learn this?".

    Cadence differs by why you are here. An AML or KYC customer book is re-screened on list change plus a periodic sweep tied to risk rating. A trade-compliance denied-party sweep is re-run against the transaction, not the calendar: before order release, before shipment, and again if the consignee, freight forwarder or end user changes.

    AML book or vendor master

    SCOPE

    The mechanics above are identical for both jobs; the file and the caveats are not.

    AML / KYC customer bookTrade-compliance denied-party sweep
    Source of the fileCRM or core banking exportERP vendor and customer master, shipment parties
    Typical nameNatural personsLegal entities, plus consignees and forwarders
    Dominant candidate typePEPSanctions and restricted-party registers
    The thing name screening cannot seeOwnership: the 50 percent rule blocks subsidiaries that appear on no listVessels and aircraft identified by IMO or tail number, and end-use controls
    Re-screen triggerList change, risk-rating reviewOrder release, shipment, party change

    Both caveats are the same admission in different words: a name check answers a name question. Ownership structures, vessel identifiers and end-use controls sit outside what any batch of strings can resolve, and a screening product that implies otherwise is selling you a false negative. That is why the whole source register sits on ScreenVeritAI's coverage page — so you can see the edge of what was screened before you decide to lean on it.

    A batch run is documentation of a check, not a decision about a counterparty. The disposition stays with your reviewers.

    Frequently asked questions

    Q&A

    How many rows can one batch screening file contain?

    One hundred, and the file must be under 2 MB. A 400-row customer book therefore goes up as four uploads of 100 rows, each returning its own job id. Above the cap the API answers HTTP 400 with "Row limit exceeded (100 max)" and nothing is charged.

    What columns does the batch CSV need?

    Three are read: name (required), country (optional) and search_type (optional, defaulting to sanctions_check). Header names are matched case-insensitively and trimmed. Every other column — customer id, date of birth, account number — is ignored by the parser: it is not screened and not stored, so keep it in your own copy of the file for reconciliation.

    Does filling in the country improve the match?

    Not the matching itself. The list screening runs on the name. What country does is participate in the deduplication key and travel with the row into the results and the export, so "Example Trading Ltd, GB" and "Example Trading Ltd" with no country are screened as two subjects rather than collapsed into one. Fill it in for reconciliation and for your own reviewers, not for recall.

    How much does a 400-row batch screening run cost?

    400 checks at €0.39 is €156.00 with search_type sanctions_check. Batch bills at the same per-check rate as single checks, credits are deducted when the job is created rather than when it finishes, and duplicate (name, country) pairs are removed before the count is taken. Choosing full_search instead changes the arithmetic by more than an order of magnitude, so set search_type deliberately.

    How many hits should I expect from 400 rows?

    Nobody can tell you honestly, and a vendor-quoted false-positive rate is not evidence about your book. The drivers are knowable: sanctions registers are small relative to a customer list, so genuine sanctions candidates on an ordinary EU retail book are rare; PEP sources are far larger, so most of your queue will be potential PEP matches; and short, common or initial-only names generate most of the noise. Measure your own first run and use that as the baseline for staffing.

    How do I export batch results?

    GET /api/v1/batch/{job_id}/export returns a CSV with the columns ordinal, name, country, search_type, status, error_message and result_json. It returns HTTP 400 while the job is still running, and the result_json column packs the whole payload of a row into a single cell — good as a human artefact, awkward to parse, so use the /rows endpoint when a machine is reading.

    Do I have to re-run the whole batch after a list update?

    Yes, and that is the point: results are point-in-time snapshots and are never recalculated when a list changes, so the only way to know today's answer is to ask today's question. Re-upload the same file, keep both jobs, and diff the candidate rows between them. The rows that appear in the new run and not the old one are exactly what the list movement did to your book.

    Sources

    SOURCES

    Informational analysis of published regulatory sources. Not legal advice. Verify the primary sources before acting.