Dilshat Rakhimov
July 2026 · 14 min read
Here's a question that should take ten seconds to answer, and at two large Central Asian banks it couldn't be answered at all: how many people bought a policy last month, by product?
The data was all there. Amplitude was receiving well over 200 distinct event types across 18-plus naming prefixes. Every product team had shipped its own events on its own schedule, in its own style, and nobody had ever been the owner of the whole. So ogpo_payment_success and halyk_casco_ad_payment_success and kasko_payment_fail and a generic payment all coexisted, meaning slightly different things, counted in different places. Ask for last month's purchases and you get an argument, not a number.
This is what an event taxonomy looks like after three years of nobody owning it. Below is how it got collapsed to 35 events - what the collapsing rule actually is, why the money event had to leave the browser entirely, and the migration step that everyone knows about and nobody does.

What 200 events actually cost you
It's tempting to file this as untidiness. It isn't. Four things break, and they're all expensive.
You can't segment by product. With ogpo_payment_success and kasko_payment_success as separate event types, "conversion rate by product" isn't a breakdown - it's a hand-built chart per product, rebuilt every time a product launches. Thirteen products, thirteen charts, and no way to rank them in one view.
You run out of property quota. Amplitude bills and limits on distinct properties. Nested structures like data.event_info.user.* and data.event_info.utm.* multiply that count fast, and once you're near the ceiling the fix is emergency deletion of properties someone's dashboard depends on.
Attribution splinters. Thirty-plus variants of one conceptual event means thirty-plus places for a funnel definition to be subtly wrong. Nobody notices, because each chart looks plausible on its own.
And PII rides along. data.event_info.user.iin - a national ID - was on every event, and phone and name sat in user properties beside it. That's a separate problem with its own fix, but it's worth naming here because a sprawling taxonomy is how it happens: nobody reviews an event nobody owns.
The rule that collapses 200 into 35
The whole redesign rests on one decision: product_type becomes a required parameter on every funnel event, and the product stops living in the event name.

