Webhooks

When you supply a callback_url, the API delivers the result to your HTTPS endpoint as an HTTP POST with a JSON body. Deliveries are signed, retried for about three days, and every attempt is visible in your dashboard.

The GET polling endpoints remain authoritative — use them as a fallback if a delivery is missed or your service is temporarily unavailable.

On this page

Envelope

All webhook payloads share this wrapper:

{
  "id":         "evt_3a4b5c6d...",
  "event":      "verify.completed",
  "created_at": "2025-06-10T14:02:11.432Z",
  "data":       { ... }
}
Field Description
id Stable, deterministic event ID (evt_ + SHA-256 hex of the resource ID and event name). Identical across retries for the same event — use it as an idempotency key.
event Event name (see table below)
created_at UTC timestamp of the event
data Event-specific payload

Request headers

Every outbound delivery carries these:

Header Value
X-Signature t=<unixSeconds>,v1=<hex> — see Signature verification
X-Ev-Event The event name, the same value as the envelope's event field, so you can route before parsing the body
X-Ev-Delivery The 1-based attempt number of this delivery: 1 first time, incrementing on each retry

X-Ev-Delivery changes between attempts; the envelope id does not. Deduplicate on the envelope id, and use X-Ev-Delivery for logging and for spotting that you are being re-sent something you already processed.

evctl listen and evctl trigger send the identical three headers locally, so a handler developed against the CLI sees exactly what production sends. See CLI.

Event types

Event Trigger data shape
verify.completed Single-email validation finished Full result object (same as GET /api/v1/verify/{id})
batch.completed Batch job finished Summary object with download URLs (see below)
test POST /api/v1/webhooks/test { "message": "This is a test event." }

Single-email payload (verify.completed)

data is the full result object described in the API reference.

Batch payload (batch.completed)

data is a summary with pre-signed download URLs:

{
  "job_id":           "7a4e2c1b-...",
  "status":           "complete",
  "total_rows":        1000,
  "unique_addresses":  987,
  "deliverable_count": 612,
  "invalid_count":     193,
  "catch_all_count":   89,
  "unknown_count":     106,
  "download": {
    "csv_url":    "https://storage.example.com/results/...?X-Amz-Expires=...",
    "json_url":   "https://storage.example.com/results/...?X-Amz-Expires=...",
    "expires_at": "2025-06-17T14:02:11.432Z"
  }
}

The download.csv_url and download.json_url pre-signed URLs are valid for 7 days from job completion. After expiry, call GET /api/v1/verify/batch/{id} to obtain fresh URLs.

The four outcome counts (and unique_addresses) are nullable. A count we do not hold for a job is reported as null rather than as 0 — treat null as "not available", never as "none". deliverable_count has no stored column of its own: it is the remainder unique_addresses − unknown_count − invalid_count − catch_all_count, so it is null whenever any of those is. This matches the batch.completed frame on the event stream exactly.

The signing secret

Your account signing secret signs every delivery. It looks like whsec_ followed by 64 hex characters, and it is revealed — and rotatable — in Dashboard → API → Webhooks.

Pin that one secret in your handler. You do not need to store a secret per request.

A per-request callback_secret is still supported: when you supply one on POST /api/v1/verify or POST /api/v1/verify/batch, that job's delivery is signed with it instead of the account secret. It is an override for callers who want per-job isolation, not the default — and because it is returned only once and never retrievable, pinning the account secret is the easier path.

For local development, evctl listen prints a whsec_ session secret and signs the events it forwards with it, so the same verification code runs unchanged.

Rotating the secret

Rotate mints a new current secret and keeps the previous one valid for 24 hours. During that window every delivery is signed with both, carrying two v1= values — so a consumer that is halfway through a rollout verifies with either secret and nothing breaks.

Once the window closes, only the new secret verifies and a single v1= is emitted again.

Rotating again while an overlap is still live is refused with a 409, and the dashboard disables the button until the window closes. There is exactly one "previous secret" slot: a second rotation would evict a secret your endpoint was promised another 20-odd hours of, silently breaking every consumer still on it. Refusing keeps the promised window true for every rotation the dashboard permits.

Signature verification

Every delivery carries an X-Signature header:

X-Signature: t=1750000000,v1=<64 lowercase hex chars>

or, mid-rotation:

X-Signature: t=1750000000,v1=<current secret's hex>,v1=<previous secret's hex>

To verify:

  1. Parse the header into its t value and every v1 value. Ignore fields you do not recognise — a future scheme may add one.
  2. Reject the delivery if t is missing, is not an integer, or is outside the range a timestamp can represent. Range-check it before converting it to a date. t arrives from the network, and in several languages handing a huge value to a date constructor raises rather than returns — turning "is this signature valid?" into an unhandled exception that anyone able to POST to your endpoint can trigger.
  3. Reject the delivery if t is more than 5 minutes from your clock, in either direction. This is the check that stops a captured delivery from being replayed later; a far-future t is as much a forgery signal as a stale one.
  4. Compute HMAC-SHA256(secret, "{t}.{rawBody}") as lowercase hex, using the t token exactly as it was sent.
  5. Compare it in constant time against each v1. Accept if any of them matches.

