When we started building the Openaggr categorization pipeline, the obvious first move was to build a lookup table. Match the raw payment string against a dictionary of known merchants, return the associated category. Simple, auditable, easy to explain to anyone who asked how it worked.
We got roughly 40% coverage on our first real dataset. The other 60% returned nothing useful. That was the moment we stopped treating merchant matching as a lookup problem and started treating it as a classification problem with multiple weak signals that needed to be combined.
This post is about what we learned building the classification system behind Openaggr's 1,200-category taxonomy. Not the taxonomy structure itself, but the signal architecture: what inputs we use, how we weight them, and why a lookup table cannot scale past a certain point regardless of how well-maintained it is.
Why Lookup Tables Break
The failure mode of a lookup table is not that it gets the wrong answer. It is that it gets no answer. Payment processor strings are not standardized. A single merchant like a local coffee shop can appear under dozens of different string representations depending on which processor they use, whether the transaction is in-store or online, and whether their business name has changed since they registered.
Consider these real-world string patterns we see regularly in ingested transaction feeds:
SQ *BLUE BOTTLE COFFEE San Francisco CA
BLUE BOTTLE COF 00283991
BBCOFFEE*ONLINE
SQ *BLUE BOTTLE #12
BLUE BOTTLE COFFEE INC
All five are the same merchant. A lookup table that knows "BLUE BOTTLE COFFEE" maps to food_and_drink.coffee_shops will only catch two of these without additional preprocessing. Add geographic expansion across a hundred cities and thousands of merchants and the unmatch rate climbs fast.
The other issue is merchant coverage. There are approximately 30 million registered businesses in the US. Any curated lookup table covers a fraction of them, biased toward the largest chains. The long tail, local merchants, irregular vendors, government entities, utility providers, covers more transaction volume for most consumer banking populations than people expect.
The Signal Stack We Use Instead
Our approach treats each transaction as a feature vector that gets passed through a classification pipeline. The inputs are:
String normalization layer. Before any model sees the merchant name, we run it through a cleaning pass that strips processor artifacts (SQ *, AMZN*, TST*, PP*), collapses whitespace, normalizes abbreviations (ST to STREET, AVE to AVENUE), and pulls the geographic suffix from in-person transactions. This is not NLP. It is deterministic regex plus a curated abbreviation map. Output is a cleaned merchant string that is substantially more consistent than the raw input.
Token embedding. The cleaned merchant string gets embedded using a fine-tuned token model that was trained on a labeled corpus of merchant strings and their ground-truth categories. We are not using a general-purpose text embedding here. The model is specifically trained to weight tokens like "pharmacy," "fuel," and "cafe" heavily and to treat tokens like location codes and terminal IDs as noise. The embedding space clusters merchants by semantic meaning, not exact string match.
MCC code when available. Merchant category codes are set by the acquiring bank, not the merchant. They are coarse (there are roughly 600 MCC codes compared to our 1,200 category nodes), and they are frequently wrong for businesses that operate across multiple verticals. But they are a strong prior. When an MCC is present, we use it as a feature, not as a definitive answer. A transaction with MCC 5912 (drug stores and pharmacies) combined with the string "CVS PHARMACY" gets a very high confidence score. The same MCC combined with "SPECIALTY HEALTH" gets a lower confidence score and we cast wider in the category space.
Amount and time features. Transaction amount and time of day are weak signals individually but meaningful at the tails. A $4.50 charge at 8:15am that matches a coffee-adjacent string has high confidence. A $4.50 charge at 11pm with a generic merchant string produces more uncertainty. We use these as regularizing features, not primary signals.
Historical co-occurrence. For users who have enriched prior transactions, we track which categories appear together in the same account over time. This catches cases where the merchant string is ambiguous but the spending context is informative. A user who consistently has gym charges, supplement purchases, and protein-related food orders has a different profile than a user whose spending context is entirely office supplies and catering. We weight co-occurrence lightly to avoid over-fitting to individual spending patterns, but it helps at the margin.
Confidence Scoring and the Handling of Ambiguous Cases
Every category assignment from our pipeline comes with a confidence score between 0 and 1. The score is calibrated: a score of 0.85 means roughly 85% of the time the primary category is correct on held-out test data. This calibration matters because it lets downstream applications decide how to handle uncertainty.
Some of our integrators surface the primary category with no indication of confidence to their end users. Others use the confidence score to decide whether to show a "best guess" label versus prompt the user to confirm the category. Both are valid use patterns depending on the product's tolerance for error.
When the top-1 confidence score drops below a threshold (we default to 0.55, but this is configurable), we return a ranked list of up to three candidate categories. The response shape looks like this:
{
"category": {
"primary": "food_and_drink.coffee_shops",
"confidence": 0.47,
"alternatives": [
{ "id": "food_and_drink.bakeries", "confidence": 0.31 },
{ "id": "food_and_drink.restaurants", "confidence": 0.14 }
]
}
}
We are not saying you should always surface alternatives to users. In a dense spending summary view, showing three possible categories for an ambiguous transaction creates noise rather than clarity. The alternatives field exists for products that want to implement user-confirmation flows, custom labeling, or downstream analytics that need to account for classification uncertainty.
The Taxonomy Depth Decision
Getting to 1,200 categories required a deliberate decision about taxonomy depth. Most MCC-based systems have one or two levels. We run three levels: a top-level domain (e.g., food_and_drink), a mid-level type (e.g., restaurants), and a leaf node (e.g., fast_food). The leaf node is optional on the response: if the classifier cannot confidently distinguish fast_food from casual_dining, it returns the mid-level type with appropriate confidence rather than forcing a wrong leaf assignment.
The 1,200 number is the total count of leaf nodes across the full taxonomy. In practice, any given transaction will match against a subset of the relevant domain. The full taxonomy is never traversed for a single transaction; the model outputs a distribution over candidate nodes and the top scorers are returned.
Building 1,200 ground-truth labeled categories required us to make taxonomy decisions that are product decisions, not just data decisions. What is the right granularity for "health and wellness"? Should "gym membership" be a separate node from "fitness equipment"? The answer depends on what downstream PFM features you want to support. We made these decisions based on what we knew finance app product managers actually want to show their users, not based on what was convenient to train.
Where the Pipeline Still Gets It Wrong
We want to be direct about the failure modes, because over-claiming accuracy is one of the things we find most frustrating about how this space is marketed.
New merchants fail until we have seen enough examples to build a reliable embedding. The first few hundred transactions from a newly registered merchant that has no prior signal in our system will get classified based on the string alone, and the string is often uninformative. We see this most with business-to-consumer service providers: cleaning services, tutors, personal trainers. Their payment strings are frequently just the business owner's name with a square reader suffix.
International merchants in non-Latin scripts require a separate processing path. Our cleaning layer was designed for Latin-script strings. We have handling for the most common international encoding patterns, but it is not as mature as the core pipeline.
Merchants that operate across multiple categories, a gas station with a convenience store, a superstore that sells everything from groceries to electronics, produce legitimate disagreement between the string signal and the MCC signal. We handle these with a mixed-category flag on the response rather than forcing a single category assignment.
What This Means If You Are Building on Top of It
If you are integrating the Openaggr enrichment API into a PFM layer, a few practical notes on using category signals well:
Do not throw away the confidence score. Even if you are not surfacing uncertainty to your end users, the confidence field is useful for deciding which transactions to exclude from summary calculations. A spending breakdown that includes several low-confidence miscategorized transactions will look wrong to users. Filtering to confidence above 0.7 before aggregating reduces that noise substantially.
The three-level taxonomy gives you flexibility in how you present categories. If your app targets users who want high-level insight, group at the mid-level type. If you are building something more analytical, the leaf nodes give you enough resolution to compute meaningful per-category trends over time.
Category labels in the response are machine-readable identifiers, not display strings. We deliberately do not return "Coffee Shop" as a display label because display labeling is a product decision that varies by brand, locale, and audience. Map food_and_drink.coffee_shops to whatever your design calls for.
We built this pipeline because we kept seeing the same pattern: neobanks spending weeks building category models that were both hard to maintain and narrower than what a dedicated enrichment system could provide. The lookup-table instinct is understandable, but the ceiling is low. If you want to cover the long tail of real-world transactions, you need a classification system built on multiple weak signals, not a single match against a curated dictionary.