Webhooks
Idova emits webhooks at every meaningful lifecycle event. You register an endpoint, subscribe to event types, and verify the signature on each delivery.
Configure an endpoint
Developers → Webhooks → Add endpoint. You'll be shown a signing secret once — copy it immediately.
Each endpoint is bound to one environment (sandbox or live) and a list of event types (empty = all).
Delivery shape
POST https://your-endpoint.example.com/webhooks/idova HTTP/1.1
Content-Type: application/json
User-Agent: Idova-Webhook/1.0
X-Idova-Event: idova.kyc.request.approved
X-Idova-Delivery: whd_01KP…
X-Idova-Signature: v1,t=1729000000,sig=5e8d4f3c2b1a…
Body:
{
"eventId": "kev_01KP4FAB3XYZ9Q4HCWRJK2FV7HG",
"eventType": "idova.kyc.request.approved",
"resourceId": "kyc_01KP4FAA2YZ9Q4HCWRJK2FV7HG",
"publicId": "kyc_A5X7Q2P4NJ",
"applicantId": "app_01KP47W4G6RBS3B5M8CHKE0EAM",
"applicantPublicId": "app_B8K2R9T4NM",
"status": "approved",
"decision": "approved",
"decisionReason": "All checks clear; documents and selfie present",
"riskScore": 0.10,
"organizationId": "org_01KP39FW63SYC67DBADG80Z33R",
"environment": "sandbox",
"actorType": "portal",
"createdAt": "2026-04-15T12:05:00Z"
}
Respond with any 2xx within 10 seconds to acknowledge delivery. Anything else is treated as failed and retried.
Event types
| Event | When fired |
|---|---|
idova.kyc.request.created | Integrator creates a new KYC request |
idova.kyc.portal_link_ready | Portal link generated + emailed to applicant |
idova.kyc.request.submitted | Applicant finishes the portal flow |
idova.kyc.request.processing | Screenings + decision engine running |
idova.kyc.request.approved | Auto-approved by engine OR manually approved |
idova.kyc.request.rejected | Auto-rejected by engine OR manually rejected |
idova.kyc.request.awaiting_review | Engine flagged for manual review |
idova.kyc.request.expired | Portal link TTL hit with no submission |
idova.kyc.request.cancelled | Admin cancelled before submission |
idova.kyc.request.failed | Processing errored |
idova.aml_check.completed | A standalone AML check finished screening |
idova.aml_check.match_found | An AML check returned potential_match (a sanctions / PEP / adverse-media hit) |
idova.aml_check.monitoring_alert | A monitored AML check flipped status on re-screen (e.g. a newly-added sanctions hit) |
Signature verification
Every webhook is signed with HMAC-SHA256 using the endpoint's signing secret.
Header: X-Idova-Signature: v1,t=<timestamp>,sig=<hex>
Payload to sign: <timestamp> + "." + <raw-body>
Verify the timestamp is within 5 minutes of now (prevents replay attacks), then recompute the HMAC and compare with timingSafeEqual.
Node.js
const crypto = require('crypto')
function verify(req, secret) {
const header = req.get('X-Idova-Signature')
const [versionPart, tPart, sigPart] = header.split(',')
if (versionPart !== 'v1') throw new Error('unsupported signature version')
const timestamp = tPart.slice(2)
const signature = sigPart.slice(4)
// Age check
const ageMs = Date.now() - parseInt(timestamp) * 1000
if (Math.abs(ageMs) > 5 * 60 * 1000) throw new Error('stale signature')
// Recompute
const payload = `${timestamp}.${req.rawBody}`
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex')
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
throw new Error('bad signature')
}
}
Python
import hmac, hashlib, time
def verify(headers, raw_body, secret):
parts = dict(p.split('=', 1) for p in headers['X-Idova-Signature'].split(',')[1:])
timestamp = parts['t']
signature = parts['sig']
if abs(time.time() - int(timestamp)) > 300:
raise ValueError('stale signature')
expected = hmac.new(
secret.encode(), f'{timestamp}.{raw_body.decode()}'.encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise ValueError('bad signature')
PHP
function verify_idova_webhook(string $header, string $rawBody, string $secret): void {
[$ver, $tPart, $sigPart] = explode(',', $header);
if ($ver !== 'v1') throw new Exception('unsupported version');
$timestamp = substr($tPart, 2);
$signature = substr($sigPart, 4);
if (abs(time() - (int)$timestamp) > 300) throw new Exception('stale');
$expected = hash_hmac('sha256', "$timestamp.$rawBody", $secret);
if (!hash_equals($expected, $signature)) throw new Exception('bad signature');
}
Go
func VerifyIdova(header, rawBody string, secret []byte) error {
parts := strings.Split(header, ",")
if parts[0] != "v1" {
return errors.New("unsupported version")
}
ts := strings.TrimPrefix(parts[1], "t=")
sig := strings.TrimPrefix(parts[2], "sig=")
tsInt, _ := strconv.ParseInt(ts, 10, 64)
if math.Abs(float64(time.Now().Unix()-tsInt)) > 300 {
return errors.New("stale signature")
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(ts + "." + rawBody))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sig), []byte(expected)) {
return errors.New("bad signature")
}
return nil
}
Java
public void verifyIdovaWebhook(String header, String rawBody, byte[] secret) throws Exception {
String[] parts = header.split(",");
if (!"v1".equals(parts[0])) throw new SecurityException("unsupported version");
String ts = parts[1].substring(2);
String sig = parts[2].substring(4);
long tsLong = Long.parseLong(ts);
if (Math.abs(Instant.now().getEpochSecond() - tsLong) > 300)
throw new SecurityException("stale signature");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
byte[] hash = mac.doFinal((ts + "." + rawBody).getBytes(StandardCharsets.UTF_8));
String expected = HexFormat.of().formatHex(hash);
if (!MessageDigest.isEqual(expected.getBytes(), sig.getBytes()))
throw new SecurityException("bad signature");
}
Ruby
require 'openssl'
def verify_idova(header, raw_body, secret)
parts = header.split(',')
raise 'unsupported version' unless parts[0] == 'v1'
timestamp = parts[1][2..]
signature = parts[2][4..]
raise 'stale signature' if (Time.now.to_i - timestamp.to_i).abs > 300
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{raw_body}")
raise 'bad signature' unless Rack::Utils.secure_compare(signature, expected)
end
Retries
Failed deliveries retry with exponential backoff: 1s → 16s → 2m → 15m → 2h. After 5 failures the endpoint is auto-disabled and you'll receive a notification email.
Use the Developers → Webhooks → Deliveries page to inspect payloads, see response codes, and replay failed deliveries.
Testing
Use the Send test event button on any endpoint. Idova fires a synthetic idova.webhook.test payload with the same signature shape — great for verifying your signature code before running a real KYC flow.
Going live
Before switching from sandbox to live:
- Endpoint is reachable over HTTPS (HTTP only allowed in sandbox)
- Signature verification works against your sandbox secret
- You return
2xxwithin 10 seconds - You handle duplicate deliveries idempotently (same
eventIdcan arrive twice) - Your code tolerates fields you don't recognize (forward-compatibility)
