← All docs

Webhooks

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

EventWhen fired
idova.kyc.request.createdIntegrator creates a new KYC request
idova.kyc.portal_link_readyPortal link generated + emailed to applicant
idova.kyc.request.submittedApplicant finishes the portal flow
idova.kyc.request.processingScreenings + decision engine running
idova.kyc.request.approvedAuto-approved by engine OR manually approved
idova.kyc.request.rejectedAuto-rejected by engine OR manually rejected
idova.kyc.request.awaiting_reviewEngine flagged for manual review
idova.kyc.request.expiredPortal link TTL hit with no submission
idova.kyc.request.cancelledAdmin cancelled before submission
idova.kyc.request.failedProcessing errored
idova.aml_check.completedA standalone AML check finished screening
idova.aml_check.match_foundAn AML check returned potential_match (a sanctions / PEP / adverse-media hit)
idova.aml_check.monitoring_alertA 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 2xx within 10 seconds
  • You handle duplicate deliveries idempotently (same eventId can arrive twice)
  • Your code tolerates fields you don't recognize (forward-compatibility)