Webhooks

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 scheme, so you can check that it came from us and was not changed.

Events

EventSent whenData
bulk.completedA bulk job finished and its results can be downloaded.job id, status, counts, credits charged
bulk.failedA bulk job could not finish (unused credits are returned).job id, status, error_code
credits.lowYour balance fell below the low-balance threshold you set.balance, threshold
credits.expiringSome of your credits expire in 30 days.credits, expiry date
webhook.testYou 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; 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.

{
  "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:

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

credits.expiring:

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

Headers

HeaderContent
webhook-idUnique id of the event. It stays the same when a delivery is retried.
webhook-timestampUnix time (seconds) when the delivery was signed.
webhook-signatureOne 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

// 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

// 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

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
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

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

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

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#

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 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.

Updated · Maintained by the email-validation.org team