Skip to Content
KnowaKYC
Tenant API (KYC)

Webhooks

The KnowaKYC webhook system delivers verification event notifications to your URL in real time. KYC events (sessions, verification, steps, documents, links, etc.) and KYB events (business verification) are both supported. You select the event types when configuring the endpoint in the admin panel.

Key properties:

  • KYC events (sessions, verification, steps, documents, links, etc.) and KYB events (see KYB webhooks)
  • HMAC-SHA256 signing for delivery integrity
  • Automatic retries with exponential backoff (up to 5 attempts)
  • SHA-256 idempotency keys to prevent duplicate processing
  • Delivery log and metrics in the admin panel

You configure webhooks in the admin panel: enter your server URL, select event types (or all types), and save. KnowaKYC sends signed POST requests for every event.

Configuration

In the Webhooks section of the admin panel, add your server URL, select the event types (or all types), and save. When the endpoint is created, the system generates a secret (with the whsec_ prefix) for signature verification. The secret is shown once — store it in a secure vault.

Event types

KYC events are grouped into the categories below. KYB events (the KYB events category in the admin panel) are documented in KYB webhooks.

Sessions (5 events)

EventDescription
session.createdA new verification session was created
session.startedThe session was started (the user began verification)
session.completedThe session is complete — a terminal event, sent only after the final decision (automatic or by a manual review operator)
session.expiredThe session expired
session.cancelledThe session was cancelled

Verification (6 events)

EventDescription
verification.approvedVerification approved
verification.declinedVerification declined
verification.needs_reviewVerification routed to manual review by an operator (not a terminal state)
verification.ocr.correctedOCR data was corrected by an operator
verification.resubmit_requestedA reviewer requested step resubmission (Manual Review Resubmit)
verification.resubmit_fulfilledThe applicant resubmitted the requested step
verification.enrichment.updatedSafe asynchronous enrichment coverage and decision summary updated

Event order for sessions with manual review

If a session needs manual review, events are sent in the following order:

  1. verification.needs_review — sent right after automated checks finish with a "needs review" decision, or when an operator manually sends a terminal session back to review. session.completed is not sent at this point — the session is not finalised yet.
  2. verification.approved or verification.declined — after the operator's final decision.
  3. session.completed — sent immediately after verification.approved / verification.declined. The two events are sent as one chain.

If you only subscribe to session.completed, you receive that event exactly once — when the session is finalised. You do not see the intermediate "in review" state; subscribe to verification.needs_review for that.

For sessions without manual review (automatic approval/rejection), the event order is: verification.approved / verification.declined → session.completed.

session.completed is final for the customer-facing webhook cycle. Internally, a reviewer can later send a completed session back to review or request a resubmit; those operator actions emit verification.needs_review or resubmit events and do not replay the original session.completed until a new final decision is made.

Steps (5 events)

EventDescription
step.startedA verification step started
step.completedA verification step completed
step.failedThe step finished with an error
step.skippedThe step was skipped because an upstream dependency did not pass
step.retriedThe step is retried

Documents (1 event)

EventDescription
document.uploadedA document was uploaded

AML screening (3 events)

EventDescription
aml.screening_completedAML screening completed
aml.hit_foundA match against an AML watchlist was found
aml.clearedAML screening completed with no matches
EventDescription
link.createdThe verification link was created
link.consumedThe link was consumed
link.expiredThe link expired
link.revokedThe link was revoked

Request format and signature

KnowaKYC sends a POST request with a JSON body to your URL. Every request carries the following headers:

HeaderDescription
Content-Typeapplication/json
X-Onekyc-SignatureSignature in the form t=<timestamp>,v1=<signature>
X-Onekyc-Event-TypeEvent type (for example, session.created)
X-Onekyc-Event-IdEvent UUID
X-Onekyc-TimestampUnix timestamp of the send
User-AgentKnowaKYC-Webhook/1.0

HTTP header names are case-insensitive (RFC 7230 §3.2). On the receiving side, use case-insensitive comparison (most HTTP frameworks do this automatically).