That's it. That's the trick. ogpo_payment_success and kasko_payment_fail and the twenty-eight others become payment_completed with product_type and is_success. Thirteen products - motor liability, comprehensive motor, travel, property, health, credit-linked - now share one schema. Adding a fourteenth product means adding a string to a catalogue, not shipping a new event and updating every dashboard that should have included it.
The purchase funnel is thirteen universal events:
| # | Event | Fires on | Key parameters |
|---|---|---|---|
| 1 | screen_viewed | any screen or landing page | screen_name, product_type, step_number, step_name |
| 2 | form_started | first interaction with a form | product_type, entry_point, utm_*, gclid, fbclid |
| 3 | form_field_completed | a high-drop-off field only | product_type, field_name, is_valid, step_number |
| 4 | option_selected | tariff / coverage / add-on chosen | product_type, option_category, option_value |
| 5 | person_added | extra driver, traveller, beneficiary | product_type, person_role, person_count |
| 6 | price_calculated | quote returned (mid-funnel) | product_type, premium (float), sum_insured, is_success |
| 7 | checkout_started | "Pay" pressed | product_type, premium, payment_method, has_add_ons |
| 8 | pay_widget_shown | gateway widget rendered | product_type, premium |
| 9 | otp_requested | OTP sent | product_type, otp_type |
| 10 | otp_completed | OTP verified | product_type, is_success, is_resend |
| 11 | payment_completed | server-side only - see below | product_type, is_success, premium, payment_id, $revenue |
| 12 | validation_error | form validation failed | product_type, error_type, field_name, step_number |
| 13 | promocode_applied | promo entered | product_type, promocode, is_valid, discount_amount |
Note what's not in there. No page_view per product. No per-product payment event. No separate success and failure events - is_success is a boolean, because a success event and a failure event that can never both fire are one event with a flag, and splitting them doubles your taxonomy for nothing.
The remaining 22 are the honest exceptions: 9 product-specific events for flows that genuinely only exist in one product (photo upload and validation on comprehensive motor, a cyber-risk toggle, a home-contents add-on during travel checkout), 8 for the claims and settlement flow, 4 for contract management and support, and 1 for technical errors.
product_type itself is a closed catalogue - 23 values at the time of writing, covering the base products, their advertising variants, and the two claims flows. Closed is the operative word: a value that isn't in the catalogue is a bug, and the schema will tell you so.
Collapse the error events too - they're the loudest thing in the room
The single highest-volume finding wasn't in the purchase funnel. It was errors, arriving as three separate event types:
| Event | 30-day volume |
|---|---|
api_error | 4.5M |
front_error | 170K |
fatal_error | 28K |
Nearly 4.7M error events a month, split across three types by where the error came from - which is a property, not an identity. All three become one technical_error, with error_severity (fatal / error / warning) and error_source (api / frontend / network) doing the work the event names were doing badly.
The same pattern shows up in an uglier form elsewhere. The photo-upload flow on comprehensive motor had shipped more than 80 distinct event types - one per file slot, per side of the car, per damage photo. Around 10K events a month spread across 80 event types, which is another way of saying: unusable, and permanently in everyone's way in the event picker. All 80 become photo_uploaded with a photo_type parameter.
If you take one operational habit from this: whenever an event name contains a value, that value wants to be a parameter. Product in the name, error source in the name, photo slot in the name, filter state in the name - every one of those was a collapse waiting to happen, and together they were most of the 200.
Flatten the properties
Alongside the event collapse, the property structure got flattened. Nested objects like data.event_info.utm.utm_source became a root-level utm_source.
This is less cosmetic than it sounds. Nested properties are awkward to chart, they inflate the distinct-property count that quota is measured against, and - the real problem - they hide things. data.event_info.user.iin sat unnoticed for a long time precisely because nobody scrolling an event's properties ever expanded that far.
Everything now lives at the root of event_properties, with types declared and enforced: product_type string and required, premium float, is_success boolean, step_number integer.
That float is not a formatting preference. On the old schema the amount was arriving as a string inside a JSON blob, which meant Amplitude's sums() over revenue returned zero. Not wrong - zero. Every revenue chart in the workspace was quietly reporting nothing, and had been for as long as anyone could remember. A typed schema is what stops that class of bug from being invisible.
The money event has to leave the browser
payment_completed is the one event in the taxonomy that the front end is not allowed to send.
The reasoning is the same as for any conversion that involves a payment gateway. The browser knows that the user pressed pay and got redirected. It does not know whether the money moved. Between those two facts sit 3-D Secure, a gateway redirect, a callback, and a user who may close the tab at any point in the sequence. A browser-side purchase event measures intent and reports it as revenue.
So the front end's job ends at pay_widget_shown. The backend sends payment_completed from the gateway callback, with the revenue properties in the same call:
Three details in there earn their place.
insert_id keyed to the invoice number. Amplitude drops duplicate insert_ids within a rolling window, so a retried callback or a double-fired worker costs nothing. Without it, every gateway retry is a phantom sale.
Revenue in the same call, not a separate one. revenue, $productId, $revenueType ride on the event rather than going out as a second Revenue API call. One network hop, one thing to fail, and ARPPU segments by product_type for free.
user_id is a UUID. Not a national ID, not a legacy numeric key from the core system. The mapping from UUID back to a real person lives in the CRM, behind role-based access - which is a whole subject of its own, and the reason the taxonomy work and the PII work had to happen in the same quarter.
Deleting the old events is the other half of this. A generic payment at 154K/month, a revenue_amount at 94K/month that nobody had ever planned and nobody could explain, and the thirty-odd *_payment_success / *_payment_fail pairs all go.
The migration step everybody skips
Here's the part that gets cut when the sprint is tight, and it's the part that determines whether any of this works.
Log both schemas in parallel for two weeks before deleting anything. Old events and new events, side by side, same traffic. Then compare. If payment_completed doesn't line up with the sum of the thirty events it replaced, the mapping is wrong and you find out while the old data still exists - not a month later, when someone opens a year-on-year chart and it's a cliff.
Then, when you do delete, keep watching:
Build a deprecated_events dashboard with an alert. It counts the events that are supposed to be gone. Target zero; alert if any of them exceeds ~100/day a week after cutover. Because they will come back. Some other team's release still pushes ogpo_payment_success, or a cached bundle serves the old tracker for weeks, or an app version you forgot about is still in the wild. Without that alert you don't discover it - you just have a slow leak of events into a schema you believe is clean.
Turn on unexpected-event blocking. Amplitude Data will flag - and can reject - events outside the planned schema. Mark the 35 as planned, declare the required properties and their types, and set the alert. This is what stops the taxonomy from silently becoming 200 events again over the following three years, which is the actual failure mode. Every one of the original 200 was added by someone reasonable, shipping something reasonable, on a Tuesday.
And block the PII fields at the schema level. A rule that rejects properties matching /iin|phone|passport/ is a five-minute configuration that outlasts every code review, every onboarding, and every well-intentioned front-end developer who adds a field "temporarily, for debugging".
What we held it to
These were the acceptance criteria the work was signed off against - the bar, not a results table:
- 35 planned events in Amplitude Data, with types declared on every required property.
sums()over revenue returns a non-zero number. Sounds trivial; it was the single most-quoted proof that the migration had landed, because it had been zero for years.- Payment events per purchasing user < 1.05 - at most 5% duplication surviving dedup.
- Server event vs gateway logs within 2%. The old fragmented setup ranged from -63% to +169% against the same source of truth, which is the number that made the case for doing any of this.
- Zero PII fields in event properties and user properties.
- Zero unplanned events carrying new PII-shaped fields in a rolling week.
The takeaway
A 200-event taxonomy is never the result of one bad decision. It's the result of nobody owning the whole while thirteen product teams each did something locally sensible, and it costs you the ability to answer basic questions about your own funnel.
The collapse rule is simple enough to apply on a whiteboard: if a value is in the event name, it belongs in a parameter instead. Product, error source, photo slot, filter state, success and failure - pull all of it out and most of a 200-event mess turns out to be about thirty-five events wearing a lot of costumes.
Two things carry the rest. The money event goes server-side, because a browser can tell you someone pressed pay and cannot tell you the payment settled. And the schema gets enforced - planned events, typed properties, blocked PII fields, an alert on anything unexpected - because a taxonomy that isn't enforced is just a document describing what your events looked like on the day you wrote it down.
The one I'd argue hardest for, though, is the boring one: two weeks of dual logging before you delete anything, and a deprecated-events alert for a month after. It's the cheapest step in the project and the only one that tells you the truth about whether the rest of it worked.
