Canonical note: this tutorial is published by the Merchant Cleanup team, who build the API shown in part 3. The reference docs live at merchantcleanup.pages.dev/docs.
If you've built a budgeting app, a bookkeeping export or a spending report, you've seen bank lines like these:
POS PURCHASE SQ *MAPLE STREET COFFEE 1234 OAKLAND CA
TST* JOES DINER #0042 AUSTIN TX
PAYPAL *PINEHILLBOOK 4029357733 CA
RIVERSIDE FUEL 00123 DENVER CO
Your users want to see "Maple Street Coffee: meals", not the raw string. This post covers what that noise is, a do-it-yourself cleaner, where the DIY version falls short, and an API option.
(All merchants in this post are made up.)
1. What the prefixes mean
A card descriptor is assembled by the merchant's payment processor and the card network, not by the merchant. The common pieces:
| Piece | Example | Meaning |
|---|---|---|
| Processor prefix |
SQ *, TST*, PAYPAL *
|
The payment processor or platform that took the payment (a point-of-sale provider, a restaurant POS, a wallet) |
| Channel words |
POS PURCHASE, DEBIT CARD
|
How the bank recorded it |
| Store/terminal number |
#0042, 1234, 00123
|
Location or terminal ID |
| Phone number | 4029357733 |
Often the merchant's support line for online payments |
| City and state | AUSTIN TX |
Where the terminal is registered |
Descriptors are usually capped at around 22 to 25 characters for the merchant part, so names get truncated or squashed together (PINEHILLBOOK).
2. The DIY approach: a regex cleaner
For a first pass, strip known noise and title-case what's left:
const PREFIXES = /^(POS PURCHASE|DEBIT CARD PURCHASE|POS)\s+/i;
const PROCESSOR = /^(SQ \*|TST\* ?|PAYPAL \*)/i;
const TRAILING = /\s+(#?\d{3,}|\d{10})(\s+.*)?$/; // store numbers, phones, then city/state
function cleanDescriptor(raw) {
let s = raw.trim().replace(PREFIXES, "");
const processor = (s.match(PROCESSOR) || [null])[0];
s = s.replace(PROCESSOR, "").replace(TRAILING, "");
const merchant = s.toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
return { merchant, processor };
}
cleanDescriptor("TST* JOES DINER #0042 AUSTIN TX");
// { merchant: "Joes Diner", processor: "TST* " }
This gets you surprisingly far on a handful of known formats. Its limits show up quickly:
- Prefix lists drift. Every processor has its own format, and new ones appear. You'll maintain the list forever.
-
No trailing number means no cut.
MAPLE STREET MARKET OAKLAND CAkeeps the city, because the regex only trims after a number. -
Squashed names stay squashed.
PINEHILLBOOKneeds word knowledge, not a regex. - No category. A regex can tell you the text, not that it's a fuel station or a diner. For that you need keyword rules, a merchant list, or the merchant category code (MCC) if your data source provides one.
If your volume is small and your formats are fixed, the regex may be all you need. If not, read on.
3. Using the Merchant Cleanup API
Merchant Cleanup is a small REST/JSON API: send descriptors, get back a cleaned merchant name, one of 23 categories, the detected processor and a confidence score from 0 to 1. The confidence is a rough guide to how much evidence there was, not a measured accuracy rate, and results are automated suggestions, so check anything that matters for taxes or bookkeeping.
Try one line without a key using the demo endpoint (one descriptor per request, limited per IP per day):
curl "https://merchantcleanup.pages.dev/v1/demo?q=TST*%20JOES%20DINER%20%230042%20AUSTIN%20TX"
For real use, get a free key at /signup (email only, no card). Keys start with mcl_; keep them server-side.
curl
curl -X POST https://merchantcleanup.pages.dev/v1/clean \
-H "Authorization: Bearer $MCL_KEY" \
-H "Content-Type: application/json" \
-d '{"descriptor": "POS PURCHASE SQ *MAPLE STREET COFFEE 1234 OAKLAND CA"}'
Real response:
{
"input": "POS PURCHASE SQ *MAPLE STREET COFFEE 1234 OAKLAND CA",
"merchant": "Maple Street Coffee",
"category": "meals",
"confidence": 0.6,
"match": "keyword",
"processor": "Square"
}
match tells you how the result was reached: merchant_list, keyword, mcc, processor_hint, cleaned_text or none.
JavaScript (Node 18+, built-in fetch)
Batch requests take up to 10 descriptors on the Free plan. Each item can be a string or an object with an optional mcc and amount:
const res = await fetch("https://merchantcleanup.pages.dev/v1/clean", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MCL_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
descriptors: [
"TST* JOES DINER #0042 AUSTIN TX",
"PAYPAL *PINEHILLBOOK 4029357733 CA",
{ descriptor: "RIVERSIDE FUEL 00123 DENVER CO", mcc: "5541", amount: -38.2 },
],
}),
});
const { results } = await res.json();
for (const r of results) console.log(r.merchant, "->", r.category, r.confidence);
Real output:
Joes Diner -> meals 0.5
Pinehillbook -> other 0.45
Riverside Fuel -> auto_and_transport 0.7
Two things worth noticing. Sending the MCC helped: the fuel line came back as keyword+mcc with an mcc_description of "Service Stations". And the squashed PINEHILLBOOK came back as Pinehillbook with category other and a lower confidence. That's the API being honest about thin evidence, and it's a good place to set a threshold and let users correct the result.
Python (requests)
import os, requests
resp = requests.post(
"https://merchantcleanup.pages.dev/v1/clean",
headers={"Authorization": f"Bearer {os.environ['MCL_KEY']}"},
json={"descriptors": [
"TST* JOES DINER #0042 AUSTIN TX",
{"descriptor": "RIVERSIDE FUEL 00123 DENVER CO", "mcc": "5541"},
]},
timeout=10,
)
resp.raise_for_status()
for r in resp.json()["results"]:
if "error" in r:
print("could not read:", r["input"], r["error"])
elif r["confidence"] < 0.5:
print("review:", r["input"], "->", r["merchant"])
else:
print(r["merchant"], r["category"])
Batch results come back in input order, and a line that can't be read returns an error in its slot instead of failing the whole batch.
Errors to handle
-
401: missing or invalid key -
413: batch larger than your plan allows (more than 10 descriptors on the Free plan) -
429: you went well over the fair-use rate; back off and retry
Pricing, briefly
The Free plan is available now: get a free key with just an email (no card) and send up to 10 descriptors per request. Batch size is the only hard limit. The per-minute number (about 10 requests a minute on Free) is a fair-use guideline, not a cap or a guarantee. Paid plans are coming soon.
Details are in the docs.
Wrap-up
- Processor prefixes like
SQ *,TST*andPAYPAL *name who processed the payment, not the merchant. - A regex cleaner handles fixed formats but needs constant upkeep and can't assign categories.
- An API call gives you a name, a category, the processor and a confidence you can threshold on.
The OpenAPI spec works with most client generators. Questions or a descriptor it handles badly? Leave it in the comments (with any real merchant details removed).
Top comments (0)