Verifying the signature

To verify a webhook's authenticity:

  1. Extract t (timestamp) and v1 (signature) from the X-Onekyc-Signature header.
  2. Build the signed string: {timestamp}.{raw_body} (the numeric value from t, a literal dot, then the raw request body).
  3. Compute HMAC-SHA256 using your webhook secret (whsec_...).
  4. Compare the result with v1 from the header (use a constant-time comparison).
  5. Confirm that the timestamp is within the allowed window (5 minutes is recommended).

Replay protection: reject requests where |now - X-Onekyc-Timestamp| > 300 seconds (5 minutes). Additionally, use the X-Onekyc-Event-Id header (UUID) for deduplication — store processed event IDs and ignore duplicate deliveries.

When the secret is rotated, the header also carries v2=<signature> — the signature with the new key. Both keys are valid during the grace period.

Signature verification examples

Node.js:

const crypto = require('crypto');

function verifyWebhookSignature(rawBody, signature, secret) {
  // Extract timestamp and signature from the header
  const parts = signature.split(',');
  const timestamp = parts.find(p => p.startsWith('t=')).split('=')[1];
  const v1Signature = parts.find(p => p.startsWith('v1=')).split('=')[1];

  // Verify the timestamp is fresh (≤ 5 minutes old)
  const maxAgeSeconds = 300; // 5 minutes
  const currentTime = Math.floor(Date.now() / 1000);
  if (currentTime - parseInt(timestamp) > maxAgeSeconds) {
    throw new Error('Webhook timestamp is too old');
  }

  // Build the signed string: timestamp.raw_body
  const signedPayload = `${timestamp}.${rawBody}`;

  // Compute HMAC-SHA256
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Constant-time compare
  if (!crypto.timingSafeEqual(
    Buffer.from(expectedSignature),
    Buffer.from(v1Signature)
  )) {
    throw new Error('Invalid webhook signature');
  }

  return true;
}

Go:

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "errors"
    "fmt"
    "strconv"
    "strings"
    "time"
)

func verifyWebhookSignature(rawBody []byte, signature, secret string) error {
    // Extract timestamp and signature from the header
    parts := strings.Split(signature, ",")
    var timestamp, v1Signature string

    for _, part := range parts {
        if strings.HasPrefix(part, "t=") {
            timestamp = strings.TrimPrefix(part, "t=")
        } else if strings.HasPrefix(part, "v1=") {
            v1Signature = strings.TrimPrefix(part, "v1=")
        }
    }

    if timestamp == "" || v1Signature == "" {
        return errors.New("invalid signature format")
    }

    // Verify the timestamp is fresh (≤ 5 minutes old)
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return fmt.Errorf("invalid timestamp: %w", err)
    }

    maxAge := int64(300) // 5 minutes
    if time.Now().Unix()-ts > maxAge {
        return errors.New("webhook timestamp is too old")
    }

    // Build the signed string: timestamp.raw_body
    signedPayload := fmt.Sprintf("%s.%s", timestamp, rawBody)

    // Compute HMAC-SHA256
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signedPayload))
    expectedSignature := hex.EncodeToString(mac.Sum(nil))

    // Constant-time compare
    if subtle.ConstantTimeCompare(
        []byte(expectedSignature),
        []byte(v1Signature),
    ) != 1 {
        return errors.New("invalid webhook signature")
    }

    return nil
}

Payload format

Every event uses a single envelope format:

{
  "id": "evt_uuid",
  "type": "session.created",
  "api_version": "2026-02-01",
  "created_at": "2026-02-01T12:00:00Z",
  "tenant_id": "uuid",
  "environment": "production",
  "idempotency_key": "unique_key",
  "data": { ... }
}

The data field's contents depend on the event type (session, verification, step, document, link, etc.).

Retry policy

If delivery fails (the response is not 2xx), the system performs up to 5 total delivery attempts with exponential backoff (base delay 5 seconds, doubled on each retry, maximum interval 24 hours, with ±20% random jitter to spread the load). After all attempts are exhausted, the event is moved to the dead-letter queue (DLQ).

