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)
| Event | Description |
|---|---|
session.created | A new verification session was created |
session.started | The session was started (the user began verification) |
session.completed | The session is complete — a terminal event, sent only after the final decision (automatic or by a manual review operator) |
session.expired | The session expired |
session.cancelled | The session was cancelled |
Verification (6 events)
| Event | Description |
|---|---|
verification.approved | Verification approved |
verification.declined | Verification declined |
verification.needs_review | Verification routed to manual review by an operator (not a terminal state) |
verification.ocr.corrected | OCR data was corrected by an operator |
verification.resubmit_requested | A reviewer requested step resubmission (Manual Review Resubmit) |
verification.resubmit_fulfilled | The applicant resubmitted the requested step |
verification.enrichment.updated | Safe 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:
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.completedis not sent at this point — the session is not finalised yet.verification.approvedorverification.declined— after the operator's final decision.session.completed— sent immediately afterverification.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)
| Event | Description |
|---|---|
step.started | A verification step started |
step.completed | A verification step completed |
step.failed | The step finished with an error |
step.skipped | The step was skipped because an upstream dependency did not pass |
step.retried | The step is retried |
Documents (1 event)
| Event | Description |
|---|---|
document.uploaded | A document was uploaded |
AML screening (3 events)
| Event | Description |
|---|---|
aml.screening_completed | AML screening completed |
aml.hit_found | A match against an AML watchlist was found |
aml.cleared | AML screening completed with no matches |
Verification links (4 events)
| Event | Description |
|---|---|
link.created | The verification link was created |
link.consumed | The link was consumed |
link.expired | The link expired |
link.revoked | The 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:
| Header | Description |
|---|---|
Content-Type | application/json |
X-Onekyc-Signature | Signature in the form t=<timestamp>,v1=<signature> |
X-Onekyc-Event-Type | Event type (for example, session.created) |
X-Onekyc-Event-Id | Event UUID |
X-Onekyc-Timestamp | Unix timestamp of the send |
User-Agent | KnowaKYC-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:
- Extract
t(timestamp) andv1(signature) from theX-Onekyc-Signatureheader. - Build the signed string:
{timestamp}.{raw_body}(the numeric value fromt, a literal dot, then the raw request body). - Compute HMAC-SHA256 using your webhook secret (
whsec_...). - Compare the result with
v1from the header (use a constant-time comparison). - 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:
| Field | Description |
|---|---|
error_code | Error code |
error_message | Error description |
max_attempts | Maximum allowed number of attempts (lets you tell whether this was the last attempt) |
Extra fields for step.retried:
| Field | Description |
|---|---|
max_attempts | Maximum allowed number of attempts |
reason | Present 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_reviewpayload (theverificationsarray). 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.
Verification link events (link.*)
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:
- Open the KnowaKYC admin panel.
- Go to Webhooks.
- Click "Rotate secret" next to the endpoint.
- Copy the new secret — it is shown only once.
- 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 OKimmediately 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
- Re-read this section and the code examples above.
- See the API reference (Swagger / OpenAPI) for the detailed specification.
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.