← Back to blog

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 status

Every Cryvis call uses:

POST https://api.cryvis.com/v1/documents/<type>
Authorization: Bearer YOUR_API_KEY
Content-Type: multipart/form-data

Create 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

SettingValue
URLhttps://api.cryvis.com/v1/documents/passport
MethodPOST
BodyMultipart Form-Data
AuthBearer Header Auth

Multipart fields (exact names):

FieldTypeValue
first_pageBinaryProperty = file_1 (biodata) — required
last_pageBinaryProperty = 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

SettingValue
URLhttps://api.cryvis.com/v1/documents/pan
Multipartfile = 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)

SettingValue
URLhttps://api.cryvis.com/v1/documents/aadhaar
Multipartone 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 propertySource
Emailintake email
Custom: kyc_document_typekyc_doc_type
Custom: kyc_id_numberkyc_id_number
Custom: kyc_date_of_birthkyc_dob
Custom: kyc_statuskyc_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.

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.