To acknowledge successful delivery, your server must respond with HTTP 2xx.

Idempotency key (idempotency_key): generated from a SHA-256 hash of the logical event identity and intended for receiver-side deduplication. The value is a 32-character hexadecimal string.

Example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2

Some session decision payloads may include optional enrichment fields when source data is available: applicant.details, applicant.localized, media_files, decision_reasons, and custom_steps. These fields are additive; webhook consumers should ignore fields they do not use.

Event payload examples

Session events (session.*)

Every session event contains a flat data structure with session info. Terminal KYC events such as session.completed also include the enriched applicant and verifications blocks. If the flow contains phone or email OTP steps, the verified values are included as data.applicant.phone and data.applicant.email.

The top-level result field is the source of truth for the final session decision. The verifications array may include historical retry attempts for a check. Each verification item includes attempt_number and is_current when available; use is_current: true as the current per-check result. For example, if document liveness failed on attempt 1 and passed on attempt 2, the terminal approved payload may contain both rows, with only the attempt 2 row marked as current.

{
  "id": "evt_550e8400-e29b-41d4-a716-446655440000",
  "type": "session.completed",
  "api_version": "2026-02-01",
  "created_at": "2026-02-11T12:00:00Z",
  "tenant_id": "550e8400-e29b-41d4-a716-446655440001",
  "environment": "production",
  "idempotency_key": "example-idempotency-key",
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "flow_id": "550e8400-e29b-41d4-a716-446655440003",
    "flow_version": 1,
    "external_id": "user_12345",
    "status": "completed",
    "created_at": "2026-02-11T10:00:00Z",
    "started_at": "2026-02-11T10:05:00Z",
    "completed_at": "2026-02-11T12:00:00Z",
    "expires_at": "2026-02-12T10:00:00Z",
    "steps_completed": 5,
    "result": "approved",
    "applicant": {
      "first_name": "John",
      "last_name": "Doe",
      "full_name": "JOHN DOE",
      "document_number": "AB1234567",
      "document_type": "passport",
      "date_of_birth": "1990-01-15",
      "phone": "+12125550100",
      "email": "john.doe@example.com"
    },
    "verifications": [
      {
        "type": "document_ocr",
        "step_id": "550e8400-e29b-41d4-a716-446655440010",
        "attempt_number": 1,
        "is_current": true,
        "status": "passed",
        "score": 0.95,
        "ocr": {
          "document_number": "AB1234567",
          "full_name": "JOHN DOE",
          "first_name": "JOHN",
          "last_name": "DOE",
          "date_of_birth": "1990-01-15",
          "issue_date": "2020-01-15",
          "expiry_date": "2030-01-15",
          "issuing_country": "USA",
          "document_type": "passport",
          "localized": {
            "English": {
              "Full Name": "JOHN DOE"
            }
          }
        }
      },
      {
        "type": "document_liveness",
        "step_id": "550e8400-e29b-41d4-a716-446655440010",
        "attempt_number": 2,
        "is_current": true,
        "status": "passed",
        "score": 0.96,
        "doc_liveness": {
          "is_real": true,
          "score": 0.96
        }
      }
    ]
  }
}

The full session field schema is in the API reference (Swagger / OpenAPI).

Verification events (verification.*)

Verification events contain full session info, applicant data extracted from the document, and the results of every check.

verification.approved:

