# Webhooks

> Docs index for AI agents: [https://mailvalid.io/llms.txt](https://mailvalid.io/llms.txt). Every docs page is also available as Markdown: add `.md` to its URL.

Instead of polling, get notified when a bulk verification job completes. Pass a `webhook_url` when you [submit a bulk job](https://mailvalid.io/docs/verify-bulk).

Every webhook includes an `X-Mailvalid-Signature` header. This is an HMAC-SHA256 of the **raw request body**, signed with your account's webhook secret. Your secret is shown in the dashboard and is also returned as `webhook_secret` from `GET /api/auth/stats`. Recompute the HMAC over the exact bytes you receive and compare. Do not re-serialize the JSON first, or it won't match.

## Webhook payload

```json
{
  "event": "job.completed",
  "timestamp": "2026-01-30T10:30:00Z",
  "data": {
    "job_id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "completed",
    "total_emails": 100,
    "processed_emails": 100,
    "valid_count": 85,
    "invalid_count": 15,
    "credits_used": 100,
    "started_at": "2026-01-30T10:28:00Z",
    "completed_at": "2026-01-30T10:30:00Z",
    "error_message": null,
    "results_summary": {
      "total": 100,
      "results_url": "https://mailvalid.io/api/v1/verify/bulk/550e8400-e29b-41d4-a716-446655440000"
    }
  }
}
```

## Webhook headers

| Header | Description |
|---|---|
| `X-Mailvalid-Signature` | HMAC-SHA256 signature of the payload |
| `X-MailValid-Event` | Event type: `job.completed`, `job.failed`, or `job.cancelled` |
| `Content-Type` | application/json |

## Verifying the signature

Compute an HMAC-SHA256 of the raw request body using your `webhook_secret`, then compare it to the `X-Mailvalid-Signature` header. Sign the bytes exactly as you received them. Don't parse and re-serialize the JSON.

```python
import hmac, hashlib
from flask import request, abort

WEBHOOK_SECRET = "your_webhook_secret"  # from GET /api/auth/stats

def handle_webhook():
    raw = request.get_data()  # raw bytes — do NOT use request.json
    expected = hmac.new(WEBHOOK_SECRET.encode(), raw, hashlib.sha256).hexdigest()
    sig = request.headers.get("X-Mailvalid-Signature", "")
    if not hmac.compare_digest(expected, sig):
        abort(401)  # signature mismatch — reject
    event = request.json
    # ... trust event and process ...
    return "", 200
```

## Retries

Failed deliveries are retried with exponential backoff (1, 2, 4, 8 and 16 minutes), up to 5 attempts.

## Related

- [Bounce webhook](https://mailvalid.io/docs/bounce-webhook): send delivery and bounce events back to MailValid
