Paystack fires customeridentification.success when a customer identity verification (typically BVN in Nigeria) completes successfully, and customeridentification.failed when verification cannot be completed. Your webhook handler should update the customer KYC status in your database, gate features that require verified identity, and provide clear recovery paths when verification fails.
Paystack customer identification events are not automatic. They fire in response to a specific API call you make to verify a customer's identity. The flow works like this:
customeridentification.success or customeridentification.failed.The key point: verification is asynchronous. You cannot get the result from the API call itself. You must handle it through webhooks. If your webhook handler is not set up or is broken, you will never know whether the verification succeeded.
This is especially important if you use dedicated virtual accounts. Paystack requires customer identification before assigning a DVA. The flow is: identify the customer, wait for the customeridentification.success webhook, then create the dedicated account. See handling dedicatedaccount.assign events for the next step.
This article is part of the Paystack webhooks engineering guide.
When identity verification succeeds, Paystack sends a webhook with this structure:
{
"event": "customeridentification.success",
"data": {
"customer_id": 12345678,
"customer_code": "CUS_abc123def456",
"email": "amina@example.com",
"identification": {
"country": "NG",
"type": "bvn",
"value": "123****789"
},
"reason": "Identification was successful"
}
}
Key fields to note:
bvn (Bank Verification Number, Nigeria), bank_account, and sometimes government-issued ID types depending on the country.NG for Nigeria, GH for Ghana).What the payload does not include: the customer's full name, date of birth, or other personal details from the BVN record. If you need those, you would have captured them during your own onboarding flow before triggering verification. The webhook only confirms that the identity check passed.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
When you receive customeridentification.success, update the customer's KYC status in your database and unlock any features that require a verified identity.
async function handleIdentificationSuccess(data) {
const { customer_code, identification, email } = data;
// Update KYC status
const result = await db.query(
'UPDATE customers SET kyc_status = $1, kyc_method = $2, ' +
'kyc_country = $3, kyc_verified_at = NOW() ' +
'WHERE paystack_customer_code = $4 RETURNING id',
['verified', identification.type, identification.country, customer_code]
);
if (result.rows.length === 0) {
// Customer not found in our database. Log and investigate.
console.error(
'KYC success for unknown customer: ' + customer_code
);
return;
}
const customerId = result.rows[0].id;
// If this customer was waiting for a DVA, trigger DVA creation now
const pending = await db.query(
'SELECT id FROM dva_requests WHERE customer_id = $1 AND status = $2',
[customerId, 'awaiting_kyc']
);
if (pending.rows.length > 0) {
await dvaQueue.add('create-dva', {
customerId: customerId,
customerCode: customer_code,
});
await db.query(
'UPDATE dva_requests SET status = $1 WHERE customer_id = $2 AND status = $3',
['kyc_complete', customerId, 'awaiting_kyc']
);
}
// Log the verification for compliance records
await db.query(
'INSERT INTO kyc_audit_log (customer_id, event, method, country, ' +
'occurred_at) VALUES ($1, $2, $3, $4, NOW())',
[customerId, 'verification_success', identification.type, identification.country]
);
}
A few important points about this handler:
Do not store the BVN. The identification.value field is already masked, but even if it were not, storing raw BVN numbers creates a compliance liability. Store the verification status (verified or failed), the method, and the timestamp. That is all you need.
Check for the customer in your database. It is possible (though rare) to receive a verification event for a customer code that does not exist in your system. This can happen if you triggered verification from a different environment, or if there is a data sync issue. Log it and investigate rather than silently ignoring it.
Trigger dependent workflows. If the customer was waiting for KYC to clear before getting a DVA, this is the moment to kick off that process. Chain the workflows through a queue, not inline.
When verification fails, the payload looks like this:
{
"event": "customeridentification.failed",
"data": {
"customer_id": 12345678,
"customer_code": "CUS_abc123def456",
"email": "amina@example.com",
"identification": {
"country": "NG",
"type": "bvn",
"value": "123****789"
},
"reason": "BVN does not match customer details"
}
}
The reason field tells you why verification failed. Common reasons include:
Your handling depends on the reason:
async function handleIdentificationFailed(data) {
const { customer_code, identification, reason, email } = data;
// Update KYC status
await db.query(
'UPDATE customers SET kyc_status = $1, kyc_failure_reason = $2, ' +
'kyc_failed_at = NOW() WHERE paystack_customer_code = $3',
['failed', reason, customer_code]
);
// Log for compliance
await db.query(
'INSERT INTO kyc_audit_log (customer_id, event, method, country, ' +
'reason, occurred_at) VALUES (' +
'(SELECT id FROM customers WHERE paystack_customer_code = $1), ' +
'$2, $3, $4, $5, NOW())',
[customer_code, 'verification_failed', identification.type,
identification.country, reason]
);
// Determine if this is retryable
const isServiceError = reason.toLowerCase().includes('unavailable') ||
reason.toLowerCase().includes('timeout') ||
reason.toLowerCase().includes('service');
if (isServiceError) {
// Schedule an automatic retry after 30 minutes
await retryQueue.add(
'retry-kyc',
{ customerCode: customer_code },
{ delay: 30 * 60 * 1000 }
);
await emailQueue.add('kyc-delay', {
to: email,
message: 'We are still verifying your identity. ' +
'This may take a little longer than usual. ' +
'We will notify you once it is complete.',
});
} else {
// Data mismatch. Customer needs to correct their information.
await emailQueue.add('kyc-failed', {
to: email,
reason: reason,
retryUrl: '/account/verify-identity',
message: 'Your identity verification could not be completed. ' +
'Please check your details and try again.',
});
}
}
The distinction between service errors and data errors is important. A service error means you should retry automatically. A data error means the customer submitted wrong information and needs to correct it. Retrying with the same wrong data will just fail again.
Identity verification webhooks are security-critical. If an attacker can forge a customeridentification.success event to your endpoint, they can bypass your KYC controls. A few things to get right:
Always verify the webhook signature. This applies to all webhooks, but it is especially important for identity events. A forged customeridentification.success could let an unverified customer access features they should not have. See verifying the x-paystack-signature header.
Do not expose failure reasons to the customer verbatim. The reason from Paystack is a technical message. Translate it into a user-friendly message. Showing internal error details can leak information about your verification pipeline.
Log everything for compliance. Regulatory requirements in Nigeria (CBN) and other markets require you to keep records of KYC attempts, both successful and failed. Log the timestamp, method, country, and outcome. Do not log the raw identification value, even if it is masked.
Rate-limit verification retries. If a customer submits wrong details and keeps retrying, they might be trying to brute-force a match. Set a maximum number of verification attempts per customer (three to five is reasonable). After that, require manual review by your compliance team.
async function canRetryVerification(customerCode) {
const result = await db.query(
'SELECT COUNT(*) as attempts FROM kyc_audit_log ' +
'WHERE customer_id = (SELECT id FROM customers ' +
'WHERE paystack_customer_code = $1) ' +
'AND event = $2 AND occurred_at > NOW() - INTERVAL '24 hours'',
[customerCode, 'verification_failed']
);
const attempts = parseInt(result.rows[0].attempts, 10);
return attempts < 5; // Allow up to 5 attempts per 24 hours
}
If you store customer data that is subject to data protection regulations (like NDPR in Nigeria or POPIA in South Africa), make sure your KYC audit log retention policy aligns with those regulations. You may need to keep records for 5 to 7 years, or you may need to delete them upon customer request, depending on the regulation.
The most common reason to use customer identification is as a prerequisite for Paystack Dedicated Virtual Accounts (DVAs). The typical flow is:
customeridentification.success via webhook.dedicatedaccount.assign.success via webhook with the virtual account details.Steps 3 through 5 happen through webhooks. Your code needs to chain these events together without blocking. The recommended approach is to use a state machine in your database:
// After customeridentification.success:
// State: awaiting_kyc -> kyc_complete
// Trigger DVA creation:
// State: kyc_complete -> dva_requested
// After dedicatedaccount.assign.success:
// State: dva_requested -> dva_active
// Database table to track this:
// dva_requests(id, customer_id, status, created_at, updated_at)
// status: awaiting_kyc | kyc_complete | dva_requested | dva_active | failed
Each webhook handler updates the state and triggers the next step if appropriate. This way, if any webhook is missed or delayed, you can see exactly where the process stalled and recover.
For the DVA webhook handler itself, see handling dedicatedaccount.assign events.
Identity verification events are tricky to test because they depend on external verification services. Here is how to approach it:
In Paystack test mode, the identification API accepts test data and fires the webhook events. Use the test BVN numbers from the Paystack documentation. These are predefined numbers that will return predictable success or failure results.
For local development, simulate the webhooks with curl:
SECRET="sk_test_your_secret_key"
# Simulate a successful identification
BODY='{"event":"customeridentification.success","data":{"customer_id":12345,"customer_code":"CUS_test123","email":"test@example.com","identification":{"country":"NG","type":"bvn","value":"123****789"},"reason":"Identification was successful"}}'
SIG=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $2}')
curl -X POST http://localhost:3000/webhooks/paystack -H "Content-Type: application/json" -H "x-paystack-signature: $SIG" -d "$BODY"
For unit tests, mock the database and queue, pass test payloads directly to your handler functions, and assert the correct state changes. Test both the success path and the failure path, including the distinction between service errors (retryable) and data errors (customer must fix their input).
For the full testing setup, see simulating Paystack webhook events for automated tests.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course