265 ms to a category: building with Jev
A payment comes in. Jev picks a category. Here’s how we got to a 265 ms median in a small dev test, and how past customer corrections help with the next decision.
- Category
- General
- Updated
- Author
- Stan Kharlap
A payment comes in. There’s a merchant name, an amount, maybe a few useful words in the reference. Now we need to pick a category. It’s a small job, but it comes up over and over in a bank import.
We started using Jev for categorization in production on September 25, 2026. In a small dev test with synthetic payments, it returned a category in 265 ms at the median, with a 302 ms p95. All 30 requests came back with a category from the list we supplied.
Here’s how we put it together: give Jev a clear set of choices, bring along the bookkeeping instructions we already use, and show it a few relevant decisions the customer made before. Then make sure the app has a suggestion to show even when the answer is a little shaky.
Give Jev a small, clear job
Jev is TypeSafe’s decision model. Its Choice feature takes a set of options and picks one. That’s a good fit here: we already have a chart of accounts, and we want a category from it. More on Jev.
Our code builds the list before calling the model. Categories have to be active, belong to the right company and account type, and fit the payment direction. Eligible equity accounts can be included where they apply. Jev gets that list along with the merchant, payment reference, amount and a little context about the business.
We also bring over the core bookkeeping guidance from our existing categorizer: transaction examples, vendor hints and the current classification instruction. That prompt already contains useful knowledge. We keep it and ask Jev to return one of the options.
Keep the trip short
We start with explicit accounting rules. A suitable merchant pattern that the customer has confirmed repeatedly can also settle the choice. Those cases don’t need a model call.
That shortcut has limits. A payment reference may explain what was actually bought, and the same supplier may appear under several categories. In either case, we send the decision through the route that considers the full context.
For each payment that reaches Jev, we make one direct request to TypeSafe. We reuse the HTTP client. During an import group, we load the categories and history once, then pick relevant examples for each payment. The model still gets one request per transaction.
When the answer comes back, we check its category ID against the list we sent. The TypeSafe API docs cover the request format. The core idea in our code looks like this:
options = eligible_categories(
company, direction,
)
examples = relevant_choices(
payment, limit=5,
)
choice = jev_choose(
payment, options, examples,
)
if choice in options:
return choice
return general_category(options)
This sketch leaves out rules, tracing and error handling. We’ll get to the fallback in a moment.
A supplier name only tells you so much
Take our fictional Atelier Nord GmbH. One invoice is for a software license, another for a training course, a third for equipment rental. Same supplier, three different purchases. Remembering the last category used for that name can easily send the next payment to the wrong place.
We already have saved customer corrections, plus category changes in the audit log. So we use those records to find useful examples for the next request. Each example must belong to the same company and payment direction. The saved choice must still match the transaction’s current category, and that category must be in the allowed list.

We give matches in the payment purpose more weight than matches in the supplier name. Dates, reference numbers and routine banking text get stripped out for matching. If two payments have clearly different purposes, sharing a supplier name won’t make them examples for each other.
Up to five relevant examples go into the prompt. If we find fewer, we send fewer. The search currently matches words, so synonyms and translations can slip through. That gives us a concrete area to improve.
We covered the starting point in our post on company memory. This version gets more selective about which past choices it brings along. A newly saved correction can help the next request without a separate training run: we update the context, while the model’s weights stay the same.
Give the customer something to work with
A blank category leaves the whole decision to the customer. We want to offer a starting point, even for a messy payment reference.
With AI suggestions enabled and at least one eligible category, we accept a valid Jev choice even at low probability. If the response is invalid, the call times out or the API fails, our code picks a local fallback. It prefers the chart’s general expense or income category. An empty category list needs a configuration fix first.
We record low-confidence choices and fallbacks separately in the trace. TypeSafe returns both a choice probability and a confidence signal; its confidence guide explains them. We inspect those signals and measure accounting accuracy separately.
The result stays a suggestion the customer can check and change. VAT treatment and finalization are separate steps. Rules that require review still take priority, and turning off AI automation is respected.
Where the 265 ms comes from
On September 25, 2026, we ran six fictional expense scenarios five times each using jev-1.13.0. Each request had 68 categories to choose from. Calls went out one at a time from our dev backend, with the scenario order rotated each round.
| Measure | Result |
|---|---|
| Median adapter time | 265 ms |
| p95, nearest-rank method | 302 ms |
| Slowest request | 791 ms |
| HTTP 200 responses | 30 of 30 |
| Eligible categories returned | 30 of 30 |
The first request was the slowest at 791 ms, and it’s included. We reused HTTP connections, supplied history in memory and switched off trace writes. The timer covers building the prompt, ranking examples and the API round trip. The browser, bank import, OCR, history database reads and saving the transaction sit outside it.
This is a small dev snapshot, with repeated inputs and provider-side caching we didn’t control. It tells us about this part of the implementation; production latency, performance under load and comparisons with other products still need their own tests. You can inspect the raw data and method.
Keep score as the history grows
All six scenarios got the categories we expected in this run. To find out how well this carries over to customer payments, we need independent examples with reviewed answers.
We track two things separately: coverage, how often there’s a category to review, and agreement, how often it matches the reviewed answer. The first tells us whether the flow keeps moving. The second tells us whether the suggestions are useful.
Our evaluator runs the same case with and without history. Test transactions stay out of every prompt, and examples must have been confirmed before the payment being tested. Records edited after that cutoff and merchant summaries without reliable dates stay out too. Otherwise, it’s easy to accidentally give the model the answer and congratulate ourselves on the result.
Our current dev sample has no independent earlier history left after those filters, so we don’t have a measured accuracy gain to share yet. That’s the next thing to establish. For now, we have a quick category suggestion, the customer’s past choices ready to help, and a way to check whether each change actually makes the suggestions better.
Frequently asked questions
- How fast is it?
- Our small dev test on September 25, 2026 clocked a 265 ms median and a 302 ms p95 across 30 calls. We repeated six fictional scenarios five times, with 68 categories to choose from. That covers our adapter and the API round trip; loading the history and saving the transaction sit outside the timer.
- Does Jev learn from customer corrections?
- We feed it up to five relevant past choices from the same company. As customers save new corrections, those become available for the next request. The model itself is not retrained. We still need independent cases to measure how much this helps.
- What if Jev is unsure or the API goes down?
- With AI suggestions enabled and categories available, we keep a valid choice even at low confidence. An error or invalid answer triggers a local fallback, usually a general expense or income category. The customer can review the suggestion; VAT checks and finalization remain separate.
Norman handles the operational finance work behind the scenes
From invoicing to bookkeeping, Norman keeps recurring finance work organized so you can stay on top of deadlines with less manual effort.