Single-record lookups are fine during live onboarding. But most platforms hit a point where they need to verify dozens or hundreds of contractors at once - during an initial data migration, for quarterly compliance audits, or when migrating from one data source to another. That is where the batch verification endpoint earns its keep.
This guide covers the full lifecycle of a batch verification job: when to use it, how to structure the request, how to handle the output (including partial failures), and how to build a recurring re-verification schedule on top of it.
When You Actually Need Batch Verification
Batch is not always the right tool. There are three distinct scenarios where it makes sense:
Initial Data Migration
You are onboarding a new platform or migrating an existing contractor database to start using license verification. You have a CSV or database table of contractors who were never checked, or who were checked years ago with an old system. You need to verify all of them before your new compliance logic goes live. This is typically a one-time job ranging from a few hundred to tens of thousands of records.
Periodic Re-Verification
Licenses expire. Status changes. A contractor who was Active and fully bonded in January might be Suspended by July. Platforms with any compliance obligation need to re-verify their contractor pool on a regular schedule - monthly for high-risk verticals (roofing, electrical, HVAC), quarterly for lower-risk categories. Batch is ideal here because you are running a sweep across your entire active contractor roster.
Compliance Audits
Insurance carriers, enterprise clients, and some municipal contracts require proof of verified contractor licensing before extending coverage or awarding work. Auditors want a current snapshot: as-of-today status for every contractor your platform has placed in the last 12 months. Batch lets you generate that snapshot on demand in minutes rather than weeks.
Single Lookups vs. Batch Operations: The Real Difference
The single-record GET /v1/verify endpoint is synchronous and returns a result immediately. It works well for interactive flows - a contractor submits their license number during signup, your server calls the API, and you show a pass/fail within the same request cycle.
Batch is different in important ways. You POST a list of records and get back results for all of them in a single response. At small sizes (under 20 records) this is nearly as fast as individual calls and simpler to manage. At larger sizes, you are trading off a few things: higher per-call processing time, more surface area for partial failures, and for very large batches, the option to go async with a webhook callback instead of waiting for the synchronous response.
The practical threshold: use single lookups for real-time user-facing flows, use batch for anything running as a background job or where you have more than ~10 records to process at once.
The POST /verify/batch Endpoint
Request Format
The batch endpoint accepts a POST request with a JSON body. The core structure is an array of verification request objects:
POST https://api.contractorverify.io/v1/verify/batch
Content-Type: application/json
Authorization: Bearer YOUR_API_KEY
{
"requests": [
{
"id": "contractor_001",
"license_number": "123456",
"state": "CA"
},
{
"id": "contractor_002",
"license_number": "789012",
"state": "TX"
},
{
"id": "contractor_003",
"name": "Pacific Northwest Roofing LLC",
"state": "WA"
}
]
}
Key fields:
- id - Your internal identifier for the contractor. This is returned in the response so you can map results back to your records without relying on license number alone. Use your primary key here.
- license_number or name - Provide license number when available. Name lookups work but can return multiple candidates for common business names.
- state - Two-letter state code. Required.
Maximum batch size is 100 records per call. For lists larger than 100, you chunk and make multiple calls.
Response Format
{
"batch_id": "batch_20260322_a4f9c",
"processed": 3,
"succeeded": 2,
"failed": 1,
"results": [
{
"id": "contractor_001",
"status": "success",
"license_number": "123456",
"state": "CA",
"license_status": "Active",
"expiration_date": "2027-02-28",
"classifications": ["C-10", "C-7"],
"disciplinary_actions": []
},
{
"id": "contractor_002",
"status": "success",
"license_number": "789012",
"state": "TX",
"license_status": "Expired",
"expiration_date": "2025-11-30",
"classifications": ["A"],
"disciplinary_actions": []
},
{
"id": "contractor_003",
"status": "error",
"error_code": "MULTIPLE_CANDIDATES",
"error_message": "3 licenses found for this name in WA. Provide license_number.",
"candidates": [
{ "license_number": "555001", "business_name": "Pacific Northwest Roofing LLC" },
{ "license_number": "555442", "business_name": "Pacific Northwest Roofing LLC (2)" },
{ "license_number": "556109", "business_name": "Pacific Northwest Roofing LLC" }
]
}
]
}
Chunking Large Lists
If you have 1,000 contractors to verify, you make 10 batch calls of 100 records each. The chunking logic is straightforward but a few implementation details matter:
Rate Limiting
The API enforces a rate limit of 10 batch calls per minute on the standard plan. With 100 records per batch and 10 calls per minute, you can process 1,000 records per minute. A list of 5,000 contractors finishes in about 5 minutes. For larger migrations (50,000+ records), use the async webhook option described below - synchronous polling at that scale is fragile.
Chunk-Level Retry vs. Record-Level Retry
When a batch call fails at the HTTP level (network error, 500, rate limit), retry the entire chunk with exponential backoff. When the call succeeds but individual records have status: "error", those are record-level failures that need different handling - retrying them will likely produce the same error. Log them separately for manual review.
status: "error" items means those specific records had lookup failures - do not re-submit the whole chunk, only the failed IDs.
Handling Partial Failures
In any reasonably sized batch, some records will fail. There are several distinct failure modes and each needs different handling:
NOT_FOUND
The license number does not exist in the state board database. This can mean the contractor gave you a wrong number, they are not actually licensed, or there is a data entry error. Flag for manual review - do not auto-approve or auto-reject. Contact the contractor for clarification before making a compliance decision.
MULTIPLE_CANDIDATES
A name-based lookup returned more than one match. The response includes a candidates array. Route this back through your onboarding UI to ask the contractor to confirm their license number. Once confirmed, store the license number and re-submit as a license_number lookup.
STATE_TIMEOUT
The state board website was unresponsive during the lookup window. This happens occasionally with boards that run older infrastructure (Louisiana, Mississippi, and a few others have notoriously slow board sites). The API will retry internally before surfacing this error. When you see it, queue the record for a retry pass 4-6 hours later. If it still times out after three attempts, log it and flag it for manual verification - do not leave it in an unresolved state indefinitely.
INVALID_STATE_FORMAT
The license number format does not match the expected format for the state. California CSLB numbers are 6-8 digits. Texas license numbers are different. If you pass a California license number for a Texas lookup, you will get this error. Check your data quality - this usually points to a mismatch in how you are storing state vs. license number in your database.
Deduplication Before Batching
Before submitting a large batch, deduplicate your input list. Contractors who operate multiple business entities sometimes appear multiple times in your database under different company names but the same license number. Sending duplicate license_number + state combinations wastes API quota and inflates your monthly lookup count.
Simple deduplication: group by (state, license_number) and keep one record per unique pair. For name-based lookups where you do not have license numbers yet, deduplication is harder - do your best by normalizing the name (lowercase, strip punctuation, strip legal suffixes like LLC/Inc/Corp) and deduplicating on the normalized form.
The Async Webhook Option for Large Batches
For batches over 500 records, or when you are running a large migration and do not want to hold an HTTP connection open while thousands of lookups process, use the async webhook option. Add a webhook_url field to your batch request:
POST https://api.contractorverify.io/v1/verify/batch
Content-Type: application/json
Authorization: Bearer YOUR_API_KEY
{
"webhook_url": "https://yourplatform.com/webhooks/cv-results",
"requests": [ ... up to 1000 records ... ]
}
The API responds immediately with a batch_id and a status: "processing" confirmation. When the batch finishes (typically within 2-10 minutes depending on size and state board responsiveness), it POSTs the full results object to your webhook URL.
Your webhook handler should respond with HTTP 200 within 10 seconds - if it times out, the API will retry the webhook delivery up to 3 times with exponential backoff. Make your handler idempotent using the batch_id to avoid processing duplicates if retries occur.
Python Script: CSV to Batch Results
Here is a complete script that reads a CSV of contractors, deduplicates, batches them into 100-record chunks, and writes the results to an output CSV:
import csv
import json
import time
import requests
from typing import List, Dict
API_KEY = "YOUR_API_KEY"
BATCH_URL = "https://api.contractorverify.io/v1/verify/batch"
BATCH_SIZE = 100
RATE_LIMIT_DELAY = 6.5 # seconds between chunks (10 calls/min = 6s min)
def load_contractors(csv_path: str) -> List[Dict]:
seen = set()
contractors = []
with open(csv_path, newline='') as f:
reader = csv.DictReader(f)
for row in reader:
key = (row['state'].upper(), row['license_number'].strip())
if key in seen:
continue
seen.add(key)
contractors.append({
"id": row['contractor_id'],
"license_number": row['license_number'].strip(),
"state": row['state'].upper().strip()
})
return contractors
def chunk(lst, size):
for i in range(0, len(lst), size):
yield lst[i:i + size]
def submit_batch(records: List[Dict], attempt: int = 0) -> Dict:
try:
resp = requests.post(
BATCH_URL,
json={"requests": records},
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"
},
timeout=60
)
if resp.status_code == 429 and attempt < 4:
wait = (2 ** attempt) * 15
print(f"Rate limited. Waiting {wait}s before retry...")
time.sleep(wait)
return submit_batch(records, attempt + 1)
resp.raise_for_status()
return resp.json()
except requests.RequestException as e:
if attempt < 3:
time.sleep((2 ** attempt) * 5)
return submit_batch(records, attempt + 1)
raise
def run_verification(input_csv: str, output_csv: str):
contractors = load_contractors(input_csv)
print(f"Loaded {len(contractors)} unique contractors")
all_results = []
chunks = list(chunk(contractors, BATCH_SIZE))
for i, batch_chunk in enumerate(chunks):
print(f"Processing chunk {i+1}/{len(chunks)} ({len(batch_chunk)} records)")
result = submit_batch(batch_chunk)
all_results.extend(result["results"])
if i < len(chunks) - 1:
time.sleep(RATE_LIMIT_DELAY)
# Write output
with open(output_csv, 'w', newline='') as f:
fieldnames = [
'id', 'status', 'license_number', 'state',
'license_status', 'expiration_date',
'classifications', 'error_code', 'error_message'
]
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction='ignore')
writer.writeheader()
for row in all_results:
if 'classifications' in row:
row['classifications'] = '|'.join(row['classifications'])
writer.writerow(row)
failed = [r for r in all_results if r['status'] == 'error']
print(f"Done. {len(all_results) - len(failed)} succeeded, {len(failed)} failed.")
print(f"Results written to {output_csv}")
run_verification("contractors.csv", "verification_results.csv")
Reconciling Batch Results Back to Your Database
The id field you pass in the batch request is returned verbatim in every result. Use your primary key as the id so you can do a direct database upsert after the batch completes. The update pattern for each successful result:
- Set
license_statusto the returned value - Set
license_expiration_dateto the returnedexpiration_date - Set
license_verified_atto the current timestamp - Set
license_classificationsto the returned array (serialize as JSON or use a junction table depending on your schema) - Set
has_disciplinary_actionsto true if thedisciplinary_actionsarray is non-empty
For failed records (status: "error"), update only license_last_attempt_at and license_error_code. Do not overwrite previously known-good data with a null result from a transient timeout.
Scheduling Re-Verification: Nightly Crons and Quarterly Audits
A verification is a snapshot in time. Its value degrades as the license approaches expiration or as the contractor's status could theoretically change. A good re-verification schedule is risk-stratified:
- Expiration within 30 days: re-verify weekly
- Expiration within 90 days: re-verify every 2 weeks
- Expiration more than 90 days out: re-verify monthly
- Status is Inactive or Suspended: re-verify weekly regardless of expiration
- No verification on record: verify immediately (should not happen in steady state)
Implement this as a nightly cron that queries your database for contractors whose next re-verification date has passed, builds a batch request from that list, and runs it. See our post on automating license expiration monitoring for the full cron and alert architecture.
Cost at Scale: What Batch Verification Actually Costs
Understanding your lookup volume before choosing a plan avoids unexpected overages. Here are the real-world numbers for typical platform sizes:
A platform with 500 active contractors running monthly re-verification uses 500 lookups per month - that comfortably fits the free tier (1,000 lookups/month) with room for onboarding new contractors during the same billing period.
A platform with 2,000 active contractors on monthly re-verification uses 2,000 lookups/month for sweeps, plus new contractor onboarding. At the $49/month plan (10,000 lookups), this is handled easily with budget for ad-hoc checks.
A platform with 10,000 active contractors quarterly re-verifying (most common pattern for larger marketplaces) uses ~3,333 lookups per month for scheduled sweeps. Add onboarding volume and you are looking at the $149/month plan territory. The quarterly schedule instead of monthly is the key lever - for large portfolios, quarterly plus expiration-driven re-checks is often the right balance of coverage and cost.
Integrating Batch Results with Your Onboarding Flow
Batch verification is typically a background operation, but the results need to feed back into your contractor status system. After each batch run, automatically flag any contractor whose license_status changed from Active to anything else. These contractors need immediate attention - they were compliant when last checked but are no longer.
For a detailed look at how license checks fit into the full onboarding flow, see our guide on integrating license verification into contractor onboarding. The onboarding article covers synchronous single-record checks - batch verification is the complement that keeps those records current after onboarding completes.
Summary
Batch verification solves the fundamental scale problem of contractor license compliance. The POST /verify/batch endpoint handles up to 100 records per call with structured error handling per record, a webhook option for large async jobs, and consistent response shapes that make database reconciliation straightforward.
The operational pattern: deduplicate your input, chunk to 100, submit with exponential backoff on 429s, reconcile results by your internal ID, and schedule re-verification based on expiration proximity. Get that loop running and your compliance data stays fresh automatically - no manual CSLB searches required.