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_secret returned in the POST /api/v1/verify or POST /api/v1/verify/batch response when you supplied a callback_url, or
  • the whsec_ value printed by evctl listen during 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.