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.
Request your server receives
Section titled “Request your server receives”| Property | Value |
|---|---|
| Method | POST |
Content-Type | application/json |
| Body | UTF-8 JSON: same fields as a synchronous verify response, plus job_id |
| Redirects | Not followed (redirect: error on the client) |
| Signature | If 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.
Dashboard delivery logs
Section titled “Dashboard delivery logs”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:
| Tier | Log retention |
|---|---|
| Free | ~24 hours |
| Builder | ~30 days |
| Scale | ~30 days |
Retry policy
Section titled “Retry policy”The verify worker makes up to 3 attempts per webhook delivery (initial try + two retries).
| Condition | Retries? |
|---|---|
Connection / TLS failure, timeout, or other transport error (httpStatus === null) | Yes, if attempts remain |
| HTTP 5xx from your server | Yes, 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):
- Base delay:
min(1000 × 2^(attemptNumber - 1), 30_000)milliseconds, whereattemptNumberis 1 for the delay after the first failure, 2 after the second. - Jitter: a random integer
0..599ms 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.)
URL rules (summary)
Section titled “URL rules (summary)”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.
Security — HMAC signature
Section titled “Security — HMAC signature”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:
- Read the raw request body as a string or bytes before parsing JSON.
- Parse the header as
sha256=+ hex; reject missing or malformed headers. - Compare expected vs provided MAC using a constant-time comparison to reduce timing leaks.
Verification code snippets
Section titled “Verification code snippets”Headers are usually case-insensitive; examples use lowercase x-truval-signature.
Node.js
Section titled “Node.js”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')}Python
Section titled “Python”import hmacimport hashlib
secret = os.environ["TRUVAL_WEBHOOK_SECRET"].encode("utf-8")raw_body: bytes = request.get_data() # before request.jsonheader = 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 JSONheader = request.get_header('HTTP_X_TRUVAL_SIGNATURE') # Rack / Railsraise '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.bytesizeraise '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');}IP allow-listing
Section titled “IP allow-listing”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_secretand verifyX-Truval-Signatureover 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.
Public Repositories
Section titled “Public Repositories”- SDK: truval-dev/truval-sdk
- MCP Server: truval-dev/truval-mcp-server
- Agent Skills: truval-dev/truval-skills