Automate KYC in n8n: Webhook to Passport, PAN, Aadhaar, and HubSpot
Build an n8n KYC workflow that routes webhook or form uploads to Cryvis Passport, PAN, and Aadhaar APIs via Switch, then writes contact fields and KYC status to HubSpot.
Manual KYC review does not scale. Applicants upload mixed ID packs, ops teams rename files, and CRM fields stay empty until someone types them in. This tutorial builds an n8n workflow that takes a Webhook (or form) multi-file submission, routes each document by type with Switch, calls the matching Cryvis extraction endpoint, and updates a HubSpot contact with structured identity data plus a KYC status.
You will wire:
- Webhook (or n8n Form Trigger) as intake
- A Switch that branches on document type
- Cryvis
POST /v1/documents/passport,/pan, and/aadhaar - HubSpot contact create/update with KYC status
If you only need one document type, start with PAN or driver's license. Hub: /blog/n8n.
Architecture
Webhook / Form (multi-file KYC)
|
v
n8n workflow
|
Switch (doc_type)
+----+----+----+
| | |
v v v
Passport PAN Aadhaar
(HTTP) (HTTP) (HTTP)
| | |
+----+----+----+
|
v
HubSpot Contact
+ KYC statusEvery Cryvis call uses:
POST https://api.cryvis.com/v1/documents/<type>
Authorization: Bearer YOUR_API_KEY
Content-Type: multipart/form-dataCreate an API key in the Cryvis dashboard and store it in an n8n Header Auth credential. Do not hard-code keys in shared workflow JSON.
Step 1: Webhook or form intake
Webhook (POST, binary enabled) or Form Trigger with: email, full_name, document_type (passport/pan/aadhaar), and file uploads in $binary. Passport needs biodata + final page; Aadhaar allows 1–2 images. Webhook text is often $json.body.*; files stay in $binary — confirm property names with one test run.
Step 2: Set + Switch
Set: doc_type, email, applicant_name; keep binaries as file_1 / file_2 (rename from data0/data1 if needed). IF: passport without file_2 → fail early.
Switch on {{ $json.doc_type }} → Passport / PAN / Aadhaar / fallback Slack.
Step 3: Cryvis Passport branch
HTTP Request
| Setting | Value |
|---|---|
| URL | https://api.cryvis.com/v1/documents/passport |
| Method | POST |
| Body | Multipart Form-Data |
| Auth | Bearer Header Auth |
Multipart fields (exact names):
| Field | Type | Value |
|---|---|---|
first_page | Binary | Property = file_1 (biodata) — required |
last_page | Binary | Property = file_2 (final page) — required |
Successful responses wrap fields under data — map HubSpot-bound variables with Set:
kyc_doc_type = passport
kyc_id_number = {{ $json.data.passport_number }}
kyc_full_name = {{ $json.data.full_name }}
kyc_dob = {{ $json.data.date_of_birth }}
kyc_nationality = {{ $json.data.nationality }}
kyc_expiry = {{ $json.data.expiry_date }}
mrz_valid = {{ $json.data.mrz.check_digits_valid }}
kyc_status = {{ $json.data.mrz.check_digits_valid ? 'extracted_ok' : 'review_mrz' }}Treat check_digits_valid: false as review. Docs: /docs/api/extractPassport · /apis/passport.
Step 4: Cryvis PAN branch
| Setting | Value |
|---|---|
| URL | https://api.cryvis.com/v1/documents/pan |
| Multipart | file = binary property file_1 |
Response data: pan_number, full_name, father_name, date_of_birth, holder_type_code / holder_type, pan_structure.
Set:
kyc_doc_type = pan
kyc_id_number = {{ $json.data.pan_number }}
kyc_full_name = {{ $json.data.full_name }}
kyc_dob = {{ $json.data.date_of_birth }}
kyc_father = {{ $json.data.father_name }}
holder_type = {{ $json.data.holder_type }}
kyc_status = {{ $json.data.pan_number ? 'extracted_ok' : 'review_missing_pan' }}Docs: /docs/api/extractPan · focused guide: Extract PAN.
Step 5: Cryvis Aadhaar branch (PII-aware)
| Setting | Value |
|---|---|
| URL | https://api.cryvis.com/v1/documents/aadhaar |
| Multipart | one or two parts named file (file_1 / file_2) |
Response data: aadhaar_number, full_name, date_of_birth / year_of_birth, gender, address, pincode, is_masked.
Prefer masked display in HubSpot when is_masked is true; restrict property visibility; if policy needs full UID, set kyc_status=needs_unmasked_resubmit.
kyc_doc_type = aadhaar
kyc_id_number = {{ $json.data.aadhaar_number }}
kyc_full_name = {{ $json.data.full_name }}
kyc_dob = {{ $json.data.date_of_birth || $json.data.year_of_birth }}
kyc_address = {{ $json.data.address }}
kyc_pincode = {{ $json.data.pincode }}
kyc_status = {{ $json.data.is_masked ? 'extracted_masked' : 'extracted_ok' }}Docs: /docs/api/extractAadhaar · /apis/aadhaar.
Step 6: Converge into HubSpot
HubSpot → Create or Update a Contact (match on email). Pull email from the earlier Set via $('Set').item.json.email.
| HubSpot property | Source |
|---|---|
| intake email | |
Custom: kyc_document_type | kyc_doc_type |
Custom: kyc_id_number | kyc_id_number |
Custom: kyc_date_of_birth | kyc_dob |
Custom: kyc_status | kyc_status |
Custom: kyc_extracted_at | {{ $now.toISO() }} |
Optional per branch: passport kyc_nationality / kyc_expiry; PAN holder_type; Aadhaar kyc_pincode + aadhaar_is_masked.
Statuses: extracted_ok, extracted_masked, review_mrz, review_missing_pan, needs_unmasked_resubmit, error.
Step 7: Error Trigger
Cryvis HTTP fails → Error Trigger
→ Set kyc_status = error
→ HubSpot update (status only)
→ Slack ops alert (statusCode + short body, not the document)Common: 400 wrong multipart names (passport first_page/last_page); 401 bad Bearer; wrong MIME (PDF/JPEG/PNG/WebP only).
Checklist
Passport: both first_page + last_page. PAN: one file. Aadhaar: 1–2 parts named file + HubSpot respects is_masked. All routes set kyc_status, upsert HubSpot by email, and surface failures via Error Trigger.
Related reading
- Extract PAN card data
- Extract driver's license data
- Convert PDFs to JSON
- Extract Indian vehicle RC
- /blog/n8n
CTA
Ship the multi-doc KYC Switch with Cryvis extraction APIs: Passport, PAN, and Aadhaar. Grab an API key, paste it into n8n as Bearer Header Auth, and map {{ $json.data.* }} fields straight into HubSpot.