Webhooks
When you supply a callback_url, the API delivers the result to your HTTPS endpoint as an
HTTP POST with a JSON body. Webhooks are delivered at-least-once; use the stable event.id
as your idempotency key.
The GET polling endpoints remain authoritative — use them as a fallback if a delivery is
missed or your service is temporarily unavailable.
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 |
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": 612,
"undeliverable": 193,
"risky": 89,
"unknown": 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.
Signature verification
Every webhook delivery includes an X-Signature header:
X-Signature: sha256=<lowercase hex>
The signature is HMAC-SHA256(secret, raw_request_body) where secret is:
callback_secretreturned in thePOST /api/v1/verifyorPOST /api/v1/verify/batchresponse when you supplied acallback_url, or- the
whsec_value printed byevctl listenduring local development (see CLI).
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.
Node.js / TypeScript
import * as crypto from 'crypto';
function verifyWebhook(
secret: string,
rawBody: Buffer | string,
signatureHeader: string,
): boolean {
const prefix = 'sha256=';
if (!signatureHeader.startsWith(prefix)) return false;
const mac = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const expected = prefix + mac;
// Constant-time comparison prevents timing attacks.
return crypto.timingSafeEqual(
Buffer.from(expected, 'utf8'),
Buffer.from(signatureHeader, 'utf8'),
);
}
Python
import hashlib
import hmac
def verify_webhook(secret: str, raw_body: bytes, signature_header: str) -> bool:
prefix = "sha256="
if not signature_header.startswith(prefix):
return False
mac = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
expected = prefix + mac
# hmac.compare_digest is constant-time and prevents timing attacks.
return hmac.compare_digest(expected, signature_header)
PHP
function verifyWebhook(string $secret, string $rawBody, string $signatureHeader): bool
{
$prefix = 'sha256=';
if (strncmp($signatureHeader, $prefix, strlen($prefix)) !== 0) {
return false;
}
$mac = hash_hmac('sha256', $rawBody, $secret);
$expected = $prefix . $mac;
// hash_equals is constant-time and prevents timing attacks.
return hash_equals($expected, $signatureHeader);
}
C#
using System.Security.Cryptography;
using System.Text;
bool VerifyWebhook(string secret, string rawBody, string signatureHeader)
{
if (!signatureHeader.StartsWith("sha256=", StringComparison.OrdinalIgnoreCase))
return false;
var key = Encoding.UTF8.GetBytes(secret);
var body = Encoding.UTF8.GetBytes(rawBody);
var mac = HMACSHA256.HashData(key, body);
var expected = "sha256=" + Convert.ToHexString(mac).ToLowerInvariant();
// Constant-time comparison prevents timing attacks.
var expectedBytes = Encoding.UTF8.GetBytes(expected);
var actualBytes = Encoding.UTF8.GetBytes(signatureHeader);
return CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes);
}
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" }
The delivery arrives within seconds and is signed with the same X-Signature header as
production events. Useful for confirming your handler is reachable before running real
validations.