Docs/Guides

Webhooks

Receive real-time notifications when validation events occur.

Webhooks allow your application to receive real-time notifications when events occur in TinyValidator. Instead of polling for status updates, we'll send an HTTP POST request to your endpoint whenever something happens.

Overview

Use webhooks to:

  • Monitor bulk validations - Get notified when validation jobs complete, fail, or pause
  • Automate workflows - Trigger actions in your system based on validation results
  • Sync data - Keep your database updated without polling

How Webhooks Work

1. Configure webhook URL and secret in your organization settings
2. Subscribe to webhook when creating bulk validations (optional)
3. TinyValidator sends POST request when events occur
4. Your server receives payload and processes it
5. Return 2xx status to acknowledge receipt

Webhook Events

Bulk Validation Events

EventDescriptionWhen It Fires
bulk_validation.completedValidation finished successfullyAll emails processed
bulk_validation.failedValidation failedError during processing
bulk_validation.pausedValidation pausedInsufficient credits
bulk_validation.cancelledValidation cancelledUser cancelled the job

Setting Up Webhooks

Step 1: Configure Organization Webhook Settings

Set your default webhook URL and signing secret:

curl -X POST "https://tinyvalidator.com/api/v1/webhooks" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_url": "https://your-app.com/webhooks/tinyvalidator",
    "webhook_secret": "whsec_your_secret_here"
  }'

Request Body

FieldTypeRequiredDescription
webhook_urlstring | nullYesHTTPS URL to receive webhooks
webhook_secretstring | nullYesSecret for signing webhooks (min 16 characters)

Note: You must provide both webhook_url and webhook_secret, or both null to disable.

Response (200 OK)

{
  "webhook_url": "https://your-app.com/webhooks/tinyvalidator",
  "has_webhook_secret": true
}

Step 2: Get Current Settings

Check your current webhook configuration:

curl "https://tinyvalidator.com/api/v1/webhooks" \
  -H "Authorization: Bearer YOUR_API_KEY"

Response (200 OK)

{
  "webhook_url": "https://your-app.com/webhooks/tinyvalidator",
  "has_webhook_secret": true
}

Step 3: Use in Bulk Validations

When creating a bulk validation, the organization webhook settings are used by default. You can override them per-job:

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",
    "webhook_url": "https://your-app.com/webhooks/bulk",
    "webhook_secret": "whsec_job_specific_secret"
  }'

Webhook Payload

When an event occurs, we send a POST request with this payload:

{
  "id": "wh_deliv_abc123",
  "type": "bulk_validation.completed",
  "occurred_at": "2026-03-13T12:20:30.000Z",
  "data": {
    "id": "bv_xyz789",
    "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"
  }
}

Payload Fields

FieldTypeDescription
idstringUnique delivery ID for this webhook
typestringEvent type that triggered the webhook
occurred_atstringISO 8601 timestamp when the event occurred
dataobjectEvent-specific data (bulk validation object)

Headers

Each webhook includes these headers:

HeaderDescription
content-typeapplication/json
user-agenttinyvalidator-webhooks/1.0
x-tv-eventEvent type (e.g., bulk_validation.completed)
x-tv-delivery-idUnique ID for this delivery (same as payload id)
x-tv-timestampUnix timestamp when the webhook was sent
x-tv-signatureHMAC-SHA256 signature (if secret was configured)

Signature Verification

Webhooks include a signature header when you configure a webhook_secret. Verify it to ensure the request came from TinyValidator:

Verification Algorithm

signature = HMAC-SHA256(secret, timestamp + "." + payload)

Example (Node.js)

