Blog
Why Our Chargeback AI Doesn't Trust Itself
If you've ever run an online store, you know the feeling: a chargeback notice lands, the clock starts at 48 hours, and the evidence you need to fight it is scattered across three different systems. Here's how we built Proofly, a self-hosted AI copilot for chargeback disputes, and why the most important part of the system isn't the AI at all.
The problem: evidence everywhere, deadline nowhere
An "item not received" chargeback sounds simple until you try to answer it. Was the order actually delivered? Was a refund already issued? Does the merchant's own shipping policy even cover this case? That information lives in different tables, different documents, and sometimes in a support agent's memory, and the merchant has to pull it together before the deadline passes, or the dispute is lost by default.
Proofly's job is to collapse that scramble into one screen: put in an order ID, get back a plain-English summary of what happened, a contest-or-accept recommendation with a confidence score, and the exact policy clause the recommendation is based on.

How it works

A case moves through five steps:
- Read: a read-only, allowlisted connector pulls the order, payment, fulfillment, refund and return rows for that order ID from the merchant's own database.
- Retrieve: the matching policy documents are found by keyword overlap with the evidence. No vector database, no embeddings.
- Review: the model gets the evidence and the policy excerpts and must answer with strict JSON: a recommendation (CONTEST, ACCEPT or MANUAL_REVIEW_REQUIRED), a confidence score, a short summary and exact quotes from the policy.
- Verify: every quote is checked against the stored policy text, and the recommendation is checked against what the connector read.
- Decide: a person approves the case and exports the evidence report, or the case lands in the manual review queue.
The safety net: code that overrules the model
It would have been easy to build a chatbot that reads some data and gives an opinion. We didn't want that, because a confidently wrong AI answer on a dispute with a hard deadline is worse than no answer at all.
So after the model answers, a few lines of plain code compare its recommendation with facts the connector read itself. When they disagree, the case goes to a human:
// Deterministic safety net: if the model's answer contradicts what the
// connector itself read from the merchant's database, don't trust it.
if (recommendation.equals("CONTEST") && refundIssued) {
caveat = "The AI recommended contesting, but your records show a refund was already issued.";
recommendation = "MANUAL_REVIEW_REQUIRED";
} else if (recommendation.equals("ACCEPT") && !refundIssued) {
caveat = "The AI recommended accepting, but there's no refund on record yet.";
recommendation = "MANUAL_REVIEW_REQUIRED";
} else if (returnOnFile && !refundIssued) {
caveat = "The customer has a return request on file. Check it before deciding.";
recommendation = "MANUAL_REVIEW_REQUIRED";
}The third rule exists because of testing. The prompt already told the model to ask for manual review when there was an open return, and models still confidently recommended contesting orders with an unresolved return for a defective item. Rather than keep rewording the prompt, we made it a rule the model can't argue with.
Small details matter here too. The refund check reads every refund row for the order, not just the first, because an order can have a failed refund followed by a successful one.
Citations that are checked, not trusted
Models are good at sounding like they're quoting a document. So Proofly asks for quotes copied exactly from the policy excerpt, then keeps only the ones that really appear, word for word, in the document the model named:
// A citation survives only if the quote appears word for word
// in the policy document the model says it came from.
for (JsonNode citation : citationsNode) {
String title = citation.path("documentTitle").asText("");
String quote = citation.path("quote").asText("");
matches.stream()
.map(Match::document)
.filter(doc -> doc.getTitle().equalsIgnoreCase(title))
.filter(doc -> !quote.isBlank() && doc.getContent().contains(quote))
.findFirst()
.ifPresent(doc -> verified.add(
new Citation(doc.getId(), doc.getTitle(), doc.getVersion(), quote)));
}The check runs against the full stored policy text, so "the policy says X" on the merchant's screen is something they can verify, not something the model made up.
Built to run on the merchant's own machine
Proofly is designed as a self-hosted, single Windows installer with an embedded database, not a SaaS product sitting on someone else's cloud with the merchant's data. That meant a few specific choices:
- A read-only, allowlisted database connector with schema-validated queries, so the AI layer can never write to the merchant's order data.
- A setup wizard that maps a merchant's own tables and columns, including what an "issued" refund looks like in their schema, instead of forcing a fixed one.
- Bring-your-own model: Anthropic, OpenAI-compatible APIs, or a local model, with the provider key encrypted at rest.
- A full audit trail of what was read, what was recommended, what the safety net changed and what a person approved.
- Evidence reports carry a SHA-256 fingerprint of their content, so any edit after approval changes the code.
Stack: Spring Boot, React, TypeScript, PostgreSQL and Anthropic, packaged for Windows.
What we deliberately left out
Some simple choices have known limits, and we wrote them down instead of hiding them:
- Keyword retrieval instead of embeddings. It works for a merchant with a handful of policy documents. Passage-level or vector retrieval is the upgrade if a policy library grows to the point where keyword overlap stops telling documents apart.
- Policy excerpts are capped at about 24,000 characters per request, so a long PDF doesn't make every case slow or overflow the model's context. Citations are still checked against the full text. If relevant clauses sit past the cap, chunked retrieval is the next step.
Proofly is a working build that isn't live yet, so we're not claiming usage numbers or results. This post is about how it's built.
The takeaway
The interesting engineering problem in "AI for high-stakes decisions" usually isn't the model call, it's everything around it. A recommendation nobody can verify is just a guess with better grammar. What made Proofly worth building was the boring half: read-only access, schema validation, citation checks, and a deterministic layer willing to overrule the AI when the merchant's own records say otherwise.
If you're building AI into a workflow where being wrong has a deadline attached, that's the part to get right first.
Read more about the build in our Proofly case study, or get in touch if you're working on something similar.