{
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "flow_id": "550e8400-e29b-41d4-a716-446655440003",
    "flow_version": 1,
    "external_id": "user_12345",
    "status": "completed",
    "result": "approved",
    "created_at": "2026-02-11T10:00:00Z",
    "started_at": "2026-02-11T10:01:00Z",
    "completed_at": "2026-02-11T12:00:00Z",
    "expires_at": "2026-02-12T10:00:00Z",
    "steps_completed": 5,
    "applicant": {
      "first_name": "John",
      "last_name": "Doe",
      "full_name": "John Doe",
      "middle_name": "Michael",
      "gender": "M",
      "nationality": "USA",
      "issuing_country": "USA",
      "document_number": "AB1234567",
      "document_type": "passport",
      "date_of_birth": "1990-01-15",
      "date_of_expiry": "2030-01-15",
      "date_of_issue": "2020-01-15",
      "phone": "+12125550100",
      "email": "john.doe@example.com"
    },
    "verifications": [
      {
        "type": "document_ocr",
        "step_id": "550e8400-e29b-41d4-a716-446655440010",
        "attempt_number": 1,
        "is_current": true,
        "status": "passed",
        "score": 0.95,
        "ocr": {
          "document_number": "AB1234567",
          "full_name": "JOHN DOE",
          "first_name": "JOHN",
          "last_name": "DOE",
          "date_of_birth": "1990-01-15",
          "nationality": "USA",
          "gender": "M",
          "issue_date": "2020-01-15",
          "expiry_date": "2030-01-15",
          "issuing_country": "USA",
          "issuing_authority": "U.S. Department of State",
          "document_type": "passport",
          "mrz_type": "TD3"
        }
      },
      {
        "type": "document_liveness",
        "step_id": "550e8400-e29b-41d4-a716-446655440010",
        "attempt_number": 2,
        "is_current": true,
        "status": "passed",
        "score": 0.96,
        "doc_liveness": {
          "is_real": true,
          "score": 0.96,
          "status": "Ok"
        }
      },
      {
        "type": "face_liveness",
        "step_id": "550e8400-e29b-41d4-a716-446655440011",
        "attempt_number": 2,
        "is_current": true,
        "status": "passed",
        "score": 0.97,
        "face_liveness": {
          "is_live": true,
          "score": 0.97,
          "faces": [
            {
              "x1": 80.0, "y1": 60.0, "x2": 220.0, "y2": 260.0,
              "yaw": 2.3, "pitch": -1.1, "roll": 0.5,
              "left_eye_closed": 0.03, "right_eye_closed": 0.04,
              "state": { "is_front": true, "quality": "High", "luminance": "Normal", "result": "Real" }
            }
          ]
        }
      },
      {
        "type": "face_match",
        "status": "passed",
        "score": 0.94,
        "face_match": {
          "score": 0.94,
          "is_match": true,
          "threshold": 0.7,
          "faces1": [
            {
              "x1": 80.0, "y1": 60.0, "x2": 220.0, "y2": 260.0,
              "yaw": 2.3, "pitch": -1.1, "roll": 0.5,
              "left_eye_closed": 0.03, "right_eye_closed": 0.04,
              "state": { "is_front": true, "quality": "High", "luminance": "Normal", "result": "Real" }
            }
          ],
          "faces2": [
            {
              "x1": 80.0, "y1": 60.0, "x2": 220.0, "y2": 260.0,
              "yaw": 2.3, "pitch": -1.1, "roll": 0.5,
              "left_eye_closed": 0.03, "right_eye_closed": 0.04,
              "state": { "is_front": true, "quality": "High", "luminance": "Normal", "result": "Real" }
            }
          ]
        }
      },
      {
        "type": "aml_screening",
        "status": "passed",
        "score": 0.9
      },
      {
        "type": "proof_of_address",
        "status": "passed",
        "score": 0.93,
        "proof_of_address": {
          "decision": "approved",
          "route": "gps_document",
          "reason_codes": ["gps_country_match", "document_address_match"],
          "applicant_message_code": "poa_approved",
          "reviewer_summary": "GPS, IP, and document evidence match the configured address policy.",
          "address_country": "USA",
          "address_city": "New York",
          "address_postal_code": "10001",
          "gps_accuracy_meters": 18.5,
          "ip_country": "USA",
          "ip_expected": "USA",
          "fingerprint_state": "consistent",
          "document_available": true,
          "document_category": "utility",
          "document_subtype": "utility_bill",
          "document_country": "USA"
        }
      }
    ]
  }
}

verification.declined:

{
  "data": {
    "session_id": "...",
    "result": "declined",
    "applicant": { "first_name": "Jane", "last_name": "Doe", "document_number": "CD9876543", "document_type": "passport" },
    "verifications": [
      {
        "type": "document_ocr",
        "status": "failed",
        "score": 0.95,
        "rejection_reasons": ["document_expired"],
        "ocr": {
          "document_number": "CD9876543",
          "first_name": "JANE",
          "last_name": "DOE",
          "date_of_birth": "1990-05-15",
          "expiry_date": "2020-01-01",
          "issuing_country": "USA",
          "document_type": "passport"
        }
      }
    ]
  }
}

verification.needs_review:

{
  "data": {
    "session_id": "...",
    "result": "review",
    "applicant": { "first_name": "Alice", "last_name": "Smith", "document_number": "EF5551234", "document_type": "passport" },
    "verifications": [
      {
        "type": "document_ocr",
        "status": "passed",
        "score": 0.91,
        "ocr": {
          "document_number": "EF5551234",
          "first_name": "ALICE",
          "last_name": "SMITH",
          "date_of_birth": "1985-03-20",
          "expiry_date": "2030-03-19",
          "issuing_country": "USA",
          "document_type": "passport"
        }
      },
      {
        "type": "face_match",
        "status": "review",
        "score": 0.76,
        "face_match": {
          "score": 0.76,
          "is_match": false,
          "threshold": 0.7,
          "faces1": [
            {
              "x1": 80.0, "y1": 60.0, "x2": 220.0, "y2": 260.0,
              "yaw": 2.3, "pitch": -1.1, "roll": 0.5,
              "left_eye_closed": 0.03, "right_eye_closed": 0.04,
              "state": { "is_front": true, "quality": "High", "luminance": "Normal", "result": "Real" }
            }
          ],
          "faces2": [
            {
              "x1": 80.0, "y1": 60.0, "x2": 220.0, "y2": 260.0,
              "yaw": 2.3, "pitch": -1.1, "roll": 0.5,
              "left_eye_closed": 0.03, "right_eye_closed": 0.04,
              "state": { "is_front": true, "quality": "High", "luminance": "Normal", "result": "Real" }
            }
          ]
        }
      },
      {
        "type": "proof_of_address",
        "status": "review",
        "score": 0.62,
        "proof_of_address": {
          "decision": "needs_review",
          "route": "document",
          "reason_codes": ["document_review_required"],
          "applicant_message_code": "poa_review_required",
          "reviewer_summary": "Document address was extracted but needs operator confirmation.",
          "address_country": "USA",
          "address_city": "Austin",
          "address_postal_code": "78701",
          "ip_country": "USA",
          "document_available": true,
          "document_category": "bank",
          "document_subtype": "bank_statement",
          "document_ocr_confidence_bucket": "medium",
          "document_ocr_degraded": false,
          "document_country": "USA"
        }
      }
    ]
  }
}

The full field schema with types, formats, and descriptions is in the API reference (Swagger / OpenAPI).

Step events (step.*)

Step events carry information about the execution of a single verification step:

{
  "type": "step.completed",
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "step_id": "550e8400-e29b-41d4-a716-446655440010",
    "step_key": "document_capture",
    "step_type": "document",
    "step_name": "Upload Document",
    "execution_id": "550e8400-e29b-41d4-a716-446655440020",
    "attempt_number": 1,
    "status": "completed",
    "output": {
      "document_type": "passport",
      "storage_key": "documents/tenant_123/session_456/document.jpg"
    }
  }
}

Extra fields for step.failed:

FieldDescription
error_codeError code
error_messageError description
max_attemptsMaximum allowed number of attempts (lets you tell whether this was the last attempt)

Extra fields for step.retried:

FieldDescription
max_attemptsMaximum allowed number of attempts
reasonPresent only for applicant-initiated revisions: "applicant_revision". The applicant went back and changed a completed step; data you stored from the previous step.completed for this step is superseded.

Document events (document.*)

document.uploaded:

