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
| Event | Description | When It Fires |
|---|---|---|
bulk_validation.completed | Validation finished successfully | All emails processed |
bulk_validation.failed | Validation failed | Error during processing |
bulk_validation.paused | Validation paused | Insufficient credits |
bulk_validation.cancelled | Validation cancelled | User 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
| Field | Type | Required | Description |
|---|---|---|---|
webhook_url | string | null | Yes | HTTPS URL to receive webhooks |
webhook_secret | string | null | Yes | Secret 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
| Field | Type | Description |
|---|---|---|
id | string | Unique delivery ID for this webhook |
type | string | Event type that triggered the webhook |
occurred_at | string | ISO 8601 timestamp when the event occurred |
data | object | Event-specific data (bulk validation object) |
Headers
Each webhook includes these headers:
| Header | Description |
|---|---|
content-type | application/json |
user-agent | tinyvalidator-webhooks/1.0 |
x-tv-event | Event type (e.g., bulk_validation.completed) |
x-tv-delivery-id | Unique ID for this delivery (same as payload id) |
x-tv-timestamp | Unix timestamp when the webhook was sent |
x-tv-signature | HMAC-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
- Return 2xx quickly - Acknowledge receipt immediately, process asynchronously
- Verify signatures - Always verify the
x-tv-signatureheader - Handle duplicates - Use
x-tv-delivery-idor payloadidto dedupe - Handle retries - We retry failed deliveries with exponential backoff
- Use HTTPS - Webhooks only work with HTTPS URLs
- 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:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 5 seconds |
| 3 | 10 seconds |
| 4 | 20 seconds |
| 5 | 40 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
- Check that your URL is HTTPS
- Verify the URL is publicly accessible (not behind a firewall)
- Check your server logs for requests from
tinyvalidator-webhooks - Ensure your endpoint returns 2xx status codes
Invalid Signature
- Verify you're using the correct secret
- Check that you're computing the signature on the raw payload (before JSON parsing)
- 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_urlandwebhook_secretmust 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
| Status | Code | Description |
|---|---|---|
| 400 | webhook_secret_required | Secret missing when URL is provided |
| 400 | webhook_url_required | URL missing when secret is provided |
| 400 | invalid_url | URL is not valid HTTPS |
| 401 | unauthorized | Invalid API key |
Next Steps
- Learn about Bulk Validation
- Read the Verification API reference
- Understand Quality Scores