Always read the raw request body bytes before parsing JSON. Some frameworks silently re-encode the body on parse, which changes the byte sequence and breaks the HMAC signature.

The timestamp is inside the MAC, so it cannot be edited to refresh a captured delivery.

Node.js / TypeScript

import * as crypto from 'crypto';

const TOLERANCE_SECONDS = 5 * 60;

// The representable range of a Unix timestamp. `t` comes from the network, so it is
// range-checked before it is ever treated as a date.
const MIN_UNIX = -62135596800;
const MAX_UNIX = 253402300799;

export function verifyWebhook(
    secret: string,
    rawBody: Buffer | string,
    signatureHeader: string,
    nowSeconds: number = Math.floor(Date.now() / 1000),
): boolean {
    let timestamp: string | null = null;
    const candidates: string[] = [];

    for (const part of signatureHeader.split(',')) {
        const eq = part.indexOf('=');
        if (eq <= 0) continue;
        const name = part.slice(0, eq).trim();
        const value = part.slice(eq + 1).trim();
        if (value.length === 0) continue;
        if (name === 't' && timestamp === null) timestamp = value;
        else if (name === 'v1') candidates.push(value);
    }

    if (timestamp === null || candidates.length === 0) return false;

    const unix = Number(timestamp);
    if (!Number.isSafeInteger(unix)) return false;
    if (unix < MIN_UNIX || unix > MAX_UNIX) return false;
    if (Math.abs(nowSeconds - unix) > TOLERANCE_SECONDS) return false;

    // The MAC covers "{t}.{rawBody}" — the timestamp exactly as it was sent.
    const mac = crypto.createHmac('sha256', secret);
    mac.update(timestamp + '.');
    mac.update(rawBody);
    const expected = Buffer.from(mac.digest('hex'), 'utf8');

    // timingSafeEqual is constant-time but throws on a length mismatch, so screen
    // that first. Accept if ANY v1 matches; no early exit.
    let match = false;
    for (const candidate of candidates) {
        const actual = Buffer.from(candidate, 'utf8');
        if (actual.length === expected.length && crypto.timingSafeEqual(expected, actual)) {
            match = true;
        }
    }
    return match;
}

Python

import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60

# The representable range of a Unix timestamp. `t` comes from the network, so it is
# range-checked before it is ever handed to a date constructor.
MIN_UNIX = -62135596800
MAX_UNIX = 253402300799


def verify_webhook(secret, raw_body, signature_header, now_seconds=None):
    timestamp = None
    candidates = []

    for part in signature_header.split(","):
        name, _, value = part.partition("=")
        name, value = name.strip(), value.strip()
        if not name or not value:
            continue
        if name == "t" and timestamp is None:
            timestamp = value
        elif name == "v1":
            candidates.append(value)

    if timestamp is None or not candidates:
        return False

    try:
        unix = int(timestamp)
    except ValueError:
        return False
    if not MIN_UNIX <= unix <= MAX_UNIX:
        return False

    now = int(time.time()) if now_seconds is None else now_seconds
    if abs(now - unix) > TOLERANCE_SECONDS:
        return False

    # The MAC covers "{t}.{raw_body}" — the timestamp exactly as it was sent.
    signed = timestamp.encode("utf-8") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()

    # compare_digest is constant-time. Accept if ANY v1 matches; no early exit.
    match = False
    for candidate in candidates:
        match |= hmac.compare_digest(expected, candidate)
    return match

PHP

<?php

const EV_TOLERANCE_SECONDS = 300;

// The representable range of a Unix timestamp. `t` comes from the network, so it is
// range-checked before it is ever treated as a date.
const EV_MIN_UNIX = -62135596800;
const EV_MAX_UNIX = 253402300799;

function verifyWebhook(
    string $secret,
    string $rawBody,
    string $signatureHeader,
    ?int $nowSeconds = null
): bool {
    $timestamp  = null;
    $candidates = [];

    foreach (explode(',', $signatureHeader) as $part) {
        $eq = strpos($part, '=');
        if ($eq === false || $eq === 0) {
            continue;
        }
        $name  = trim(substr($part, 0, $eq));
        $value = trim(substr($part, $eq + 1));
        if ($value === '') {
            continue;
        }
        if ($name === 't' && $timestamp === null) {
            $timestamp = $value;
        } elseif ($name === 'v1') {
            $candidates[] = $value;
        }
    }

    if ($timestamp === null || $candidates === []) {
        return false;
    }

    // A cast alone is not enough: (int) on non-numeric text silently yields 0, and on an
    // over-large number it saturates. Validate the digits, then range-check.
    if (preg_match('/^-?\d+$/', $timestamp) !== 1) {
        return false;
    }
    $unix = (int) $timestamp;
    if ($unix < EV_MIN_UNIX || $unix > EV_MAX_UNIX) {
        return false;
    }

    $now = $nowSeconds ?? time();
    if (abs($now - $unix) > EV_TOLERANCE_SECONDS) {
        return false;
    }

    // The MAC covers "{t}.{rawBody}" — the timestamp exactly as it was sent.
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    // hash_equals is constant-time. Accept if ANY v1 matches; no early exit.
    $match = false;
    foreach ($candidates as $candidate) {
        $match = hash_equals($expected, $candidate) || $match;
    }
    return $match;
}