{
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "step_id": "step_document_capture",
    "storage_key": "documents/tenant_123/session_456/document.jpg"
  }
}

Intermediate OCR / liveness / face-match / AML-screening / proof-of-address events are NOT delivered as separate webhook events. Their results are included in the verification.approved / verification.declined / verification.needs_review payload (the verifications array). Proof-of-address payloads include the redacted summary only; raw GPS coordinates and storage keys are not sent. See the examples above.

AML screening events (aml.*)

aml.screening_completed:

{
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "match_found": false,
    "risk_score": 15.2,
    "decision": "approve",
    "match_severity": ""
  }
}

aml.hit_found:

{
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "risk_score": 85.5,
    "decision": "review",
    "match_severity": "high"
  }
}

aml.cleared:

{
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "risk_score": 5.0
  }
}

Enrichment lifecycle and coverage

KYC terminal events include data.enrichment when an applicant enrichment record exists. Its scope is applicant: this is the latest applicant aggregate, not proof that enrichment ran for this particular session. The summary includes status, observation/completion timestamps, endpoint call_statuses, merged decision, risk score and match count when known, and optional passport/bankruptcy findings.

{
  "enrichment": {
    "scope": "applicant",
    "summary": {
      "status": "completed",
      "call_statuses": {"passport_check": "completed", "fssp": "skipped_no_inn"},
      "merged_decision": "approve",
      "merged_risk_score": 0,
      "match_count": 0,
      "passport_invalid": false
    }
  }
}

Absent findings are unknown. A skipped or failed endpoint is not a negative check. Known zero scores/counts and known false findings remain explicit. Raw vendor responses and identifiers such as INN, SNILS and passport numbers are not part of this summary.

Enrichment can finish after a terminal event. Subscribe to verification.enrichment.updated for subsequent completed or failed updates, correlated by session_id, applicant_id and attempt. The update uses the same applicant-scoped summary. An unavailable terminal snapshot is reported through enrichment_errors; it must not be treated as a clean result.

Terminal identity and evidence

Automatic and manually reviewed KYC terminal/decision events use the same evidence projection. applicant.full_name remains the existing display value. full_name_latin is emitted only for an observed Latin-script name. full_name_native and full_name_original retain original document spelling when available; no transliteration is invented. name_source identifies the document result, step and attempt; name_evidence retains reconciliation alternatives. localized preserves provider field labels and adds canonical aliases. Missing names remain absent for flows without identity evidence.

Terminal snapshots also include available verifications, decision_reasons, custom_steps, document_collections, media_files, safe risk_signals and device summaries. Applicant identity uses current attempts. Verification entries retain is_current so consumers can distinguish superseded evidence. Optional read failures appear as stable codes in enrichment_errors rather than silently implying an empty successful check.

Every link event carries the full verification-link record:

{
  "type": "link.consumed",
  "data": {
    "link": {
      "id": "550e8400-e29b-41d4-a716-446655440003",
      "tenant_id": "550e8400-e29b-41d4-a716-446655440000",
      "flow_id": "550e8400-e29b-41d4-a716-446655440002",
      "external_user_id": "user_12345",
      "external_reference": "ref_abc123",
      "status": "consumed",
      "session_id": "550e8400-e29b-41d4-a716-446655440001",
      "created_at": "2026-02-11T10:00:00Z",
      "activated_at": "2026-02-11T10:05:00Z",
      "consumed_at": "2026-02-11T12:00:00Z",
      "expires_at": "2026-02-14T10:00:00Z",
      "locale": "en"
    }
  }
}

The full link field schema is in the API reference (Swagger / OpenAPI).

OCR events (verification.ocr.*)

verification.ocr.corrected — an operator edited OCR results in the admin panel:

{
  "type": "verification.ocr.corrected",
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "applicant_id": "550e8400-e29b-41d4-a716-446655440005",
    "verification_id": "550e8400-e29b-41d4-a716-446655440099",
    "fields": {
      "first_name": "JANE",
      "last_name": "DOE",
      "date_of_birth": "1990-05-15"
    }
  }
}

