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:
- Parse the header into its
tvalue and everyv1value. Ignore fields you do not recognise — a future scheme may add one. - Reject the delivery if
tis missing, is not an integer, or is outside the range a timestamp can represent. Range-check it before converting it to a date.tarrives 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. - Reject the delivery if
tis 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-futuretis as much a forgery signal as a stale one. - Compute
HMAC-SHA256(secret, "{t}.{rawBody}")as lowercase hex, using thettoken exactly as it was sent. - 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.