# Webhooks

URL: https://email-validation.org/docs/webhooks/
Updated: 2026-10-20

> Documentation index: https://email-validation.org/llms.txt

Webhooks send an HTTPS `POST` to your server when a bulk job ends or your credits need attention. Each delivery is
signed with the [Standard Webhooks](https://www.standardwebhooks.com/) scheme, so you can check that it came from us
and was not changed.

## Events

| Event | Sent when | Data |
|---|---|---|
| `bulk.completed` | A bulk job finished and its results can be downloaded. | job id, status, counts, credits charged |
| `bulk.failed` | A bulk job could not finish (unused credits are returned). | job id, status, `error_code` |
| `credits.low` | Your balance fell below the low-balance threshold you set. | balance, threshold |
| `credits.expiring` | Some of your credits expire in 30 days. | credits, expiry date |
| `webhook.test` | You pressed **Send test** for the endpoint in the dashboard (sent whatever events it subscribes to). | endpoint id |

## Register an endpoint

Add endpoints under **Webhooks** in the dashboard: an `https://` URL on port 443 with a public host name, and the
events you want. We show the signing secret (`whsec_…`) once; store it like a password. For safety, adding an
endpoint asks you to confirm it is you.

Lists uploaded in the dashboard notify the endpoint you pick under **Notify a webhook** before you upload them
(endpoints subscribed to a bulk event are offered), or, when you pick none, every endpoint subscribed to the event.
For a job created through the API, pass the endpoint's id as `webhook_endpoint_id` when you
[create the job](https://email-validation.org/docs/api/bulk/#create); that endpoint gets the job's event even if it did not subscribe to it. Credit
events go to every endpoint that subscribes to them. **Send test** in the dashboard delivers a `webhook.test` event,
signed like every other delivery, to check your handler. Answer it with a `2xx` too: a handler that accepts only the
events it subscribed to would make the test report a failure.

## Payload

Every delivery is a JSON object with the event `type`, a `timestamp` and `data`. It holds ids, statuses and counts
only, never addresses or results; download those with the API.

```json
{
  "type": "bulk.completed",
  "timestamp": "2026-11-02T09:52:08.000Z",
  "data": {
    "job_id": "job_3Fv8QpL2xM7nC1rT9kW4yB",
    "status": "completed",
    "total_rows": 12480,
    "unique_emails": 12102,
    "processed": 12102,
    "credits_charged": 11874,
    "counts": {
      "deliverable": 10215,
      "undeliverable": 977,
      "risky": 723,
      "unknown": 187
    },
    "error_code": null
  }
}
```

`credits.low`:

```json
{
  "type": "credits.low",
  "timestamp": "2026-11-02T10:04:00.000Z",
  "data": {
    "balance": 950,
    "threshold": 1000
  }
}
```

`credits.expiring`:

```json
{
  "type": "credits.expiring",
  "timestamp": "2026-11-02T00:00:00.000Z",
  "data": {
    "credits": 850,
    "expires_at": "2026-12-02T12:00:00.000Z"
  }
}
```

## Headers

| Header | Content |
|---|---|
| `webhook-id` | Unique id of the event. It stays the same when a delivery is retried. |
| `webhook-timestamp` | Unix time (seconds) when the delivery was signed. |
| `webhook-signature` | One or more signatures separated by spaces, each `v1,<base64>`. |

## Verify the signature

1. Read the **raw** request body, before any JSON parsing.
2. Build the signed content: `webhook-id` + `.` + `webhook-timestamp` + `.` + body.
3. Compute HMAC-SHA256 over it with your secret: the part after `whsec_`, base64-decoded.
4. Base64-encode the result and compare it, in constant time, with each `v1,` entry of `webhook-signature`. One
   match is enough.
5. Reject the delivery when `webhook-timestamp` is more than 5 minutes away from your clock.

**JavaScript**

```js
// Node.js (Express shown): use express.raw() so the body stays byte-for-byte as sent.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !timestamp || !signatures) throw new Error('Missing webhook headers');
  const ts = Number(timestamp);
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) {
    throw new Error('Timestamp outside tolerance');
  }
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest();
  for (const entry of signatures.split(' ')) {
    const [version, sig] = entry.split(',');
    if (version !== 'v1' || !sig) continue;
    const given = Buffer.from(sig, 'base64');
    if (given.length === expected.length && timingSafeEqual(given, expected)) return JSON.parse(rawBody);
  }
  throw new Error('No valid signature');
}

const app = express();
app.post('/webhooks/email-validation', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = verifyWebhook(req.body, req.headers, process.env.EV_WEBHOOK_SECRET);
  } catch {
    return res.sendStatus(400);
  }
  // Deduplicate on req.headers['webhook-id'], then handle event.type.
  res.sendStatus(204);
});
```

**TypeScript**

```ts
// Web Crypto: Cloudflare Workers, Deno, Bun, Node.js 20+.
export async function verifyWebhook(
  rawBody: string,
  headers: Headers,
  secret: string,
  toleranceSeconds = 300,
): Promise<unknown> {
  const id = headers.get('webhook-id');
  const timestamp = headers.get('webhook-timestamp');
  const signatures = headers.get('webhook-signature');
  if (!id || !timestamp || !signatures) throw new Error('Missing webhook headers');
  const ts = Number(timestamp);
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) {
    throw new Error('Timestamp outside tolerance');
  }
  const toBytes = (b64: string) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
  const key = await crypto.subtle.importKey(
    'raw',
    toBytes(secret.replace(/^whsec_/, '')),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['verify'],
  );
  const data = new TextEncoder().encode(`${id}.${timestamp}.${rawBody}`);
  for (const entry of signatures.split(' ')) {
    const [version, sig] = entry.split(',');
    if (version !== 'v1' || !sig) continue;
    let given: Uint8Array;
    try {
      given = toBytes(sig);
    } catch {
      continue;
    }
    // crypto.subtle.verify compares in constant time.
    if (await crypto.subtle.verify('HMAC', key, given, data)) return JSON.parse(rawBody);
  }
  throw new Error('No valid signature');
}

// In a Worker: const event = await verifyWebhook(await request.text(), request.headers, env.EV_WEBHOOK_SECRET);
```

**Python**

```python
import base64
import hashlib
import hmac
import json
import time


def verify_webhook(raw_body: bytes, headers, secret: str, tolerance: int = 300) -> dict:
    """headers: any case-insensitive mapping, e.g. request.headers in Flask or Django."""
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not (msg_id and timestamp and signatures):
        raise ValueError("missing webhook headers")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
        raise ValueError("timestamp outside tolerance")

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = hmac.new(key, signed, hashlib.sha256).digest()
    for entry in signatures.split(" "):
        version, _, sig = entry.partition(",")
        if version != "v1" or not sig:
            continue
        try:
            given = base64.b64decode(sig)
        except ValueError:
            continue
        if hmac.compare_digest(given, expected):
            return json.loads(raw_body)
    raise ValueError("no valid signature")


# Flask: event = verify_webhook(request.get_data(), request.headers, os.environ["EV_WEBHOOK_SECRET"])
```

**PHP**

```php
<?php
function verify_webhook(string $rawBody, array $headers, string $secret, int $tolerance = 300): array
{
    $id = $headers['webhook-id'] ?? '';
    $timestamp = $headers['webhook-timestamp'] ?? '';
    $signatures = $headers['webhook-signature'] ?? '';
    if ($id === '' || $timestamp === '' || $signatures === '') {
        throw new RuntimeException('Missing webhook headers');
    }
    if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $tolerance) {
        throw new RuntimeException('Timestamp outside tolerance');
    }
    $key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
    if ($key === false) {
        throw new RuntimeException('Invalid webhook secret');
    }
    $expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$rawBody", $key, true));
    foreach (explode(' ', $signatures) as $entry) {
        [$version, $sig] = array_pad(explode(',', $entry, 2), 2, '');
        if ($version === 'v1' && hash_equals($expected, $sig)) {
            return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
        }
    }
    throw new RuntimeException('No valid signature');
}

// Read the raw body before any framework parses it.
$event = verify_webhook(
    file_get_contents('php://input'),
    array_change_key_case(getallheaders(), CASE_LOWER),
    getenv('EV_WEBHOOK_SECRET'),
);
```

**Ruby**

```ruby
require "json"
require "openssl"

def verify_webhook(raw_body, headers, secret, tolerance: 300)
  id = headers["webhook-id"]
  timestamp = headers["webhook-timestamp"]
  signatures = headers["webhook-signature"]
  raise ArgumentError, "missing webhook headers" unless id && timestamp && signatures
  unless timestamp.match?(/\A\d+\z/) && (Time.now.to_i - timestamp.to_i).abs <= tolerance
    raise ArgumentError, "timestamp outside tolerance"
  end

  key = secret.delete_prefix("whsec_").unpack1("m0")
  expected = [OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{raw_body}")].pack("m0")
  valid = signatures.split(" ").any? do |entry|
    version, sig = entry.split(",", 2)
    version == "v1" && sig && OpenSSL.secure_compare(sig, expected)
  end
  raise ArgumentError, "no valid signature" unless valid

  JSON.parse(raw_body)
end

# Rails: verify_webhook(request.raw_post, request.headers, ENV.fetch("EV_WEBHOOK_SECRET"))
```

**Go**

```go
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"math"
	"net/http"
	"strconv"
	"strings"
	"time"
)

// Verify checks a delivery. Read the body with io.ReadAll(r.Body) before calling it.
func Verify(body []byte, h http.Header, secret string, tolerance time.Duration) error {
	id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature")
	if id == "" || ts == "" || sigs == "" {
		return errors.New("missing webhook headers")
	}
	sec, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-sec)) > tolerance.Seconds() {
		return errors.New("timestamp outside tolerance")
	}
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return err
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + ts + "."))
	mac.Write(body)
	expected := mac.Sum(nil)
	for _, entry := range strings.Split(sigs, " ") {
		version, sig, ok := strings.Cut(entry, ",")
		if !ok || version != "v1" {
			continue
		}
		given, err := base64.StdEncoding.DecodeString(sig)
		if err == nil && hmac.Equal(given, expected) {
			return nil
		}
	}
	return errors.New("no valid signature")
}
```

**Java**

```java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class WebhookVerifier {
    /** Throws SecurityException unless the delivery is authentic and recent. */
    public static void verify(String rawBody, String id, String timestamp, String signatures, String secret)
            throws Exception {
        if (id == null || timestamp == null || signatures == null) {
            throw new SecurityException("Missing webhook headers");
        }
        if (!timestamp.matches("\\d{1,12}")
                || Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp)) > 300) {
            throw new SecurityException("Timestamp outside tolerance");
        }
        byte[] key = Base64.getDecoder().decode(secret.replaceFirst("^whsec_", ""));
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(key, "HmacSHA256"));
        byte[] expected = mac.doFinal((id + "." + timestamp + "." + rawBody).getBytes(StandardCharsets.UTF_8));
        for (String entry : signatures.split(" ")) {
            String[] parts = entry.split(",", 2);
            if (parts.length != 2 || !parts[0].equals("v1")) {
                continue;
            }
            try {
                if (MessageDigest.isEqual(Base64.getDecoder().decode(parts[1]), expected)) {
                    return;
                }
            } catch (IllegalArgumentException ignored) {
                // not base64: try the next signature
            }
        }
        throw new SecurityException("No valid signature");
    }
}
```

**C#**

```csharp
using System.Security.Cryptography;
using System.Text;

public static class WebhookVerifier
{
    /// Throws unless the delivery is authentic and recent. Pass the raw body as received.
    public static void Verify(string rawBody, string? id, string? timestamp, string? signatures, string secret)
    {
        if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(signatures))
            throw new CryptographicException("Missing webhook headers");
        if (!long.TryParse(timestamp, out var ts) ||
            Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300)
            throw new CryptographicException("Timestamp outside tolerance");

        var key = Convert.FromBase64String(secret.StartsWith("whsec_") ? secret[6..] : secret);
        var expected = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{id}.{timestamp}.{rawBody}"));
        foreach (var entry in signatures.Split(' '))
        {
            var parts = entry.Split(',', 2);
            if (parts.Length != 2 || parts[0] != "v1") continue;
            try
            {
                if (CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(parts[1]), expected)) return;
            }
            catch (FormatException)
            {
                // not base64: try the next signature
            }
        }
        throw new CryptographicException("No valid signature");
    }
}
```

The [JavaScript and Python SDKs](https://email-validation.org/docs/sdks/) include a ready-made `verifyWebhook` / `verify_webhook` helper.

## Respond and retry

- Answer with any `2xx` status within 10 seconds. Do slow work after you answer.
- Redirects are not followed, and anything other than `2xx` counts as a failure.
- Failed deliveries are retried several times with growing delays, so the same event can arrive more than once:
  deduplicate on `webhook-id`.
- An endpoint that fails 20 times in a row is disabled; turn it back on in the dashboard after fixing it.

## Security checklist

- Verify every delivery before acting on it, and keep the secret out of code and logs.
- Accept only `POST` with `Content-Type: application/json` on the webhook path.
- Treat the payload as a hint: when in doubt, read the job with the API before doing anything irreversible.