Manual Review Resubmit events (verification.resubmit_*)

Events for step resubmission requests by manual review operators.

request_id identifies one resubmit attempt and correlates its fulfillment. scope is step (also the legacy default) or flow for a full KYB restart. step_id is empty for flow. A nonblank own client_message permits an empty reason_code; both are sent when supplied. Previous attempts remain in history. Internal notes are never sent.

Manual rejection events (verification.declined, session.completed, and KYB decision events) carry the current decision_id, optional decision_reason_codes, and optional public client_message from that same persisted decision. A comment-only rejection has no invented reason code. Previous comments are never substituted for the current decision, and internal_notes remain private.

verification.resubmit_requested — a reviewer asked the applicant to resubmit a step:

{
  "type": "verification.resubmit_requested",
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "flow_id": "550e8400-e29b-41d4-a716-446655440003",
    "external_id": "user_12345",
    "applicant_id": "550e8400-e29b-41d4-a716-446655440005",
    "step_id": "550e8400-e29b-41d4-a716-446655440010",
    "step_type": "document_capture",
    "reason_code": "blurry",
    "client_message": "Photo is blurry, please re-upload",
    "attempt_number": 1,
    "requested_by_id": "550e8400-e29b-41d4-a716-446655440100",
    "requested_by_name": "Reviewer Name",
    "expires_at": "2026-05-04T12:00:00Z"
  }
}

Allowed reason_code values: illegible, blurry, cropped, glare, wrong_document, expired_document, partial.

verification.resubmit_fulfilled — the applicant resubmitted the requested step:

Fulfillment confirms that a new terminal attempt was recorded for the requested step; it does not mean that the step or verification session passed or was approved. The payload includes the same external_id identity supplied with the request.

{
  "type": "verification.resubmit_fulfilled",
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "flow_id": "550e8400-e29b-41d4-a716-446655440003",
    "external_id": "user_12345",
    "applicant_id": "550e8400-e29b-41d4-a716-446655440005",
    "step_id": "550e8400-e29b-41d4-a716-446655440010",
    "reason_code": "blurry",
    "attempt_number": 1,
    "fulfilled_at": "2026-04-27T18:30:00Z"
  }
}

Manual Review Resubmit limits: up to 3 requests per session, 7-day request TTL. If the applicant does not resubmit before the TTL expires, the request is cancelled automatically.

Frequently asked questions

Lost webhook secret

A webhook secret (with the whsec_ prefix) cannot be recovered — it is shown only once when you create the endpoint.

What to do:

  1. Open the KnowaKYC admin panel.
  2. Go to Webhooks.
  3. Click "Rotate secret" next to the endpoint.
  4. Copy the new secret — it is shown only once.
  5. Update the secret in your application configuration.

Note: during rotation, both secrets (old and new) are valid at the same time for a 24-hour grace period. This keeps webhook delivery uninterrupted while you update credentials. The X-Onekyc-Signature header carries both v1=<old_signature> and v2=<new_signature>.

No incoming webhooks

If webhooks stop arriving, run through the following checks:

1. Endpoint status in the admin panel

  • Open the Webhooks section of the admin panel.
  • Check the endpoint status (active / disabled / error).
  • Review the delivery log — it lists response codes and error details.

2. Server reachability

  • The URL must be publicly reachable over HTTPS (HTTP is not supported).
  • Make sure your firewall does not block incoming requests from KnowaKYC.
  • Verify the SSL certificate is valid (self-signed certificates are not supported).

3. Webhook handler code

  • Your endpoint must respond with HTTP 2xx (200, 201, 204) within 30 seconds.
  • If processing takes longer, return 200 OK immediately and process asynchronously.
  • Check your application log for signature validation errors.

4. Event types

  • Confirm that the required event types are enabled in the endpoint settings.
  • If you selected "All events", confirm that events are actually being generated (sessions created, verifications run, etc.).

5. Dead-letter queue