import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyWebhook(
  payload: string,
  signature: string,
  secret: string,
  timestamp: string
): boolean {
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${payload}`)
    .digest('hex');
  
  // Use timing-safe comparison to prevent timing attacks
  try {
    const sigBuf = Buffer.from(signature, 'hex');
    const expBuf = Buffer.from(expected, 'hex');
    return timingSafeEqual(sigBuf, expBuf);
  } catch {
    return false;
  }
}

// Express middleware example
app.post('/webhooks/tinyvalidator', (req, res) => {
  const signature = req.headers['x-tv-signature'] as string;
  const timestamp = req.headers['x-tv-timestamp'] as string;
  const payload = JSON.stringify(req.body);
  
  if (!verifyWebhook(payload, signature, process.env.WEBHOOK_SECRET!, timestamp)) {
    return res.status(401).send('Invalid signature');
  }
  
  // Process the webhook...
  res.sendStatus(200);
});

Example (Python)

import hmac
import hashlib

def verify_webhook(payload: str, signature: str, secret: str, timestamp: str) -> bool:
    expected = hmac.new(
        secret.encode('utf-8'),
        f"{timestamp}.{payload}".encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    
    return hmac.compare_digest(signature, expected)

# Flask example
@app.route('/webhooks/tinyvalidator', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('x-tv-signature')
    timestamp = request.headers.get('x-tv-timestamp')
    payload = request.get_data(as_text=True)
    
    if not verify_webhook(payload, signature, WEBHOOK_SECRET, timestamp):
        return 'Invalid signature', 401
    
    # Process the webhook...
    return '', 200

Example (PHP)

function verifyWebhook(string $payload, string $signature, string $secret, string $timestamp): bool {
    $expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
    return hash_equals($expected, $signature);
}

// Example usage
$signature = $_SERVER['HTTP_X_TV_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_TV_TIMESTAMP'] ?? '';
$payload = file_get_contents('php://input');

if (!verifyWebhook($payload, $signature, $_ENV['WEBHOOK_SECRET'], $timestamp)) {
    http_response_code(401);
    exit('Invalid signature');
}

Handling Webhooks

Best Practices

  1. Return 2xx quickly - Acknowledge receipt immediately, process asynchronously
  2. Verify signatures - Always verify the x-tv-signature header
  3. Handle duplicates - Use x-tv-delivery-id or payload id to dedupe
  4. Handle retries - We retry failed deliveries with exponential backoff
  5. Use HTTPS - Webhooks only work with HTTPS URLs
  6. Idempotency - Processing the same webhook twice should be safe

Idempotency Example

// Track processed webhooks to prevent duplicate processing
const processedWebhooks = new Set<string>();

app.post('/webhooks/tinyvalidator', async (req, res) => {
  const deliveryId = req.headers['x-tv-delivery-id'] as string;
  
  // Check if already processed
  if (processedWebhooks.has(deliveryId)) {
    return res.sendStatus(200); // Already handled
  }
  
  // Process the webhook
  const { type, data } = req.body;
  
  switch (type) {
    case 'bulk_validation.completed':
      await processCompletedValidation(data);
      break;
    case 'bulk_validation.failed':
      await notifyFailure(data);
      break;
    case 'bulk_validation.paused':
      await notifyInsufficientCredits(data);
      break;
  }
  
  // Mark as processed
  processedWebhooks.add(deliveryId);
  
  res.sendStatus(200);
});

Retry Logic

If your endpoint returns a non-2xx status or times out, we retry with exponential backoff:

AttemptDelay
1Immediate
25 seconds
310 seconds
420 seconds
540 seconds

After 5 failed attempts, the webhook is considered failed and won't be retried.

Timeout

Your endpoint must respond within 30 seconds. After that, the request times out and we retry.

Complete Example (Express)

import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = express();
app.use(express.json({ verify: (req, res, buf) => { (req as any).rawBody = buf; } }));

// Store processed webhook IDs (use Redis in production)
const processedWebhooks = new Set<string>();

function verifySignature(
  rawBody: Buffer,
  signature: string,
  secret: string,
  timestamp: string
): boolean {
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody.toString('utf8')}`)
    .digest('hex');
  
  try {
    return timingSafeEqual(
      Buffer.from(signature, 'hex'),
      Buffer.from(expected, 'hex')
    );
  } catch {
    return false;
  }
}

app.post('/webhooks/tinyvalidator', async (req, res) => {
  const signature = req.headers['x-tv-signature'] as string;
  const timestamp = req.headers['x-tv-timestamp'] as string;
  const deliveryId = req.headers['x-tv-delivery-id'] as string;
  const event = req.headers['x-tv-event'] as string;
  
  // Verify signature
  if (!verifySignature((req as any).rawBody, signature, process.env.WEBHOOK_SECRET!, timestamp)) {
    console.error('Invalid webhook signature');
    return res.status(401).send('Invalid signature');
  }
  
  // Deduplication
  if (processedWebhooks.has(deliveryId)) {
    console.log(`Already processed webhook ${deliveryId}`);
    return res.sendStatus(200);
  }
  
  const { data } = req.body;
  
  try {
    switch (event) {
      case 'bulk_validation.completed':
        console.log(`Validation ${data.id} completed!`);
        console.log(`Valid: ${data.valid_count}, Invalid: ${data.invalid_count}`);
        
        // Download results
        const resultFile = data.files?.find((file) => file.type === 'result');
        if (resultFile?.href) {
          const results = await fetch(resultFile.href, {
            headers: { 'Authorization': `Bearer ${process.env.TINYVALIDATOR_API_KEY}` }
          });
          const csv = await results.text();
          await processValidationResults(csv);
        }
        break;
        
      case 'bulk_validation.failed':
        console.error(`Validation ${data.id} failed:`, data.error);
        await notifyTeamOfFailure(data);
        break;
        
      case 'bulk_validation.paused':
        console.log(`Validation ${data.id} paused - insufficient credits`);
        await notifyAdminToAddCredits(data);
        break;
        
      case 'bulk_validation.cancelled':
        console.log(`Validation ${data.id} was cancelled`);
        break;
    }
    
    processedWebhooks.add(deliveryId);
    res.sendStatus(200);
  } catch (error) {
    console.error('Error processing webhook:', error);
    // Return 500 to trigger retry
    res.status(500).send('Internal error');
  }
});