C#

using System.Security.Cryptography;
using System.Text;

public static class EmailValidatorWebhooks
{
    static readonly TimeSpan Tolerance = TimeSpan.FromMinutes(5);

    // DateTimeOffset.MinValue / MaxValue in Unix seconds. FromUnixTimeSeconds THROWS outside
    // this range, and `t` comes from the network — so it is range-checked before conversion.
    const long MinUnixSeconds = -62_135_596_800;
    const long MaxUnixSeconds =  253_402_300_799;

    public static bool VerifyWebhook(
        string secret, string rawBody, string signatureHeader, DateTimeOffset? now = null)
    {
        string? timestamp = null;
        var candidates = new List<string>();

        foreach (var part in signatureHeader.Split(','))
        {
            var eq = part.IndexOf('=');
            if (eq <= 0) continue;

            var name  = part[..eq].Trim();
            var value = part[(eq + 1)..].Trim();
            if (value.Length == 0) continue;

            if (name == "t" && timestamp is null) timestamp = value;
            else if (name == "v1") candidates.Add(value);
        }

        if (timestamp is null || candidates.Count == 0) return false;
        if (!long.TryParse(timestamp, out var unix)) return false;
        if (unix is < MinUnixSeconds or > MaxUnixSeconds) return false;

        var age = (now ?? DateTimeOffset.UtcNow) - DateTimeOffset.FromUnixTimeSeconds(unix);
        if (age.Duration() > Tolerance) return false;

        // The MAC covers "{t}.{rawBody}" — the timestamp exactly as it was sent.
        var expected = Encoding.UTF8.GetBytes(Convert.ToHexStringLower(HMACSHA256.HashData(
            Encoding.UTF8.GetBytes(secret),
            Encoding.UTF8.GetBytes(timestamp + "." + rawBody))));

        // FixedTimeEquals is constant-time. Accept if ANY v1 matches; no early exit.
        var match = false;
        foreach (var candidate in candidates)
            match |= CryptographicOperations.FixedTimeEquals(
                expected, Encoding.UTF8.GetBytes(candidate));

        return match;
    }
}

Retries

A delivery is retried until your endpoint answers 2xx, or until the budget runs out:

Up to 22 attempts spread over about 3.1 days.

The delay doubles from 30 seconds and then flattens at six hours:

Attempt Delay before it
1 — (immediate)
2 30s
3 1m
4 2m
5 4m
6 8m
7 16m
8 32m
9 1h 04m
10 2h 08m
11 4h 16m
12–22 6h each

Every delay is jittered by ±20%, so a provider-wide outage that fails many deliveries at once does not retry them all in lockstep and bury your endpoint under the whole backlog the moment it recovers. Six hours is a true ceiling — jitter is absorbed at the cap, never past it.

An endpoint that is simply down is not treated as broken. Returning the same 500 on every attempt keeps the full budget: a consistently failing consumer is an outage, not a poisoned payload, and it gets the whole three days to come back.

While retries remain, the delivery's row in Dashboard → API → Webhooks shows its next retry time, so a pending delivery is visibly pending rather than inferred from attempt numbers that stopped moving.

Giving up: Abandoned

When the budget is exhausted, the delivery is abandoned — and you are told:

  • The delivery history gains an explicit Abandoned row, a terminal state distinct from a failed attempt.
  • An in-app notification is raised on the owning account. Webhooks are their own notification category, so you can turn it off in Settings if you would rather not hear about it.

Before this, the only signal that a delivery had been given up on was that its attempt numbers stopped increasing.

Because event.id is stable and the polling endpoints stay authoritative, an abandoned delivery is recoverable: fetch the resource by id, or list it with GET /api/v1/verify / GET /api/v1/verify/batch.

Seeing why an attempt failed

Each attempt records the status your endpoint returned and a capped snippet of its response body, both visible under Inspect in the delivery log. A 500 with {"error":"db timeout"} in the body tells you far more than a bare 500.

Idempotency

event.id is a stable evt_<sha256hex> derived deterministically from the resource ID and event name. Retries for the same event carry the same id. Deduplicate on event.id in your handler — you may receive the same event more than once.

Test deliveries

Use POST /api/v1/webhooks/test to send a synthetic test event to any URL:

POST /api/v1/webhooks/test
X-Api-Key: ev_your_key
Content-Type: application/json

{ "callback_url": "https://yourapp.example.com/hooks/email" }

202 Accepted:

{
  "status": "queued",
  "event_id": "evt_3a4b5c6d..."
}

event_id is the envelope id the delivery will carry, so you can match the event your endpoint receives — and its row in the delivery log — to the call that produced it.

The delivery arrives within seconds, carries event: "test" with data: { "message": "This is a test event." }, and is signed with the same account signing secret as production events. So the secret you pin for production verifies a test event unchanged — you can validate your signature handling before running a single real validation.

To do the same thing entirely offline, with no live job and no public URL, use evctl trigger test --to http://localhost:3000/webhook. See CLI.