Skip to content

Webhooks

Email verification supports an optional webhook on POST /v1/email/verify. The API responds immediately with 202 Accepted, a job_id, and status: "pending"; when verification finishes, truval.dev POSTs the full result JSON to your URL. Request fields, payload shape, and SSRF-safe URL rules are documented on the Email verify page (including the sequence diagram).

This reference focuses on delivery mechanics, retries, signature verification (multiple languages), and network security expectations.

PropertyValue
MethodPOST
Content-Typeapplication/json
BodyUTF-8 JSON: same fields as a synchronous verify response, plus job_id
RedirectsNot followed (redirect: error on the client)
SignatureIf you sent webhook_secret in the verify request: header X-Truval-Signature: sha256=<hex> (see Security — HMAC signature)

A delivery is treated as successful when your endpoint returns an HTTP 2xx status.

The developer dashboard (Webhooks tab) shows delivery attempts, HTTP status, and errors. Paid tiers can retry failed deliveries from the dashboard. This guide describes automatic retries from the API worker only.

Retention by plan:

TierLog retention
Free~24 hours
Builder~30 days
Scale~30 days

The verify worker makes up to 3 attempts per webhook delivery (initial try + two retries).

ConditionRetries?
Connection / TLS failure, timeout, or other transport error (httpStatus === null)Yes, if attempts remain
HTTP 5xx from your serverYes, if attempts remain
HTTP 4xx (or any non-2xx that is not 5xx)No — that attempt counts as final failure for that phase

Backoff before the next attempt (after attempt n fails, before attempt n+1):

  1. Base delay: min(1000 × 2^(attemptNumber - 1), 30_000) milliseconds, where attemptNumber is 1 for the delay after the first failure, 2 after the second.
  2. Jitter: a random integer 0..599 ms added to the base.

So the wait after the first failure is about 1.0–1.6 s; after the second, about 2.0–2.6 s. (The formula allows larger bases for hypothetical extra attempts; only three total attempts are used today.)

Callbacks are only sent to URLs that pass validation at request time: HTTPS, hostname (not a raw IP), no userinfo in the URL, not localhost or *.local. Redirects are not followed. See Async webhook — URL rules on the API page for the full rules and residual SSRF considerations.

If you include webhook_secret (minimum 8 characters) in the verify request, each callback includes:

X-Truval-Signature: sha256=<lowercase_hex>

The value is HMAC-SHA256(webhook_secret, raw_body), where raw_body is the exact UTF-8 string of the JSON body (the same octets as in the HTTP entity). Compute the HMAC over the raw bytes, not a re-serialized or parsed-then-stringified object.

Receiver checklist:

  1. Read the raw request body as a string or bytes before parsing JSON.
  2. Parse the header as sha256= + hex; reject missing or malformed headers.
  3. Compare expected vs provided MAC using a constant-time comparison to reduce timing leaks.

Headers are usually case-insensitive; examples use lowercase x-truval-signature.

import { createHmac, timingSafeEqual } from 'node:crypto'
const secret = process.env.TRUVAL_WEBHOOK_SECRET!
const rawBody = /* await readRawBodyString(req) */
const header = req.headers['x-truval-signature']
if (typeof header !== 'string') throw new Error('missing signature')
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')
if (header.length !== expected.length || !timingSafeEqual(Buffer.from(header), Buffer.from(expected))) {
throw new Error('invalid webhook signature')
}
import hmac
import hashlib
secret = os.environ["TRUVAL_WEBHOOK_SECRET"].encode("utf-8")
raw_body: bytes = request.get_data() # before request.json
header = request.headers.get("X-Truval-Signature", "")
if not header.startswith("sha256="):
raise ValueError("missing or invalid signature header")
their_hex = header[7:].encode("ascii")
expected_hex = hmac.new(secret, raw_body, hashlib.sha256).hexdigest().encode("ascii")
if not hmac.compare_digest(their_hex, expected_hex):
raise ValueError("invalid webhook signature")
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"strings"
)
func verifyTruvalSignature(secret []byte, rawBody []byte, header string) bool {
const p = "sha256="
if !strings.HasPrefix(header, p) {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write(rawBody)
want := mac.Sum(nil)
got, err := hex.DecodeString(header[len(p):])
if err != nil || len(got) != len(want) {
return false
}
return subtle.ConstantTimeCompare(got, want) == 1
}
require 'openssl'
secret = ENV.fetch('TRUVAL_WEBHOOK_SECRET')
raw_body = request.body.read # before parsing JSON
header = request.get_header('HTTP_X_TRUVAL_SIGNATURE') # Rack / Rails
raise 'missing signature' unless header&.start_with?('sha256=')
their_hex = header.delete_prefix('sha256=')
expected_raw = OpenSSL::HMAC.digest('SHA256', secret, raw_body)
their_raw = [their_hex].pack('H*')
raise 'invalid webhook signature' unless their_raw.bytesize == expected_raw.bytesize
raise 'invalid webhook signature' unless OpenSSL.fixed_length_secure_compare(their_raw, expected_raw)
$secret = getenv('TRUVAL_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_TRUVAL_SIGNATURE'] ?? '';
if (!str_starts_with($header, 'sha256=')) {
http_response_code(401);
exit('missing signature');
}
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $header)) {
http_response_code(401);
exit('invalid webhook signature');
}

Webhook callbacks are sent from Cloudflare Workers (the same edge platform as api.truval.dev). Truval does not publish a stable list of outbound IP addresses for those callbacks. IP ranges can change; allow-listing by IP is not supported as a primary security control.

Recommended approach:

  • Terminate HTTPS on your endpoint.
  • Use a long, random webhook_secret and verify X-Truval-Signature over the raw body (above).
  • Optionally use a hard-to-guess path or query token in the webhook URL you register (defense in depth).

If your infrastructure requires IP restrictions, prefer a reverse proxy or tunnel you control, or a vendor that offers signature-based webhook verification instead of relying on source IP alone.