async function processValidationResults(csv: string) {
  // Parse CSV and update your database
  console.log('Processing validation results...');
}

async function notifyTeamOfFailure(data: any) {
  // Send alert to your team
}

async function notifyAdminToAddCredits(data: any) {
  // Notify admin to add credits
}

app.listen(3000, () => {
  console.log('Webhook server listening on port 3000');
});

Security Considerations

HTTPS Required

Webhooks only work with HTTPS URLs. HTTP URLs are rejected.

Secret Management

  • Generate strong secrets (at least 32 random characters)
  • Store secrets in environment variables
  • Rotate secrets periodically
  • Never expose secrets in logs or error messages

IP Allowlisting (Optional)

If you want to restrict webhook requests to TinyValidator IPs, contact support for our IP ranges.

Testing Webhooks

Local Development

Use a tool like ngrok to expose your local server:

# Start your webhook server
npm run dev

# In another terminal, expose it
ngrok http 3000

# Use the ngrok HTTPS URL in your webhook settings
curl -X POST "https://tinyvalidator.com/api/v1/webhooks" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_url": "https://abc123.ngrok.io/webhooks/tinyvalidator",
    "webhook_secret": "whsec_test_secret"
  }'

Testing Payloads

You can manually test your webhook handler with curl:

# Create a test payload
payload='{"id":"wh_test_123","type":"bulk_validation.completed","occurred_at":"2026-03-13T12:00:00.000Z","data":{"id":"bv_test","status":"completed","total_count":100,"processed_count":100,"valid_count":90,"invalid_count":10,"risky_count":0,"error_count":0,"progress":100,"error":null,"urls":null,"created_at":"2026-03-13T11:00:00.000Z","started_at":"2026-03-13T11:01:00.000Z","completed_at":"2026-03-13T12:00:00.000Z","expires_at":null}}'

# Generate signature
timestamp=$(date +%s)
secret="whsec_test_secret"
signature=$(echo -n "${timestamp}.${payload}" | openssl dgst -sha256 -hmac "$secret" | sed 's/^.* //')

# Send test webhook
curl -X POST "https://your-app.com/webhooks/tinyvalidator" \
  -H "Content-Type: application/json" \
  -H "x-tv-event: bulk_validation.completed" \
  -H "x-tv-delivery-id: wh_test_123" \
  -H "x-tv-timestamp: $timestamp" \
  -H "x-tv-signature: $signature" \
  -d "$payload"

Troubleshooting

Webhooks Not Received

  1. Check that your URL is HTTPS
  2. Verify the URL is publicly accessible (not behind a firewall)
  3. Check your server logs for requests from tinyvalidator-webhooks
  4. Ensure your endpoint returns 2xx status codes

Invalid Signature

  1. Verify you're using the correct secret
  2. Check that you're computing the signature on the raw payload (before JSON parsing)
  3. Ensure the timestamp and payload format match exactly

Duplicate Webhooks

  • Implement idempotency checks using x-tv-delivery-id
  • Store processed IDs and check before processing

High Latency

  • Return 200 immediately, process asynchronously
  • Use a queue (Redis, SQS, etc.) for background processing
  • Keep webhook handling lightweight

API Reference

Get Webhook Settings

GET /api/v1/webhooks

Retrieve your organization's webhook configuration.

Response (200 OK)

{
  "webhook_url": "https://your-app.com/webhooks/tinyvalidator",
  "has_webhook_secret": true
}

Update Webhook Settings

POST /api/v1/webhooks

Update your organization's webhook configuration.

Request Body

{
  "webhook_url": "https://your-app.com/webhooks/tinyvalidator",
  "webhook_secret": "whsec_your_secret_here"
}

Validation Rules

  • Both webhook_url and webhook_secret must be provided together, or both null
  • URL must be valid HTTPS
  • Secret must be at least 16 characters

Response (200 OK)

{
  "webhook_url": "https://your-app.com/webhooks/tinyvalidator",
  "has_webhook_secret": true
}

Error Responses

StatusCodeDescription
400webhook_secret_requiredSecret missing when URL is provided
400webhook_url_requiredURL missing when secret is provided
400invalid_urlURL is not valid HTTPS
401unauthorizedInvalid API key

Next Steps