If the system cannot deliver a webhook after 5 attempts (with exponential backoff up to 24 hours), the event is moved to the dead-letter queue. Undelivered events are visible in the admin panel, where you can trigger manual redelivery.

Endpoint testing:

To validate an endpoint, click "Send test webhook" in the admin panel. In the dialog, choose an event type (for example, verification.approved) — the system sends a test payload of that type with a valid HMAC-SHA256 signature. The test event identifier carries the evt_test_ prefix.

Contacting support

If you run into trouble with webhooks, use the following channels:

1. Documentation and examples

2. Technical support

  • Email: legal@knowakyc.com
  • Hours: Mon–Fri, 10:00–19:00 (GMT+6, Almaty)

3. Critical issues

For critical issues blocking production:

  • Send a request to support with "URGENT" in the subject line.
  • Include tenant_id, the endpoint URL, and sample error log entries.
  • A response is provided within 2 hours during business hours.

4. Bug reports and improvement suggestions

If you find a bug in the webhook system or have suggestions:

  • Open a request through the admin panel customer area.
  • Or send the details to legal@knowakyc.com.

For full details on payload format, headers, and retry policy, see the API reference (Swagger / OpenAPI).

Review comment webhooks

New comments on KYC review tasks emit verification.comment.created; new comments on KYB review tasks emit kyb.review.comment.created. Comments created in the tenant dashboard, the platform review console, and the tenant API use the same contract. These events do not change the verification decision or complete the session.

Subscribe to the exact event type for the required scope. Existing * subscriptions also receive these events. Delivery uses the existing webhook signature, environment filtering, retries, and delivery logs. Events are delivered at least once; consumers must deduplicate using the event id or idempotency_key. Arrival order is not guaranteed.

Example event data for KYC:

{
  "scope": "kyc",
  "task_id": "550e8400-e29b-41d4-a716-446655440010",
  "session_id": "550e8400-e29b-41d4-a716-446655440001",
  "flow_id": "550e8400-e29b-41d4-a716-446655440002",
  "external_id": "customer_123",
  "applicant_id": "550e8400-e29b-41d4-a716-446655440099",
  "comment": {
    "id": "550e8400-e29b-41d4-a716-446655440011",
    "content": "Please provide a clearer document photo.",
    "created_at": "2026-10-16T08:00:00.123456Z",
    "author_type": "admin_user"
  }
}

For KYB, scope is kyb and business_id replaces applicant_id when present. Author types are admin_user, superadmin_user, api_key, and system. Employee identifiers and names are not included in the webhook. All task comments are client-visible; decision internal_notes and resubmission client_message are separate fields and do not produce comment events. Only newly created comments generate these events; existing comments are not replayed.

Retrieve comments with GET /tenant/v1/kyc/review-tasks/{task_id}/comments (reviews:read) or GET /tenant/v1/kyb/review-tasks/{task_id}/comments (kyb.reviews:read). Creating a comment uses POST at the same path and the corresponding write scope. The successful creation response adds comment_id and created_at, matching the saved comment and webhook snapshot. Each successful POST creates a new comment; webhook event deduplication does not make repeated creation requests idempotent. After an uncertain request outcome, reconcile using the comments API before resubmitting.

The comment, webhook event, and outbox entry are committed together. Failure to persist the event rolls back comment creation. HTTP delivery happens asynchronously after commit, so a successful comment response is not proof of successful HTTP webhook delivery; use delivery logs.

At the grace deadline, the replacement becomes the primary secret: the header contains only v1=<new_signature>. The old secret no longer signs deliveries, even if background database cleanup has not run yet. A receiver must accept the replacement signature in v2 during grace and in v1 after completion; use constant-time HMAC comparison and retain timestamp/replay checks.

To finish early, confirm that the receiver uses the replacement key, then choose Complete rotation in the console. API clients can call POST /admin/v1/webhooks/endpoints/{id}/rotate/complete with {"rotation_expires_at":"<expiry returned by the endpoint API>"}. A stale expiry returns 409; refresh the endpoint instead of automatically retrying a secret mutation. Completion immediately retires the old secret.

On this page