Overview
KYC (Know Your Customer) webhook events allow you to receive real-time notifications when a KYC verification status changes. Gu1 automatically sends HTTP POST requests to your configured webhook endpoint whenever a validation status updates, enabling you to automate customer onboarding workflows and maintain compliance.
Why Use KYC Webhooks?
Real-Time Updates Get instant notifications when verification status changes
Efficient No need to poll the API repeatedly
Automated Workflows Automatically update user accounts based on verification results
Better UX Notify customers immediately after verification
Available Events
Gu1 sends webhooks for the following KYC validation events:
Event Type Description When Triggered kyc.validation_createdValidation session created When you create a new KYC validation kyc.validation_in_progressCustomer started verification Customer is actively completing verification (filling out form) kyc.validation_in_reviewVerification under review Verification completed, requires manual review from compliance team kyc.validation_approvedVerification approved Identity successfully verified kyc.validation_rejectedVerification rejected Identity verification failed kyc.validation_abandonedCustomer abandoned process Customer left without completing kyc.validation_expiredValidation session expired Session expired (typically after 7 days) kyc.validation_cancelledValidation cancelled Validation manually cancelled by organization
Event Payload Structure
All KYC webhook events follow this standard structure:
{
"event" : "kyc.validation_approved" ,
"timestamp" : "2025-01-07T11:00:00Z" ,
"organizationId" : "org-123" ,
"payload" : {
"validationId" : "550e8400-e29b-41d4-a716-446655440000" ,
"entityId" : "123e4567-e89b-12d3-a456-426614174000" ,
"entity" : {
"id" : "123e4567-e89b-12d3-a456-426614174000" ,
"externalId" : "customer_xyz789" ,
"name" : "John Doe" ,
"type" : "person"
},
"status" : "approved"
// ... event-specific fields
}
}
Common Payload Fields
The event type (e.g., kyc.validation_approved)
ISO 8601 timestamp when the event occurred
The KYC validation ID in Gu1
The entity (person) ID being verified
Entity information including your externalId for easy lookup
Current validation status: pending, in_progress, in_review, approved, rejected, abandoned, expired, cancelled
Event-Specific Payloads
kyc.validation_created
Sent when a new KYC validation is created.
{
"event" : "kyc.validation_created" ,
"timestamp" : "2025-01-07T10:30:00Z" ,
"organizationId" : "org-123" ,
"payload" : {
"validationId" : "550e8400-e29b-41d4-a716-446655440000" ,
"entityId" : "123e4567-e89b-12d3-a456-426614174000" ,
"entity" : {
"id" : "123e4567-e89b-12d3-a456-426614174000" ,
"externalId" : "customer_xyz789" ,
"name" : "John Doe" ,
"type" : "person"
},
"status" : "pending" ,
"validationUrl" : "https://kyc.gu1.io/validate/abc123" ,
"expiresAt" : "2025-01-14T10:30:00Z"
}
}
Use case : Send the validation URL to your customer via email or SMS.
kyc.validation_in_progress
Sent when a customer starts the verification process.
{
"event" : "kyc.validation_in_progress" ,
"timestamp" : "2025-01-07T10:35:00Z" ,
"organizationId" : "org-123" ,
"payload" : {
"validationId" : "550e8400-e29b-41d4-a716-446655440000" ,
"entityId" : "123e4567-e89b-12d3-a456-426614174000" ,
"entity" : {
"id" : "123e4567-e89b-12d3-a456-426614174000" ,
"externalId" : "customer_xyz789" ,
"name" : "John Doe" ,
"type" : "person"
},
"status" : "in_progress" ,
"startedAt" : "2025-01-07T10:35:00Z"
}
}
Use case : Update UI to show βVerification in progressβ status.
kyc.validation_in_review
Sent when a customer completes verification and requires manual review from the compliance team.
{
"event" : "kyc.validation_in_review" ,
"timestamp" : "2025-01-07T10:50:00Z" ,
"organizationId" : "org-123" ,
"payload" : {
"validationId" : "550e8400-e29b-41d4-a716-446655440000" ,
"entityId" : "123e4567-e89b-12d3-a456-426614174000" ,
"entity" : {
"id" : "123e4567-e89b-12d3-a456-426614174000" ,
"externalId" : "customer_xyz789" ,
"name" : "John Doe" ,
"type" : "person"
},
"status" : "in_review" ,
"completedAt" : "2025-01-07T10:50:00Z"
}
}
Use case : Notify compliance team for manual review. Update UI to show βUnder review by compliance teamβ.
kyc.validation_approved
Sent when verification is successfully completed.
{
"event" : "kyc.validation_approved" ,
"timestamp" : "2025-01-07T11:00:00Z" ,
"organizationId" : "org-123" ,
"payload" : {
"validationId" : "550e8400-e29b-41d4-a716-446655440000" ,
"entityId" : "123e4567-e89b-12d3-a456-426614174000" ,
"entity" : {
"id" : "123e4567-e89b-12d3-a456-426614174000" ,
"externalId" : "customer_xyz789" ,
"name" : "John Doe" ,
"type" : "person"
},
"status" : "approved" ,
"verifiedAt" : "2025-01-07T11:00:00Z" ,
"extractedData" : {
"firstName" : "John" ,
"lastName" : "Doe" ,
"dateOfBirth" : "1990-05-20" ,
"nationality" : "US" ,
"documentNumber" : "AB123456" ,
"documentType" : "passport" ,
"documentExpiry" : "2030-05-20" ,
"address" : {
"street" : "123 Main St" ,
"city" : "New York" ,
"state" : "NY" ,
"postalCode" : "10001" ,
"country" : "US"
}
},
"verifiedFields" : [
"firstName" ,
"lastName" ,
"dateOfBirth" ,
"nationality" ,
"documentNumber"
],
"warnings" : [],
"decision" : {
"id_verification" : "pass" ,
"face_match" : "pass" ,
"liveness" : "pass" ,
"document_authenticity" : "pass"
}
}
}
Additional Fields :
verifiedAt: Timestamp when verification was approved
extractedData: Personal information extracted from the document
verifiedFields: Array of fields that were successfully verified
warnings: Array of any warnings detected during verification (empty if approved)
decision: Verification results for each check
Use case : Activate customer account and grant access to services.
kyc.validation_rejected
Sent when verification fails.
{
"event" : "kyc.validation_rejected" ,
"timestamp" : "2025-01-07T11:00:00Z" ,
"organizationId" : "org-123" ,
"payload" : {
"validationId" : "550e8400-e29b-41d4-a716-446655440000" ,
"entityId" : "123e4567-e89b-12d3-a456-426614174000" ,
"entity" : {
"id" : "123e4567-e89b-12d3-a456-426614174000" ,
"externalId" : "customer_xyz789" ,
"name" : "John Doe" ,
"type" : "person"
},
"status" : "rejected" ,
"verifiedAt" : "2025-01-07T11:00:00Z" ,
"extractedData" : {},
"verifiedFields" : [],
"warnings" : [
"Document authenticity check failed" ,
"Face match confidence low" ,
"Liveness detection failed"
],
"decision" : {
"id_verification" : "fail" ,
"face_match" : "fail" ,
"liveness" : "fail" ,
"document_authenticity" : "fail"
},
"rejectionReason" : "Document authenticity could not be verified"
}
}
Additional Fields :
warnings: Array of reasons why verification failed
rejectionReason: Primary reason for rejection
Use case : Notify customer that verification failed and provide guidance on next steps.
kyc.validation_abandoned
Sent when a customer starts but doesnβt complete the verification.
{
"event" : "kyc.validation_abandoned" ,
"timestamp" : "2025-01-07T10:45:00Z" ,
"organizationId" : "org-123" ,
"payload" : {
"validationId" : "550e8400-e29b-41d4-a716-446655440000" ,
"entityId" : "123e4567-e89b-12d3-a456-426614174000" ,
"entity" : {
"id" : "123e4567-e89b-12d3-a456-426614174000" ,
"externalId" : "customer_xyz789" ,
"name" : "John Doe" ,
"type" : "person"
},
"status" : "abandoned" ,
"abandonedAt" : "2025-01-07T10:45:00Z" ,
"lastStep" : "document_upload"
}
}
Additional Fields :
lastStep: The last step the customer completed before abandoning
Use case : Send a reminder email to complete verification.
kyc.validation_expired
Sent when a validation session expires without completion.
{
"event" : "kyc.validation_expired" ,
"timestamp" : "2025-01-14T10:30:00Z" ,
"organizationId" : "org-123" ,
"payload" : {
"validationId" : "550e8400-e29b-41d4-a716-446655440000" ,
"entityId" : "123e4567-e89b-12d3-a456-426614174000" ,
"entity" : {
"id" : "123e4567-e89b-12d3-a456-426614174000" ,
"externalId" : "customer_xyz789" ,
"name" : "John Doe" ,
"type" : "person"
},
"status" : "expired" ,
"expiresAt" : "2025-01-14T10:30:00Z" ,
"createdAt" : "2025-01-07T10:30:00Z"
}
}
Use case : Clean up pending validations and notify customer to restart verification.
kyc.validation_cancelled
Sent when a validation is manually cancelled by the organization.
{
"event" : "kyc.validation_cancelled" ,
"timestamp" : "2025-01-07T12:00:00Z" ,
"organizationId" : "org-123" ,
"payload" : {
"validationId" : "550e8400-e29b-41d4-a716-446655440000" ,
"entityId" : "123e4567-e89b-12d3-a456-426614174000" ,
"entity" : {
"id" : "123e4567-e89b-12d3-a456-426614174000" ,
"externalId" : "customer_xyz789" ,
"name" : "John Doe" ,
"type" : "person"
},
"status" : "cancelled" ,
"cancelledAt" : "2025-01-07T12:00:00Z" ,
"cancelledBy" : "user_123"
}
}
Use case : Notify customer that validation was cancelled. Clean up associated resources and update status in your system.
Code Examples
Node.js - Handling KYC Events
const express = require ( 'express' );
const crypto = require ( 'crypto' );
const app = express ();
app . use ( express . json ({
verify : ( req , res , buf ) => {
req . rawBody = buf . toString ( 'utf8' );
}
}));
app . post ( '/webhooks/kyc' , async ( req , res ) => {
try {
// Verify webhook signature (see security guide)
const signature = req . headers [ 'x-webhook-signature' ];
const webhookSecret = process . env . GU1_WEBHOOK_SECRET ;
if ( ! verifySignature ( req . rawBody , signature , webhookSecret )) {
console . error ( 'Invalid webhook signature' );
return res . status ( 401 ). json ({ error: 'Invalid signature' });
}
// Extract webhook data
const { event , timestamp , organizationId , payload } = req . body ;
console . log ( 'Received KYC webhook:' , {
event ,
validationId: payload . validationId ,
status: payload . status
});
// Process the webhook based on event type
await handleKycWebhook ( event , payload );
// Return 200 to acknowledge receipt
res . status ( 200 ). json ({
success: true ,
message: 'Webhook received'
});
} catch ( error ) {
console . error ( 'Webhook error:' , error );
res . status ( 500 ). json ({
error: error . message
});
}
});
async function handleKycWebhook ( event , data ) {
const { validationId , entityId , entity , status } = data ;
// Update your database with validation ID from Gu1
await db . updateEntity ( entity . externalId , {
kycValidationId: validationId ,
kycStatus: status ,
lastUpdated: new Date ()
});
// Perform actions based on event type
switch ( event ) {
case 'kyc.validation_created' :
console . log ( 'KYC validation created for:' , entity . name );
// Send validation URL to customer
await sendValidationEmail ( entity , data . validationUrl );
break ;
case 'kyc.validation_in_progress' :
await notifyCustomer ( entity . externalId , 'verification-started' );
break ;
case 'kyc.validation_in_review' :
// Notify compliance team
await notifyComplianceTeam ( entity , validationId );
await notifyCustomer ( entity . externalId , 'verification-in-review' );
break ;
case 'kyc.validation_approved' :
// Extract verified data
const { extractedData , verifiedFields } = data ;
await db . updateEntity ( entity . externalId , {
verifiedData: extractedData ,
verifiedFields: verifiedFields ,
verifiedAt: data . verifiedAt ,
isVerified: true
});
// Activate customer account
await activateCustomerAccount ( entity . externalId );
await notifyCustomer ( entity . externalId , 'verification-approved' );
break ;
case 'kyc.validation_rejected' :
await db . updateEntity ( entity . externalId , {
isVerified: false ,
rejectionReasons: data . warnings ,
rejectionReason: data . rejectionReason
});
await notifyCustomer ( entity . externalId , 'verification-rejected' , {
reasons: data . warnings
});
break ;
case 'kyc.validation_abandoned' :
await notifyCustomer ( entity . externalId , 'verification-incomplete' , {
lastStep: data . lastStep
});
break ;
case 'kyc.validation_expired' :
await notifyCustomer ( entity . externalId , 'verification-expired' );
// Clean up expired validation
await db . deleteValidation ( validationId );
break ;
case 'kyc.validation_cancelled' :
await notifyCustomer ( entity . externalId , 'verification-cancelled' );
// Clean up associated resources
await db . deleteValidation ( validationId );
break ;
}
}
function verifySignature ( rawBody , signature , secret ) {
const expectedSignature = crypto
. createHmac ( 'sha256' , secret )
. update ( rawBody )
. digest ( 'hex' );
return signature === expectedSignature ;
}
app . listen ( 3000 );
Python - Handling KYC Events
from flask import Flask, request, jsonify
import hmac
import hashlib
import logging
app = Flask( __name__ )
@app.route ( '/webhooks/kyc' , methods = [ 'POST' ])
def kyc_webhook ():
try :
# Get signature from header
signature = request.headers.get( 'X-Webhook-Signature' )
webhook_secret = os.getenv( 'GU1_WEBHOOK_SECRET' )
# Get raw body for signature verification
raw_body = request.get_data( as_text = True )
# Verify signature
if not verify_signature(raw_body, signature, webhook_secret):
logging.error( 'Invalid webhook signature' )
return jsonify({ 'error' : 'Invalid signature' }), 401
# Parse payload
payload = request.json
event = payload.get( 'event' )
data = payload.get( 'payload' )
logging.info( f 'Received KYC webhook: { event } ' )
# Process the webhook
handle_kyc_webhook(event, data)
return jsonify({
'success' : True ,
'message' : 'Webhook received'
}), 200
except Exception as e:
logging.error( f 'Webhook error: { e } ' )
return jsonify({
'error' : str (e)
}), 500
def verify_signature ( raw_body , signature , secret ):
"""Verify HMAC SHA-256 signature"""
expected_signature = hmac.new(
secret.encode( 'utf-8' ),
raw_body.encode( 'utf-8' ),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected_signature)
def handle_kyc_webhook ( event , data ):
validation_id = data[ 'validationId' ]
entity_id = data[ 'entityId' ]
entity = data[ 'entity' ]
status = data[ 'status' ]
# Update database
db.update_entity(
external_id = entity[ 'externalId' ],
kyc_validation_id = validation_id,
kyc_status = status,
last_updated = datetime.now()
)
# Handle different events
if event == 'kyc.validation_created' :
send_validation_email(entity, data[ 'validationUrl' ])
elif event == 'kyc.validation_in_review' :
notify_compliance_team(entity, validation_id)
notify_customer(entity[ 'externalId' ], 'verification-in-review' )
elif event == 'kyc.validation_approved' :
db.update_entity(
external_id = entity[ 'externalId' ],
verified_data = data.get( 'extractedData' ),
verified_fields = data.get( 'verifiedFields' ),
verified_at = data.get( 'verifiedAt' ),
is_verified = True
)
activate_customer_account(entity[ 'externalId' ])
notify_customer(entity[ 'externalId' ], 'verification-approved' )
elif event == 'kyc.validation_rejected' :
db.update_entity(
external_id = entity[ 'externalId' ],
is_verified = False ,
rejection_reasons = data.get( 'warnings' )
)
notify_customer(entity[ 'externalId' ], 'verification-rejected' )
elif event == 'kyc.validation_abandoned' :
notify_customer(entity[ 'externalId' ], 'verification-incomplete' )
elif event == 'kyc.validation_expired' :
notify_customer(entity[ 'externalId' ], 'verification-expired' )
db.delete_validation(validation_id)
elif event == 'kyc.validation_cancelled' :
notify_customer(entity[ 'externalId' ], 'verification-cancelled' )
db.delete_validation(validation_id)
if __name__ == '__main__' :
app.run( port = 3000 )
Use Cases
Use Case 1: Automatic Account Activation
Automatically activate customer accounts when KYC is approved:
async function handleKycApproved ( data ) {
const { entity , extractedData , verifiedFields } = data ;
// Update customer record
await db . customers . update ({
where: { externalId: entity . externalId },
data: {
kycStatus: 'approved' ,
verifiedAt: new Date (),
verifiedData: extractedData ,
isActive: true
}
});
// Enable login
await auth . enableUser ( entity . externalId );
// Grant access to services
await services . grantAccess ( entity . externalId , [ 'trading' , 'withdrawal' ]);
// Send welcome email
await email . send ({
to: extractedData . email || entity . attributes ?. email ,
subject: 'Welcome! Your account is verified' ,
template: 'kyc-approved' ,
data: {
name: entity . name ,
verifiedAt: new Date (). toISOString ()
}
});
// Log event
await audit . log ({
action: 'kyc_approved' ,
entityId: entity . id ,
timestamp: new Date ()
});
}
Use Case 2: Compliance Monitoring
Track KYC rejections for compliance review:
async function handleKycRejected ( data ) {
const { entity , warnings , rejectionReason } = data ;
// Log rejection
await compliance . logRejection ({
entityId: entity . id ,
externalId: entity . externalId ,
reasons: warnings ,
primaryReason: rejectionReason ,
timestamp: new Date ()
});
// Alert compliance team if multiple rejections
const rejectCount = await db . rejections . count ({
where: { entityId: entity . id }
});
if ( rejectCount >= 3 ) {
await slack . send ({
channel: '#compliance-alerts' ,
message: `π¨ Multiple KYC rejections for ${ entity . name } ( ${ entity . externalId } )` ,
fields: {
'Entity ID' : entity . id ,
'Rejection Count' : rejectCount ,
'Latest Reason' : rejectionReason
}
});
}
// Notify customer with guidance
await email . send ({
to: entity . attributes ?. email ,
subject: 'Verification unsuccessful' ,
template: 'kyc-rejected' ,
data: {
name: entity . name ,
reasons: warnings ,
supportUrl: 'https://support.example.com/kyc'
}
});
}
Use Case 3: Abandoned Verification Recovery
Send reminders to customers who abandon verification:
async function handleKycAbandoned ( data ) {
const { entity , validationId , lastStep } = data ;
// Schedule reminder email
await queue . scheduleEmail ({
to: entity . attributes ?. email ,
subject: 'Complete your verification' ,
template: 'kyc-reminder' ,
data: {
name: entity . name ,
lastStep ,
validationUrl: `https://kyc.gu1.io/validate/ ${ validationId } `
},
sendAt: new Date ( Date . now () + 24 * 60 * 60 * 1000 ) // Send in 24 hours
});
// Track abandonment
await analytics . track ({
event: 'kyc_abandoned' ,
entityId: entity . id ,
lastStep ,
timestamp: new Date ()
});
}
Best Practices
Use entity.externalId for Lookup
The webhook includes entity.externalId which is the ID you provided when creating the entity. Use this to look up the customer in your database. const customer = await db . findCustomer ({
externalId: data . entity . externalId
});
Store the validationId from Gu1 in your database. This allows you to query validation details later if needed. await db . updateCustomer ( customer . id , {
kycValidationId: data . validationId ,
kycStatus: data . status
});
You might receive the same webhook multiple times. Use the validationId to ensure you process each event only once. async function handleWebhook ( webhook ) {
const alreadyProcessed = await db . checkWebhookProcessed (
webhook . payload . validationId ,
webhook . event
);
if ( alreadyProcessed ) {
return ; // Skip duplicate
}
// Process webhook
await processValidation ( webhook . payload );
// Mark as processed
await db . markWebhookProcessed (
webhook . payload . validationId ,
webhook . event
);
}
Always return a 200 status code as quickly as possible to acknowledge receipt. Process the webhook asynchronously if needed. app . post ( '/webhooks/kyc' , async ( req , res ) => {
// Acknowledge immediately
res . status ( 200 ). send ( 'OK' );
// Process asynchronously
processWebhook ( req . body ). catch ( console . error );
});
Always verify the X-Webhook-Signature header to ensure the webhook is authentic. See the security guide for details. const signature = req . headers [ 'x-webhook-signature' ];
if ( ! verifySignature ( req . rawBody , signature , secret )) {
return res . status ( 401 ). json ({ error: 'Invalid signature' });
}
If processing fails, log the error but still return 200 to prevent retries. Store failed webhooks for manual review. try {
await processWebhook ( payload );
} catch ( error ) {
await db . saveFailedWebhook ({
payload ,
error: error . message ,
receivedAt: new Date ()
});
// Still return 200
res . status ( 200 ). json ({ success: false });
}
Troubleshooting
Check these items:
Webhook URL is publicly accessible via HTTPS
Webhook is configured and enabled in dashboard
Subscribed to correct KYC event types
Endpoint returns 200 status code within 30 seconds
Check server logs for incoming requests
Signature Verification Failing
Common causes:
Using wrong secret (check dashboard for current secret)
Verifying signature on parsed JSON instead of raw body
Secret not properly saved after webhook creation
Encoding issues (ensure UTF-8)
See the security guide for proper implementation.
Receiving Duplicate Webhooks
This is normal behavior. Webhooks may be sent multiple times due to network issues, timeouts, or retries. Always implement idempotency using the webhook validationId and event type.
Next Steps