This guide walks you through integrating Yativo KYC (individual) and KYB (business) verification into your application. You can choose a hosted approach for quick integration or a fully custom API integration for maximum control.
Integration approaches
| Approach | Best For | Effort | Customization |
|---|
| Hosted KYC/KYB Link | Quick launch, iframe embed | Low | Limited |
| Custom API Integration | Full control, native UI | High | Full |
Option A: Hosted links (recommended)
The simplest integration. You create a customer, then redirect them to Yativo’s hosted verification flow at https://kyc.yativo.com.
Step 1: Get an access token
async function getAuthToken() {
const response = await fetch('https://api.yativo.com/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
account_id: process.env.YATIVO_ACCOUNT_ID,
app_secret: process.env.YATIVO_APP_SECRET,
}),
});
const data = await response.json();
// Token expires in 600 seconds — cache and refresh as needed
return data.data.access_token;
}
Step 2: Create a customer
async function createCustomer(token, customerData) {
const response = await fetch('https://api.yativo.com/api/v1/customer', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Idempotency-Key': `create-customer-${customerData.email}-${Date.now()}`,
},
body: JSON.stringify({
customer_name: customerData.name,
customer_email: customerData.email,
customer_phone: customerData.phone,
customer_country: customerData.country, // ISO 3166-1 alpha-2, e.g. "US"
customer_type: 'individual', // or 'business'
}),
});
const data = await response.json();
return data.data.customer_id;
}
Step 3: Redirect to hosted KYC
function redirectToKYC(customerId) {
// Redirect individual customers
window.location.href = `https://kyc.yativo.com/individual/${customerId}`;
}
function redirectToKYB(customerId) {
// Redirect business customers
window.location.href = `https://kyc.yativo.com/business/${customerId}`;
}
Embedding in an iframe
If you prefer to embed verification within your application instead of a full redirect:
<iframe
src="https://kyc.yativo.com/individual/CUSTOMER_ID"
allow="camera; microphone"
width="100%"
height="700px"
style="border: none; border-radius: 8px;"
title="Identity Verification"
></iframe>
The allow="camera; microphone" attribute is required for document capture and selfie steps to work correctly in the iframe.
Step 4: Poll for verification status
After the customer completes the hosted flow, poll the status endpoint to detect when verification is complete.
function pollKycStatus(token, customerId, onComplete) {
const intervalId = setInterval(async () => {
try {
const response = await fetch(
`https://api.yativo.com/api/v1/customer/kyc/${customerId}`,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
const data = await response.json();
const status = data.data?.status;
if (status === 'approved') {
clearInterval(intervalId);
onComplete({ success: true, status, isVaApproved: data.data.is_va_approved });
} else if (status === 'rejected') {
clearInterval(intervalId);
onComplete({
success: false,
status,
reasons: data.data.kyc_rejection_reasons,
});
}
} catch (err) {
console.error('Error polling KYC status:', err);
}
}, 5000); // Poll every 5 seconds
// Return cleanup function
return () => clearInterval(intervalId);
}
// Usage
const stopPolling = pollKycStatus(token, customerId, (result) => {
if (result.success) {
console.log('Customer verified! VA approved:', result.isVaApproved);
} else {
console.log('Verification rejected. Reasons:', result.reasons);
}
});
Instead of polling, consider using webhooks to receive real-time notifications when KYC status changes.
Option B: Custom API integration
Build your own multi-step form that collects customer data and submits it directly to the KYC API.
import { useState } from 'react';
function PersonalInfoStep({ onNext }) {
const [form, setForm] = useState({
first_name: '',
last_name: '',
email: '',
phone: '',
calling_code: '+1',
gender: '',
birth_date: '',
nationality: '',
taxId: '',
// Nigerian nationals only
bvn: '',
nin: '',
});
const handleChange = (field) => (e) =>
setForm((prev) => ({ ...prev, [field]: e.target.value }));
const isNigerian = form.nationality === 'NG';
return (
<form onSubmit={(e) => { e.preventDefault(); onNext(form); }}>
<input
placeholder="First Name"
value={form.first_name}
onChange={handleChange('first_name')}
required
/>
<input
placeholder="Last Name"
value={form.last_name}
onChange={handleChange('last_name')}
required
/>
<input
type="email"
placeholder="Email"
value={form.email}
onChange={handleChange('email')}
required
/>
{/* Gender accepts only "male" or "female" */}
<select value={form.gender} onChange={handleChange('gender')} required>
<option value="">Select gender</option>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
<input
type="date"
value={form.birth_date}
onChange={handleChange('birth_date')}
required
/>
<input
placeholder="Nationality (ISO alpha-2, e.g. US)"
value={form.nationality}
onChange={handleChange('nationality')}
required
/>
{isNigerian && (
<>
<input
placeholder="BVN (11 digits)"
value={form.bvn}
onChange={handleChange('bvn')}
maxLength={11}
required
/>
<input
placeholder="NIN (11 digits)"
value={form.nin}
onChange={handleChange('nin')}
maxLength={11}
required
/>
</>
)}
<button type="submit">Next</button>
</form>
);
}
When nationality is "NG", both bvn and nin (each exactly 11 digits) are required by the API. Show these fields conditionally based on the selected nationality.
Step 2: Upload files via Yativo storage
Before submitting the KYC form, upload document images using Yativo’s storage endpoint. This keeps payloads small and avoids base64 encoding large files.
/**
* Upload a file to Yativo storage and return a hosted URL
* to pass into the KYC payload.
*/
async function uploadDocument(token, file) {
const formData = new FormData();
formData.append('document', file); // field name must be "document"
const response = await fetch('https://api.yativo.com/api/v1/storage/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
// Do NOT set Content-Type — let the browser set it with the boundary
},
body: formData,
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.message || 'File upload failed');
}
const data = await response.json();
return data.data.url; // Pass this URL in the KYC payload
}
You can also pass files as base64-encoded strings inline, but the hosted URL approach is recommended for files over ~500 KB.
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result); // Returns data URL (base64)
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
Step 3: Collect residential address
function AddressStep({ token, onNext }) {
const [address, setAddress] = useState({
street_line_1: '',
city: '',
state: '',
postal_code: '',
country: '',
});
const [proofFile, setProofFile] = useState(null);
const [uploading, setUploading] = useState(false);
const handleFileChange = async (e) => {
const file = e.target.files[0];
if (!file) return;
setUploading(true);
try {
const url = await uploadDocument(token, file);
setProofFile(url);
} finally {
setUploading(false);
}
};
const handleSubmit = (e) => {
e.preventDefault();
onNext({ ...address, proof_of_address_file: proofFile });
};
return (
<form onSubmit={handleSubmit}>
<input
placeholder="Street Address"
value={address.street_line_1}
onChange={(e) => setAddress((a) => ({ ...a, street_line_1: e.target.value }))}
required
/>
<input
placeholder="City"
value={address.city}
onChange={(e) => setAddress((a) => ({ ...a, city: e.target.value }))}
required
/>
<input
placeholder="State / Province"
value={address.state}
onChange={(e) => setAddress((a) => ({ ...a, state: e.target.value }))}
required
/>
<input
placeholder="Postal Code"
value={address.postal_code}
onChange={(e) => setAddress((a) => ({ ...a, postal_code: e.target.value }))}
required
/>
<label>
Proof of Address (utility bill, bank statement, etc.)
<input type="file" accept="image/*,.pdf" onChange={handleFileChange} required />
{uploading && <span>Uploading…</span>}
{proofFile && <p>✓ Uploaded</p>}
</label>
<button type="submit" disabled={uploading || !proofFile}>
Next
</button>
</form>
);
}
Step 4: Collect ID document
function IdDocumentStep({ token, onNext }) {
const [doc, setDoc] = useState({
type: 'passport',
issuing_country: '',
number: '',
date_issued: '',
expiration_date: '',
image_front_file: null,
image_back_file: null,
});
const handleFileUpload = (field) => async (e) => {
const file = e.target.files[0];
if (!file) return;
const url = await uploadDocument(token, file);
setDoc((d) => ({ ...d, [field]: url }));
};
const needsBack = doc.type !== 'passport';
return (
<form onSubmit={(e) => { e.preventDefault(); onNext([doc]); }}>
<select
value={doc.type}
onChange={(e) => setDoc((d) => ({ ...d, type: e.target.value }))}
>
<option value="passport">Passport</option>
<option value="national_id">National ID</option>
<option value="other">Other</option>
</select>
<input
placeholder="Document Number"
value={doc.number}
onChange={(e) => setDoc((d) => ({ ...d, number: e.target.value }))}
required
/>
<label>
Issue Date
<input
type="date"
value={doc.date_issued}
onChange={(e) => setDoc((d) => ({ ...d, date_issued: e.target.value }))}
required
/>
</label>
<label>
Expiration Date
<input
type="date"
value={doc.expiration_date}
onChange={(e) => setDoc((d) => ({ ...d, expiration_date: e.target.value }))}
required
/>
</label>
<label>
Front of Document
<input type="file" accept="image/*,.pdf" onChange={handleFileUpload('image_front_file')} required />
{doc.image_front_file && <p>✓ Front uploaded</p>}
</label>
{needsBack && (
<label>
Back of Document
<input type="file" accept="image/*,.pdf" onChange={handleFileUpload('image_back_file')} required />
{doc.image_back_file && <p>✓ Back uploaded</p>}
</label>
)}
<button type="submit">Next</button>
</form>
);
}
The values sent to the API are snake_case identifiers, not display labels. Use the exact values below — the API will reject any other values.
function FinancialInfoStep({ onNext }) {
const [financial, setFinancial] = useState({
employment_status: '',
most_recent_occupation_code: '',
source_of_funds: '',
account_purpose: '',
account_purpose_other: '',
expected_monthly_payments_usd: '',
acting_as_intermediary: false,
});
const showOtherPurpose = financial.account_purpose === 'other';
return (
<form onSubmit={(e) => { e.preventDefault(); onNext(financial); }}>
<select
value={financial.employment_status}
onChange={(e) => setFinancial((f) => ({ ...f, employment_status: e.target.value }))}
required
>
<option value="">Employment Status</option>
<option value="employed">Employed</option>
<option value="self_employed">Self-Employed</option>
<option value="unemployed">Unemployed</option>
<option value="retired">Retired</option>
<option value="student">Student</option>
<option value="homemaker">Homemaker</option>
<option value="exempt">Exempt</option>
</select>
<select
value={financial.source_of_funds}
onChange={(e) => setFinancial((f) => ({ ...f, source_of_funds: e.target.value }))}
required
>
<option value="">Source of Funds</option>
<option value="salary">Salary</option>
<option value="business_income">Business Income</option>
<option value="company_funds">Company Funds</option>
<option value="savings">Savings</option>
<option value="investments_loans">Investments / Loans</option>
<option value="inheritance">Inheritance</option>
<option value="gifts">Gifts</option>
<option value="government_benefits">Government Benefits</option>
<option value="pension_retirement">Pension / Retirement</option>
<option value="sale_of_assets_real_estate">Sale of Assets / Real Estate</option>
<option value="ecommerce_reseller">E-commerce / Reseller</option>
<option value="gambling_proceeds">Gambling Proceeds</option>
<option value="someone_elses_funds">Someone Else's Funds</option>
</select>
<select
value={financial.account_purpose}
onChange={(e) => setFinancial((f) => ({ ...f, account_purpose: e.target.value }))}
required
>
<option value="">Account Purpose</option>
<option value="receive_salary">Receive Salary</option>
<option value="business_transactions">Business Transactions</option>
<option value="purchase_goods_and_services">Purchase Goods & Services</option>
<option value="personal_or_living_expenses">Personal / Living Expenses</option>
<option value="payments_to_friends_or_family_abroad">Payments to Friends/Family Abroad</option>
<option value="receive_payment_for_freelancing">Receive Payment for Freelancing</option>
<option value="investment_purposes">Investment Purposes</option>
<option value="protect_wealth">Protect Wealth</option>
<option value="ecommerce_retail_payments">E-commerce / Retail Payments</option>
<option value="operating_a_company">Operating a Company</option>
<option value="charitable_donations">Charitable Donations</option>
<option value="other">Other</option>
</select>
{showOtherPurpose && (
<input
placeholder="Describe your account purpose"
value={financial.account_purpose_other}
onChange={(e) => setFinancial((f) => ({ ...f, account_purpose_other: e.target.value }))}
required
/>
)}
<select
value={financial.expected_monthly_payments_usd}
onChange={(e) => setFinancial((f) => ({ ...f, expected_monthly_payments_usd: e.target.value }))}
required
>
<option value="">Expected Monthly Volume (USD)</option>
<option value="0_4999">Less than $5,000</option>
<option value="5000_9999">$5,000 – $9,999</option>
<option value="10000_49999">$10,000 – $49,999</option>
<option value="50000_plus">$50,000 or more</option>
</select>
<label>
<input
type="checkbox"
checked={financial.acting_as_intermediary}
onChange={(e) => setFinancial((f) => ({ ...f, acting_as_intermediary: e.target.checked }))}
/>
Acting on behalf of a third party
</label>
<button type="submit">Next</button>
</form>
);
}
Step 6: Submit to the KYC API
async function submitKYC(token, customerId, formData) {
const payload = {
customer_id: customerId,
// Personal info
...formData.personal,
// Residential address (including proof_of_address_file URL)
residential_address: formData.address,
// ID documents (array of document objects with uploaded URLs)
identifying_information: formData.documents,
// Financial & purpose
...formData.financial,
// Selfie image URL from storage upload
selfie_image: formData.selfieUrl,
// Supporting documents
uploaded_documents: formData.supportingDocs,
};
// Remove account_purpose_other unless account_purpose is "other"
if (payload.account_purpose !== 'other') {
delete payload.account_purpose_other;
}
const response = await fetch('https://kyc.yativo.com/api/individual-kyc/submit', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Idempotency-Key': `kyc-${customerId}-${Date.now()}`,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'KYC submission failed');
}
return response.json();
}
The KYC submission endpoint is at https://kyc.yativo.com, not the main API. The Authorization bearer token is the same one issued by POST /auth/login.
Status polling (TypeScript)
type KycStatus = 'not_started' | 'submitted' | 'manual_review' | 'approved' | 'rejected' | 'under_review';
interface KycStatusResult {
status: KycStatus;
isVaApproved: boolean;
rejectionReasons: string[];
kycLink: string;
}
function pollKycStatus(
token: string,
customerId: string,
onStatusChange: (result: KycStatusResult) => void,
intervalMs = 5000
): () => void {
const intervalId = setInterval(async () => {
const response = await fetch(
`https://api.yativo.com/api/v1/customer/kyc/${customerId}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const { data } = await response.json();
const result: KycStatusResult = {
status: data.status,
isVaApproved: data.is_va_approved,
rejectionReasons: data.kyc_rejection_reasons ?? [],
kycLink: data.kyc_link,
};
onStatusChange(result);
if (data.status === 'approved' || data.status === 'rejected') {
clearInterval(intervalId);
}
}, intervalMs);
return () => clearInterval(intervalId);
}
Webhooks (recommended over polling)
Instead of polling, register a webhook to receive real-time notifications:
// Register webhook
await fetch('https://api.yativo.com/api/v1/webhook', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'webhook-kyc-events',
},
body: JSON.stringify({
url: 'https://your-app.com/webhooks/yativo',
events: [
'customer.kyc.approved',
'customer.kyc.rejected',
],
}),
});
Then in your webhook handler:
// Express.js example
app.post('/webhooks/yativo', (req, res) => {
const { event, data } = req.body;
if (event === 'customer.kyc.approved') {
const { customer_id } = data;
// Activate customer account, send welcome email, etc.
activateCustomer(customer_id);
}
if (event === 'customer.kyc.rejected') {
const { customer_id, rejection_reasons } = data;
// Notify customer, share kyc_link for re-submission
notifyCustomerRejection(customer_id, rejection_reasons);
}
res.sendStatus(200);
});
UI/UX best practices
Progress indicators
Show a clear multi-step progress bar so customers know where they are in the verification process. For individual KYC: Personal Info → Address & Documents → ID Document → Financial Info → Review & Submit.
Clear error messages
Display field-level validation errors immediately, not just on submit. Translate API error codes into user-friendly language. The API returns a data object with field-specific error arrays on 422 responses.
Document upload previews
Always show a preview of uploaded documents before submission. This helps customers catch blurry or incorrectly oriented images before they fail review.
function DocumentPreview({ url, label }) {
if (!url) return null;
const isImage = /\.(jpg|jpeg|png|heic)$/i.test(url) || url.startsWith('data:image');
return (
<div style={{ marginTop: 8 }}>
<p style={{ fontSize: 12, color: '#666' }}>{label} preview:</p>
{isImage ? (
<img
src={url}
alt={label}
style={{ maxWidth: 300, maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
/>
) : (
<p style={{ color: '#888' }}>📄 PDF uploaded</p>
)}
</div>
);
}
Mobile camera support
Use capture="environment" on file inputs to open the rear camera directly on mobile:
<!-- Opens rear camera for document capture -->
<input type="file" accept="image/*" capture="environment" />
<!-- Opens front camera for selfie -->
<input type="file" accept="image/*" capture="user" />
Selfie guidance
For the selfie step, display on-screen guidelines (oval face outline, lighting tips) before the customer takes their photo to reduce re-submission rates.
Retry on rejection
When a customer’s KYC is rejected, use the kyc_link from the status response to send them back to the hosted flow for corrections.
const statusData = await getKycStatus(token, customerId);
if (statusData.status === 'rejected') {
const kycLink = statusData.kyc_link;
// Show retry button that redirects to kycLink
console.log('Rejection reasons:', statusData.kyc_rejection_reasons);
}