The Bulk Email Validation API lets you validate thousands of email addresses in a single operation. Upload a CSV file, and we'll process it in the background, notifying you via webhook when complete.
Overview
Bulk validation is designed for:
- Cleaning email lists - Validate your entire database in one go
- Importing contacts - Check emails before adding them to your CRM
- Periodic maintenance - Re-validate lists quarterly to catch stale addresses
- Large-scale operations - Process up to 50MB of emails per file
The Workflow
Bulk validation follows a simple upload process:
1. Create → 2. Upload → Process → Download
Step 1: Create a Validation Job
Create a job and receive a presigned upload URL:
curl -X POST "https://tinyvalidator.com/api/v1/bulk-validations" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"filename": "emails.csv",
"has_header": true,
"email_column": "email",
"webhook_url": "https://your-app.com/webhooks/bulk"
}'
Step 2: Upload Your CSV
Upload your file directly to the presigned URL (valid for 15 minutes):
curl -X PUT "https://upload-url-from-step-1" \
-H "Content-Type: text/csv" \
--upload-file emails.csv
Once the upload completes, TinyValidator automatically queues the validation job for processing.
Download Results
Once complete, download the results:
curl "https://tinyvalidator.com/api/v1/files/{output_file_id}" \
-H "Authorization: Bearer YOUR_API_KEY"
CSV Format
Your CSV file should contain one email per row. Use column headers for clarity:
email,name,company
[email protected],John Doe,Acme Inc
[email protected],Jane Smith,Widgets LLC
[email protected],Test User,Test Corp
Requirements
- File size: Maximum 50MB
- Format: CSV with optional header row
- Delimiter: Comma (
,) by default - Encoding: UTF-8 recommended
- Columns: At least one email column
API Reference
Create Validation Job
Create a new bulk validation and get an upload URL.
POST /api/v1/bulk-validations
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
filename | string | Yes | Original filename (for reference) |
has_header | boolean | No | CSV has header row (default: true) |
email_column | string | null | No | Column name containing emails (default: first column) |
delimiter | string | null | No | CSV delimiter (default: ,) |
webhook_url | string | null | No | URL to receive completion webhook |
webhook_secret | string | null | No | Secret for webhook signature verification |
Response (201 Created)
{
"id": "bv_abc123xyz",
"status": "uploading",
"status_reason": "awaiting_upload",
"file": {
"type": "input",
"id": "file_abc123xyz",
"name": "emails.csv",
"mime_type": "text/csv",
"href": "https://tinyvalidator.com/api/v1/files/file_abc123xyz",
"upload_url": "https://storage.tinyvalidator.com/uploads/bv_abc123xyz/input.csv?...",
"expires_at": "2026-03-13T12:30:00.000Z"
}
}
Get Validation Status
Check the current status and progress of a validation job.
GET /api/v1/bulk-validations/{id}
Response (200 OK)
{
"id": "bv_abc123xyz",
"status": "processing",
"status_reason": "queued",
"filename": "emails.csv",
"total_count": 1000,
"processed_count": 450,
"valid_count": 380,
"invalid_count": 50,
"risky_count": 20,
"error_count": 0,
"progress": 45,
"error": null,
"files": [
{
"type": "input",
"id": "file_abc123xyz",
"name": "emails.csv",
"mime_type": "text/csv",
"href": "https://tinyvalidator.com/api/v1/files/file_abc123xyz"
}
],
"created_at": "2026-03-13T12:15:00.000Z",
"started_at": "2026-03-13T12:15:05.000Z",
"completed_at": null,
"expires_at": null
}
List Validations
Get all bulk validations for your organization.
GET /api/v1/bulk-validations?limit=10&status=completed
Query Parameters
| Parameter | Type | Description |
|---|---|---|
limit | number | Max results to return (1-100, default: 10) |
status | string | Filter by status: uploading, pending, processing, paused, completed, failed, cancelled, expired |
Response (200 OK)
{
"data": [
{
"id": "bv_abc123xyz",
"status": "completed",
"status_reason": "queued",
"filename": "emails.csv",
"total_count": 1000,
"processed_count": 1000,
"valid_count": 850,
"invalid_count": 100,
"risky_count": 50,
"error_count": 0,
"progress": 100,
"error": null,
"files": [
{
"type": "input",
"id": "file_input123",
"name": "emails.csv",
"mime_type": "text/csv",
"href": "https://tinyvalidator.com/api/v1/files/file_input123"
},
{
"type": "result",
"id": "file_output123",
"name": "emails-results.csv",
"mime_type": "text/csv",
"href": "https://tinyvalidator.com/api/v1/files/file_output123"
}
],
"created_at": "2026-03-13T12:15:00.000Z",
"started_at": "2026-03-13T12:15:05.000Z",
"completed_at": "2026-03-13T12:20:30.000Z",
"expires_at": "2026-04-13T12:20:30.000Z"
}
]
}
Cancel Validation
Cancel a validation that's still in progress.
POST /api/v1/bulk-validations/{id}/cancel
Cancel only works for validations in uploading, pending, processing, or paused states. Once cancelled, you'll receive a bulk_validation.cancelled webhook.
Response (200 OK)
Returns the updated validation record with status: "cancelled".
Resume Paused Validation
Resume a validation that was paused due to insufficient credits.
POST /api/v1/bulk-validations/{id}/resume
After adding credits to your account, call this endpoint to resume processing from where it left off.
Response (200 OK)
Returns the updated validation record with status: "pending".
Download Results
Get the CSV file with validation results.
GET /api/v1/files/{file_id}
The result file ID is available in the files array of a completed validation.
Response Fields
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier for the validation job |
status | string | Current status: uploading, pending, processing, paused, completed, failed, cancelled, expired |
status_reason | string | null | Detailed reason for current status |
filename | string | null | Original filename |
total_count | number | null | Total emails in the file |
processed_count | number | Emails processed so far |
valid_count | number | Emails marked as valid |
invalid_count | number | Emails marked as invalid |
risky_count | number | Emails marked as risky |
error_count | number | Rows with errors (invalid format, etc.) |
progress | number | Percentage complete (0-100) |
error | object | null | Error details if failed |
files | array | File metadata for the input and any generated result files |
created_at | string | ISO 8601 timestamp when job was created |
started_at | string | null | When processing began |
completed_at | string | null | When processing finished |
expires_at | string | null | When result files will be deleted |
Status Reasons
| Reason | Description |
|---|---|
awaiting_upload | Job created, waiting for file upload |
queued | Upload complete, waiting to start processing |
insufficient_credits | Paused due to low credits - add credits to resume |
user_cancelled | You cancelled the job |
invalid_csv | CSV format error - check file and retry |
storage_error | File storage issue - contact support |
processing_error | Unexpected error during processing - contact support |
retention_expired | Results deleted after retention period |
Webhooks
Receive real-time notifications when validation events occur.
Webhook Events
| Event | Description |
|---|---|
bulk_validation.completed | Validation finished successfully |
bulk_validation.failed | Validation failed (see error field) |
bulk_validation.paused | Paused due to insufficient credits |
bulk_validation.cancelled | Job was cancelled |
Webhook Payload
{
"id": "wh_deliv_123",
"type": "bulk_validation.completed",
"occurred_at": "2026-03-13T12:20:30.000Z",
"data": {
"id": "bv_abc123xyz",
"status": "completed",
"status_reason": "queued",
"filename": "emails.csv",
"total_count": 1000,
"processed_count": 1000,
"valid_count": 850,
"invalid_count": 100,
"risky_count": 50,
"error_count": 0,
"progress": 100,
"error": null,
"urls": {
"result": "https://tinyvalidator.com/api/v1/files/file_output123"
},
"created_at": "2026-03-13T12:15:00.000Z",
"started_at": "2026-03-13T12:15:05.000Z",
"completed_at": "2026-03-13T12:20:30.000Z",
"expires_at": "2026-04-13T12:20:30.000Z"
}
}
Webhook Headers
| Header | Description |
|---|---|
x-tv-event | Event type |
x-tv-delivery-id | Unique delivery ID |
x-tv-timestamp | Unix timestamp |
x-tv-signature | HMAC-SHA256 signature (if webhook_secret was set) |
Signature Verification
If you provided a webhook_secret, verify the signature to ensure the webhook came from TinyValidator:
import { createHmac } from 'node:crypto';
function verifyWebhook(payload: string, signature: string, secret: string, timestamp: string): boolean {
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${payload}`)
.digest('hex');
return signature === expected;
}
For complete webhook documentation, see the Webhooks Guide.
Result CSV Format
The result CSV includes all original columns plus validation details:
email,name,company,tv_email,tv_valid,tv_score,tv_deliverability,tv_disposable,tv_role_account,tv_catch_all,tv_free_email,tv_syntax_valid,tv_domain_valid,tv_mailbox_valid,tv_provider,tv_mx_host,tv_suggestion,tv_row_status,tv_status_reason
[email protected],John,Acme,[email protected],true,95,high,false,false,false,false,true,true,true,google,aspmx.l.google.com,,processed,
[email protected],Jane,Widgets,[email protected],false,0,risky,false,false,false,false,true,false,false,,,Domain not found,processed,Domain not found
[email protected],Bob,Test,[email protected],true,80,medium,false,false,false,false,true,true,true,gmail.com,gmail-smtp-in.l.google.com,gmail.com,processed,
,[email protected],,[email protected],,,,,,,,,,,,,,blank,blank_email
Result Columns
All columns prefixed with tv_ are added by TinyValidator:
| Column | Description |
|---|---|
tv_email | The normalized email address that was validated |
tv_valid | Whether email is valid (not disposable and domain exists) |
tv_score | Quality score (0-100) |
tv_deliverability | Rating: high, medium, low, or risky |
tv_disposable | True if disposable email |
tv_role_account | True if role address (info@, support@, etc.) |
tv_catch_all | True if domain accepts all emails |
tv_free_email | True if free email provider (Gmail, Yahoo, etc.) |
tv_syntax_valid | True if valid email format |
tv_domain_valid | True if domain has MX records |
tv_mailbox_valid | True if mailbox verified |
tv_provider | Detected email provider |
tv_mx_host | Mail exchange host for the domain |
tv_suggestion | Typo suggestion (if any) |
tv_row_status | Processing status: processed, duplicate, blank, invalid_input, or error |
tv_status_reason | Detailed reason for the row status |
Row Status Values
The tv_row_status column indicates what happened to each row:
| Status | Description |
|---|---|
processed | Email was validated and a result is available |
duplicate | Same email appeared earlier in the file; previous result was reused |
blank | Row had no email in the specified column |
invalid_input | Email column value couldn't be parsed as an email |
error | Validation failed for this email (see tv_status_reason) |
Code Examples
Complete Workflow (TypeScript)
interface BulkValidation {
id: string;
status: string;
upload?: { url: string; expires_at: string };
urls?: { result?: string };
}
async function createBulkValidation(filename: string, fileBuffer: Buffer) {
// Step 1: Create the job
const createRes = await fetch(
'https://tinyvalidator.com/api/v1/bulk-validations',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.TINYVALIDATOR_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
filename,
has_header: true,
email_column: 'email',
webhook_url: 'https://your-app.com/webhooks/bulk',
}),
}
);
const job: BulkValidation = await createRes.json();
// Step 2: Upload the file
await fetch(job.file.upload_url, {
method: 'PUT',
headers: { 'Content-Type': 'text/csv' },
body: fileBuffer,
});
return job.id;
}
// Check status
async function getValidationStatus(id: string) {
const response = await fetch(
`https://tinyvalidator.com/api/v1/bulk-validations/${id}`,
{
headers: {
'Authorization': `Bearer ${process.env.TINYVALIDATOR_API_KEY}`,
},
}
);
return response.json();
}
// Download results
async function downloadResults(fileId: string) {
const response = await fetch(
`https://tinyvalidator.com/api/v1/files/${fileId}`,
{
headers: {
'Authorization': `Bearer ${process.env.TINYVALIDATOR_API_KEY}`,
},
}
);
return response.blob();
}
Webhook Handler (Express)
import express from 'express';
import { createHmac } from 'node:crypto';
const app = express();
app.use(express.json());
app.post('/webhooks/bulk', (req, res) => {
const signature = req.headers['x-tv-signature'] as string;
const timestamp = req.headers['x-tv-timestamp'] as string;
const event = req.headers['x-tv-event'] as string;
// Verify signature if you set a webhook_secret
if (signature && process.env.WEBHOOK_SECRET) {
const payload = JSON.stringify(req.body);
const expected = createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(`${timestamp}.${payload}`)
.digest('hex');
if (signature !== expected) {
return res.status(401).send('Invalid signature');
}
}
const { data } = req.body;
switch (event) {
case 'bulk_validation.completed':
console.log(`Validation ${data.id} completed!`);
console.log(`Results: ${data.urls?.result}`);
// Download and process the results
break;
case 'bulk_validation.failed':
console.error(`Validation ${data.id} failed:`, data.error);
break;
case 'bulk_validation.paused':
console.log(`Validation ${data.id} paused - add credits to resume`);
break;
}
res.sendStatus(200);
});
Polling for Status
If you don't use webhooks, poll for status updates:
async function waitForCompletion(id: string): Promise<string> {
const checkInterval = 5000; // 5 seconds
const maxAttempts = 720; // 1 hour
for (let i = 0; i < maxAttempts; i++) {
const status = await getValidationStatus(id);
if (status.status === 'completed') {
return status.urls!.result;
}
if (status.status === 'failed') {
throw new Error(`Validation failed: ${status.error?.message}`);
}
if (status.status === 'paused') {
throw new Error('Validation paused - add credits to resume');
}
console.log(`Progress: ${status.progress}%`);
await new Promise(resolve => setTimeout(resolve, checkInterval));
}
throw new Error('Timeout waiting for validation');
}
Billing & Credits
Bulk validation uses the same credit system as single verifications:
- 1 credit per unique email - Duplicate emails within the same job only count once
- No charge for errors - Blank rows or malformed emails don't consume credits
- Processing order - Credits are deducted as emails are validated, not upfront
- Insufficient credits - If you run out, the job pauses and can be resumed later
Best Practices
- Use webhooks - Don't poll; let us notify you when done
- Set a webhook secret - Verify webhook authenticity
- Handle duplicates - We dedupe internally, but clean your lists beforehand for faster processing
- Check CSV format - Ensure proper encoding and valid email addresses
- Download promptly - Results expire after 7 days
- Monitor status - Handle
pausedstates by adding credits - Retry failed uploads - If upload fails, create a new job (upload URLs expire after 15 minutes)
Error Handling
Common Errors
| Status | Error Code | Description |
|---|---|---|
| 400 | invalid_request | Missing required fields |
| 400 | bulk_validation_not_uploading | Job not in uploading state |
| 400 | invalid_csv | CSV parsing failed |
| 401 | unauthorized | Invalid or missing API key |
| 404 | not_found | Job or file not found |
| 409 | duplicate_job | Identical job already exists |
| 429 | rate_limit_exceeded | Too many requests |
Error Response Format
{
"error": "invalid_csv",
"message": "Failed to parse CSV file",
"errors": [
{
"path": ["file"],
"message": "Invalid CSV format at row 15"
}
]
}
Limits
| Limit | Value |
|---|---|
| Maximum file size | 50MB |
| Upload URL expiration | 15 minutes |
| Result retention | 7 days |
| Max jobs per request (list) | 100 |
| Concurrent jobs per organization | 2 |
Processing Concurrency
Bulk validation is designed to process large files efficiently while respecting rate limits:
| Setting | Default | Description |
|---|---|---|
| Concurrent validations per organization | 2 jobs | Maximum jobs running simultaneously per org |
| Concurrent validations per job | 12 | Emails validated in parallel within a single job |
| Domain rate limiting | Per-domain locks | Prevents overwhelming individual mail servers |
Next Steps
- Try the Free Email Verifier for single checks
- Learn about Webhooks for real-time notifications
- Read the Verification API reference
- Understand Quality Scores