Docs/Guides

Bulk Email Validation

Validate thousands of emails at once with CSV upload and background processing.

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

FieldTypeRequiredDescription
filenamestringYesOriginal filename (for reference)
has_headerbooleanNoCSV has header row (default: true)
email_columnstring | nullNoColumn name containing emails (default: first column)
delimiterstring | nullNoCSV delimiter (default: ,)
webhook_urlstring | nullNoURL to receive completion webhook
webhook_secretstring | nullNoSecret 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

ParameterTypeDescription
limitnumberMax results to return (1-100, default: 10)
statusstringFilter 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

FieldTypeDescription
idstringUnique identifier for the validation job
statusstringCurrent status: uploading, pending, processing, paused, completed, failed, cancelled, expired
status_reasonstring | nullDetailed reason for current status
filenamestring | nullOriginal filename
total_countnumber | nullTotal emails in the file
processed_countnumberEmails processed so far
valid_countnumberEmails marked as valid
invalid_countnumberEmails marked as invalid
risky_countnumberEmails marked as risky
error_countnumberRows with errors (invalid format, etc.)
progressnumberPercentage complete (0-100)
errorobject | nullError details if failed
filesarrayFile metadata for the input and any generated result files
created_atstringISO 8601 timestamp when job was created
started_atstring | nullWhen processing began
completed_atstring | nullWhen processing finished
expires_atstring | nullWhen result files will be deleted

Status Reasons

ReasonDescription
awaiting_uploadJob created, waiting for file upload
queuedUpload complete, waiting to start processing
insufficient_creditsPaused due to low credits - add credits to resume
user_cancelledYou cancelled the job
invalid_csvCSV format error - check file and retry
storage_errorFile storage issue - contact support
processing_errorUnexpected error during processing - contact support
retention_expiredResults deleted after retention period

Webhooks

Receive real-time notifications when validation events occur.

Webhook Events

EventDescription
bulk_validation.completedValidation finished successfully
bulk_validation.failedValidation failed (see error field)
bulk_validation.pausedPaused due to insufficient credits
bulk_validation.cancelledJob 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

HeaderDescription
x-tv-eventEvent type
x-tv-delivery-idUnique delivery ID
x-tv-timestampUnix timestamp
x-tv-signatureHMAC-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:

ColumnDescription
tv_emailThe normalized email address that was validated
tv_validWhether email is valid (not disposable and domain exists)
tv_scoreQuality score (0-100)
tv_deliverabilityRating: high, medium, low, or risky
tv_disposableTrue if disposable email
tv_role_accountTrue if role address (info@, support@, etc.)
tv_catch_allTrue if domain accepts all emails
tv_free_emailTrue if free email provider (Gmail, Yahoo, etc.)
tv_syntax_validTrue if valid email format
tv_domain_validTrue if domain has MX records
tv_mailbox_validTrue if mailbox verified
tv_providerDetected email provider
tv_mx_hostMail exchange host for the domain
tv_suggestionTypo suggestion (if any)
tv_row_statusProcessing status: processed, duplicate, blank, invalid_input, or error
tv_status_reasonDetailed reason for the row status

Row Status Values

The tv_row_status column indicates what happened to each row:

StatusDescription
processedEmail was validated and a result is available
duplicateSame email appeared earlier in the file; previous result was reused
blankRow had no email in the specified column
invalid_inputEmail column value couldn't be parsed as an email
errorValidation 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

  1. Use webhooks - Don't poll; let us notify you when done
  2. Set a webhook secret - Verify webhook authenticity
  3. Handle duplicates - We dedupe internally, but clean your lists beforehand for faster processing
  4. Check CSV format - Ensure proper encoding and valid email addresses
  5. Download promptly - Results expire after 7 days
  6. Monitor status - Handle paused states by adding credits
  7. Retry failed uploads - If upload fails, create a new job (upload URLs expire after 15 minutes)

Error Handling

Common Errors

StatusError CodeDescription
400invalid_requestMissing required fields
400bulk_validation_not_uploadingJob not in uploading state
400invalid_csvCSV parsing failed
401unauthorizedInvalid or missing API key
404not_foundJob or file not found
409duplicate_jobIdentical job already exists
429rate_limit_exceededToo 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

LimitValue
Maximum file size50MB
Upload URL expiration15 minutes
Result retention7 days
Max jobs per request (list)100
Concurrent jobs per organization2

Processing Concurrency

Bulk validation is designed to process large files efficiently while respecting rate limits:

SettingDefaultDescription
Concurrent validations per organization2 jobsMaximum jobs running simultaneously per org
Concurrent validations per job12Emails validated in parallel within a single job
Domain rate limitingPer-domain locksPrevents overwhelming individual mail servers

Next Steps