Signup to KYC to first deposit, in your warehouse
"How many September signups passed KYC and funded within a week, and which KYC vendor loses the most people?" Your product analytics SDK sees the clicks, and KYC decisions and deposits never reach it unless your backend sends them. KYC outcomes arrive on the vendor's webhook and deposits settle in your ledger, so five of the seven onboarding events can only come from your backend.
Jitsu takes the browser and app events and the backend events into one events table in your warehouse, the same table the other fintech pipelines write to. One ClickHouse query then returns the funnel by KYC provider and first-touch source.
TL;DR
Account Created,KYC Approved,KYC Rejected,Deposit CompletedandAccount Fundedcome from the backend over the HTTP API with a server-to-server write key.- Set
messageIdfrom the vendor's decision ID, and Jitsu deduplicates a retried webhook in the warehouse. - Funnel events missing a required property go to their own table through a contract function, so a release that drops a property shows up as rows there.
- All 35 fintech events are in the tracking plan (CSV).
The onboarding events
| Event | Sent from | Required properties | PII handling |
|---|---|---|---|
identify | Browser, app, backend | userId (your internal ID), trait email | hash_for_ads; never national ID or date of birth |
Account Created | Backend | signup_method | none |
KYC Started | Browser or app | kyc_provider | none |
KYC Submitted | Browser, app or backend | kyc_provider, attempt_number | none; never document numbers, images or selfie data |
KYC Approved | Backend, from the KYC webhook | kyc_provider, review_type | none |
KYC Rejected | Backend, from the KYC webhook | kyc_provider, reason_category | none; never free-text reasons with personal data |
Deposit Completed | Backend | deposit_id, amount, currency, funding_method | band_for_third_party |
Account Funded | Backend, once per user | value, currency | hash_for_ads |
The tracking plan's pii_handling column is none, hash_for_ads (email and phone hashed before an ad platform), band_for_third_party (the exact amount in the warehouse, a band or nothing in third-party tools) or strip_query_params (page URLs). Its never_send_fields column names the fields no event may carry.
Use your internal user ID as userId on every event. An account number or national ID used as userId travels to every destination.
Sending from the web, the apps and the KYC webhook
From the browser or web app, after signup, with the JavaScript library:
jitsu.identify("u_8f3a2c", { email: "ana@example.com" });
jitsu.track("KYC Started", { kyc_provider: "vendor_a", attempt_number: 1 });
From the backend, with a server-to-server write key and the HTTP API. This is the call a KYC webhook handler makes after an approval:
curl --location 'https://your-jitsu-domain.com/api/s/track' \
--header 'Content-Type: application/json' \
--header 'X-Write-Key: keyId:keySecret' \
--data-raw '{
"type": "track",
"event": "KYC Approved",
"messageId": "kyc-decision-7781",
"userId": "u_8f3a2c",
"timestamp": "2026-09-03T08:00:00.000Z",
"properties": {
"kyc_provider": "vendor_a",
"review_type": "auto",
"time_to_decision_seconds": 79200
},
"context": {}
}'
Strict Mode on the server-to-server stream accepts only requests with a valid write key (server-to-server docs).
Apps use Segment's open-source libraries with the host set to your Jitsu domain: iOS, Android, React Native. An app install has its own anonymous ID, so call identify in the app as well as on the web.
Holding the funnel to the tracking plan
The warehouse connection runs a contract function after the guardrail. It keeps the listed properties and removes everything else (reviewer notes, document numbers, raw vendor payloads), maps unknown rejection reasons to other, drops funnel events your backend marks with environment: "sandbox", and routes an event that is missing a required property to its own table with JITSU_TABLE_NAME. Funnel events in the table then all match the plan, and the broken ones can still be queried.
// Event name -> [required properties, optional properties], copied from fintech-tracking-plan.csv.
const PLAN = {
"Account Created": [["signup_method"], ["product", "country", "referral_code_used"]],
"KYC Started": [["kyc_provider"], ["attempt_number"]],
"KYC Submitted": [["kyc_provider", "attempt_number"], ["document_type_category"]],
"KYC Approved": [["kyc_provider", "review_type"], ["time_to_decision_seconds"]],
"KYC Rejected": [["kyc_provider", "reason_category"], ["attempt_number", "is_final"]],
"Deposit Completed": [["deposit_id", "amount", "currency", "funding_method"], ["is_first_deposit"]],
"Account Funded": [["value", "currency"], ["funding_method", "days_since_signup"]],
};
const REASONS = new Set([
"document_unreadable", "document_expired", "data_mismatch", "sanctions_screening", "risk_review", "other",
]);
const VIOLATIONS_TABLE = "kyc_contract_violations";
export default async function kycFunnelContract(event, { log }) {
const spec = event.type === "track" && Object.hasOwn(PLAN, event.event ?? "") ? PLAN[event.event] : undefined;
if (!spec) return; // not a funnel event: passes through unchanged
const props = event.properties && typeof event.properties === "object" ? event.properties : {};
if (props.environment === "sandbox") return "drop"; // KYC vendor sandbox traffic
const [required, optional] = spec;
const kept = {};
for (const key of [...required, ...optional]) {
if (props[key] !== undefined && props[key] !== null && props[key] !== "") kept[key] = props[key];
}
if (typeof kept.reason_category === "string" && !REASONS.has(kept.reason_category)) {
kept.reason_category = "other";
}
const missing = required.filter(key => !(key in kept));
if (missing.length > 0) {
log.warn(`kyc-funnel-contract: ${event.event} is missing ${missing.join(", ")}`);
return { ...event, properties: { ...kept, missing_properties: missing.join(",") }, JITSU_TABLE_NAME: VIOLATIONS_TABLE };
}
return { ...event, properties: kept };
}
Events outside the plan return nothing, which in a Jitsu function means they pass unchanged (return values). The function makes no fetch call, so the sandbox events it drops are not billed as long as no other connection delivers them (pricing). Its warning lists the missing properties in Live Events, and the kyc_contract_violations table keeps the events themselves.
Linking the funnel to the first ad click
Every backend event carries the user ID, so the funnel works from Account Created onward on any plan. The first-touch source needs one more step, because the visitor's first page views were anonymous.
Identity stitching supplies it. When a visitor identifies, Jitsu goes back over that visitor's anonymous events from the previous 30 days and writes the user ID onto them in your warehouse: ClickHouse, BigQuery, PostgreSQL, Redshift, Snowflake or MySQL. It is switched on per connection. On Jitsu Cloud it is part of the Enterprise plan, and a self-hosted install has it on any plan, backed by MongoDB (identity stitching docs). Without it, the first-touch column can only read page views from signed-in sessions.
The funnel query
This is ClickHouse SQL over the default single events table. Track properties are columns at the root (kyc_provider), UTM parameters arrive as context_campaign_source, and FINAL makes the ReplacingMergeTree table return one row per message_id, which exact counts need. The timestamp filter keeps FINAL to the weeks the query reads.
-- ClickHouse. Accounts created in September 2026, and how many reached each step within 7 days.
-- FINAL collapses rows that share a message_id (ReplacingMergeTree deduplication).
WITH per_user AS (
SELECT
user_id,
argMinIf(context_campaign_source, timestamp, type = 'page') AS first_touch_source,
anyIf(kyc_provider, event = 'KYC Submitted') AS kyc_provider,
minIf(timestamp, event = 'Account Created') AS created_at,
minIf(timestamp, event = 'KYC Submitted') AS submitted_at,
minIf(timestamp, event = 'KYC Approved') AS approved_at,
minIf(timestamp, event = 'Account Funded') AS funded_at
FROM events FINAL
WHERE user_id != ''
AND timestamp >= '2026-08-01' AND timestamp < '2026-10-15'
AND (type = 'page' OR event IN ('Account Created', 'KYC Submitted', 'KYC Approved', 'Account Funded'))
GROUP BY user_id
)
SELECT
if(first_touch_source = '', '(none)', first_touch_source) AS source,
if(kyc_provider = '', '(no submission)', kyc_provider) AS provider,
count() AS created,
countIf(submitted_at BETWEEN created_at AND created_at + INTERVAL 7 DAY) AS kyc_submitted,
countIf(approved_at BETWEEN created_at AND created_at + INTERVAL 7 DAY) AS kyc_approved,
countIf(funded_at BETWEEN created_at AND created_at + INTERVAL 7 DAY) AS funded,
round(funded / created, 3) AS created_to_funded
FROM per_user
WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01'
GROUP BY source, provider
ORDER BY created DESC, source, provider;
Each output row is one first-touch source and KYC provider: accounts created, then how many submitted KYC, were approved and funded within seven days, and the created-to-funded rate. A provider with many submissions and few approvals points at document quality or that vendor's rules. Few submissions point at the screen before verification. If one vendor's approvals fall, read its KYC Rejected rows by reason_category, then check kyc_contract_violations for rejections with no reason_category: those mean the webhook handler is sending decisions without a reason.
Questions people ask
Which events should come from the backend? Anything that is a financial or compliance fact: account creation, KYC decisions, deposits, withdrawals and the first funded deposit. The browser and the app send the steps a user takes, such as opening the KYC flow.
What about users who retry KYC? Send attempt_number on every KYC Submitted, and is_final on KYC Rejected when the vendor will not take another attempt. The funnel query counts each user once, at their first submission and first approval, so retries do not inflate it.
Can I run the funnel on BigQuery or Snowflake? Yes. Jitsu loads the same events into BigQuery, Snowflake and the other warehouses it supports. The query on this page is written for ClickHouse, so its argMinIf, minIf and countIf calls and FINAL need that warehouse's own syntax.
Get started with Jitsu
Point one KYC webhook handler at Jitsu with a server-to-server write key on the Free plan, and its decisions arrive in your events table. Identity stitching, which adds the anonymous first touch, is set up on a demo call.