Fintech KYC drop-off: a step funnel with time-outs and retries
In this article (7 sections)
Related programme: the Data Analytics with Generative AI course (3–4 months; ₹90,000 (inclusive of GST) · EMI from ₹4,000/month).
A KYC drop-off funnel should be built from the onboarding event log as distinct users per step, for a cohort that has all had the same time to finish (for example, seven days after signup). At each step keep three counts apart: users who never attempted it, users who attempted and failed, and users who passed after however many retries and time-outs. A single "conversion %" per step blends a design problem, a verification problem and an impatience problem, and each needs a different team.
Three kinds of drop at every step
For a lending or investing app, the onboarding steps usually look like: sign up, identity number check, address or identity document, selfie or liveness check, then a review (automatic or manual). At any step, a user who does not get through has done one of three things:
| Kind of drop | What the log shows | Usual owner |
|---|---|---|
| Never attempted | passed the previous step, no event for this one | product and design: the ask felt too big |
| Tried and failed | one or more fail results, no ok | verification vendor, image quality, data mismatch |
| Timed out | session expired mid-step, did not return | engineering: session length, slow upload |
The funnel below reports these separately. The exact steps and their order depend on your product and on what your compliance team has decided is required; the analysis does not depend on them.
The event log you need
| Column | Type | Notes |
|---|---|---|
| user_id | text | stable across devices; not the device ID |
| step | text | one fixed list of step names, versioned when the flow changes |
| result | text | ok, fail, timeout |
| ts | timestamp | server time, one time zone |
| attempt_no | integer | optional; can be derived by ordering |
| failure_reason | text | vendor code (blurred image, name mismatch) |
| app_version, channel | text | to split by release and acquisition source |
Worked example: a September sign-up cohort
The log below is synthetic, generated for 1,200 users signing up to a lending app during September 2026, with up to three tries per step, occasional session time-outs, and a manual review that takes one to three days.
import random
import pandas as pd
# Synthetic onboarding event log for a lending app. Not real customer data.
rng = random.Random(7)
STEPS = ["signup", "pan_check", "address_doc", "selfie", "review"]
PASS = {"pan_check": 0.9, "address_doc": 0.6, "selfie": 0.7, "review": 0.88}
ATTEMPT = {"pan_check": 0.9, "address_doc": 0.8, "selfie": 0.88, "review": 1.0}
rows = []
start = pd.Timestamp("2026-09-01")
for u in range(1, 1201):
t = start + pd.Timedelta(minutes=rng.randrange(0, 29 * 24 * 60))
rows.append((u, "signup", "ok", t))
for step in STEPS[1:]:
if rng.random() > ATTEMPT[step]:
break # never attempts the step
passed = False
for attempt in range(3): # up to three tries
wait = [1, 2, 3] if step == "review" else [0.002, 0.005, 0.01, 0.2, 2]
t += pd.Timedelta(days=rng.choice(wait)) # manual review waits days
if step != "review" and rng.random() < 0.06:
rows.append((u, step, "timeout", t)) # session expired mid-step
continue
if rng.random() < PASS[step]:
rows.append((u, step, "ok", t)); passed = True; break
rows.append((u, step, "fail", t))
if not passed:
break
ev = pd.DataFrame(rows, columns=["user_id", "step", "result", "ts"])
EXTRACT = pd.Timestamp("2026-09-30")
ev = ev[ev.ts <= EXTRACT] # the extract cannot see the future
print("events:", len(ev), "users:", ev.user_id.nunique())
# Cohort rule: only users who signed up at least 7 days before the extract
signup = ev[ev.step == "signup"].set_index("user_id")["ts"]
mature = signup[signup <= EXTRACT - pd.Timedelta(days=7)].index
e = ev[ev.user_id.isin(mature) & (ev.ts <= ev.user_id.map(signup) + pd.Timedelta(days=7))]
print("mature cohort:", len(mature))
rows = []
prev = None
for step in STEPS:
s = e[e.step == step]
attempted = s.user_id.nunique()
passed = s[s.result == "ok"].user_id.nunique()
rows.append({
"step": step,
"attempted": attempted,
"passed": passed,
"pass_per_try_pct": round(100 * (s.result == "ok").sum() / len(s), 1),
"pass_of_attempted_pct": round(100 * passed / attempted, 1),
"attempted_of_prev_pass_pct": round(100 * attempted / prev, 1) if prev else None,
"tries_per_attempter": round(len(s) / attempted, 2),
"timeouts": int((s.result == "timeout").sum()),
})
prev = passed
f = pd.DataFrame(rows)
print(f.to_string(index=False))
print("end-to-end (%):", round(100 * f.passed.iloc[-1] / f.passed.iloc[0], 1))
# Without a cohort rule: completion by signup week, whatever each user's age
done = ev[(ev.step == "review") & (ev.result == "ok")].user_id.unique()
wk = ((signup - start).dt.days // 7 + 1).clip(upper=4).rename("signup_week")
by_week = pd.DataFrame({"week": wk, "done": signup.index.isin(done)}).groupby("week")["done"]
print((100 * by_week.mean()).round(1).to_string())The funnel for the 902 users who signed up at least seven days before the extract, counting only what they did in their first seven days:
| Step | Attempted | Passed | Pass per try % | Passed of attempted % | Attempted of previous passed % | Tries per attempter | Time-outs |
|---|---|---|---|---|---|---|---|
| signup | 902 | 902 | 100.0 | 100.0 | – | 1.00 | 0 |
| pan_check | 812 | 810 | 83.4 | 99.8 | 90.0 | 1.20 | 62 |
| address_doc | 632 | 581 | 57.1 | 91.9 | 78.0 | 1.61 | 50 |
| selfie | 518 | 497 | 68.4 | 95.9 | 89.2 | 1.40 | 36 |
| review | 455 | 437 | 86.2 | 96.0 | 91.5 | 1.11 | 0 |
End to end, 48.4% of the cohort completed onboarding within seven days.
What the table is saying
The address document is the problem, twice over. Only 78.0% of users who cleared the PAN step even tried it, the lowest "attempted" ratio in the flow, so the ask itself is putting people off. And those who tried passed only 57.1% of individual tries, needing 1.61 tries on average. That is two separate fixes: better explanation before the step, and better capture guidance (or a different document option) during it.
Retries make a step look healthy. Per user, 91.9% of address-document attempters eventually passed. Reported that way the step looks fine. The per-try rate of 57.1% is what the user experienced, and each retry is also a vendor verification call that costs money. Show both.
Time-outs are their own line. 62 PAN-step and 50 address-step time-outs are sessions that expired while the user was in the middle of a step. That is an engineering setting, not a verification outcome, and it hides inside "fail" in most dashboards.
Why the latest week always looks broken
The last block of the output ignores the cohort rule and simply asks what share of each signup week has completed so far:
| Signup week | Completed by 30 September (%) |
|---|---|
| 1 | 55.3 |
| 2 | 55.1 |
| 3 | 53.6 |
| 4 | 29.5 |
Week 4 has not collapsed. Its users have had anything from a few hours to about eight days, and the manual review alone takes one to three. A funnel refreshed daily on all sign-ups will show a falling line every Monday and cause a needless alarm. Fix the window (here seven days), exclude anyone younger than it, and note that the seven-day figure (48.4%) is lower than the eventual completion rate of older weeks (about 55%) because some users finish late.
Traps particular to KYC data
- Flow versions. When a step is added or reordered, old and new users are not comparable. Split by app version or by the date the change went live.
- Users who start again. Someone who abandons and re-registers with a new phone number appears as two users, one dropped and one complete. Match on a verified identifier where you are permitted to.
- Vendor failure codes. "Fail" from a blurred photo and "fail" from a data mismatch need different fixes. Keep the vendor reason, and check how codes changed when the vendor was switched.
- Personal data in the extract. The analysis needs user IDs, steps and timestamps, not identity numbers or images. Ask for the minimum columns and keep the extract access-controlled.
Taking it to product, risk and operations
Give each owner one line. Product: "22% of users who pass the PAN step never try the address document." Operations or the vendor manager: "Address document tries pass 57% of the time," followed by those failures split by vendor reason code. Engineering: "148 sessions expired mid-step in the cohort, 112 of them at the PAN and address steps." Risk and compliance: completion is 48.4% within seven days, with the review step passing 96.0% of those who reach it; any change to steps goes through them first, and the funnel is how you measure its effect.
Payment failures have a similar split between customer and system causes; see the UPI failure rate playbook. Event-log funnels like this one are built in pandas and SQL during the Data Analyst programme.
Frequently asked questions
How do you calculate drop-off in an onboarding funnel?
Count distinct users who reached each step within a fixed window after signup, then divide each step's count by the previous step's passed count. Keep attempted and passed as separate columns so that people who never tried a step are not confused with people who tried and failed.
Why does the latest week always look worst in a KYC funnel?
Because recent sign-ups have had less time to finish, especially if any step involves a manual review queue. Compare only users who have had the same number of days since signup, for example seven, and exclude anyone younger than that from the rate.
Pankit Kumar has 10 years in Data Science & AI, building and shipping production systems in regulated pharma and clinical environments. He is a freelance trainer at Boston Institute of Analytics, AnalytixLabs and Scaler, and has taught this material to thousands of working professionals.
This article is part of our Data Analytics with Generative AI programme — 3–4 months. The full analyst stack — Excel, SQL, Power BI and Python pipelines — then a generative-AI layer you can prove is right.
Explore Data Analytics with Generative AI