Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

What's actually inside a card statement line? Decoding transaction descriptors

If you have ever built anything on top of bank or card data, you have met strings like these: POS 4521 CAREEM*RIDE DXB AE PAYPAL *NETFLIX 4029357733 SQ *BLUE BOTTLE COF SAN FRANCISCO CA شراء كارفور الرياض Your u

If you have ever built anything on top of bank or card data, you have met strings like these:

POS 4521 CAREEM*RIDE DXB AE
PAYPAL *NETFLIX 4029357733
SQ *BLUE BOTTLE COF SAN FRANCISCO CA
شراء كارفور الرياض

Your users see them on their statements and ask "what is this charge?". Your support team sees them in disputes. Your analytics team wants to group spend by merchant and category. And the string you have is a cramped, truncated, half-encoded line that was never designed to be read by a human.

This post walks through where these strings come from, why they look the way they do, and how to turn them into something useful.

Where the descriptor comes from

When a card is used, the merchant's terminal or payment gateway sends an authorization message through the card network. One field in that message carries the card acceptor name and location. In the ISO 8583 layout most networks build on, it is a fixed-width field of roughly 40 characters, usually split into:

Part Typical width Example
Merchant name ~22 to 25 chars BLUE BOTTLE COF
City ~13 chars SAN FRANCISCO
Country 2 chars CA / AE

Then the issuing bank takes that field, adds its own prefixes and reference numbers, and prints the result on the statement. Every bank does this a little differently.

The anatomy of a messy line

Take POS 4521 CAREEM*RIDE DXB AE apart:

  • POS: added by the bank. It says the card was present at a point of sale (or the bank's idea of one). Other banks write PURCHASE, CARD PURCHASE, شراء, or nothing.
  • 4521: a terminal ID, last four card digits or a sequence number. Noise for our purposes.
  • CAREEM*RIDE: the merchant name plus a soft descriptor after the *. Merchants use the part after the asterisk to say which product you bought.
  • DXB: the city, here as an airport code. Other lines say DUBAI, DUBAI AE, or دبي.
  • AE: the country code.

Now PAYPAL *NETFLIX 4029357733:

  • PAYPAL *: the payment facilitator prefix. The real merchant is behind it. You will see the same pattern with SQ * (Square), SP * (Shopify Payments), GOOGLE *, APPLE.COM/BILL, AMZN Mktp, and many regional aggregators.
  • NETFLIX: the actual merchant.
  • 4029357733: a customer service phone number. Merchants are encouraged to put one in the descriptor so you can call them instead of disputing.

The last one, شراء كارفور الرياض, is "purchase, Carrefour, Riyadh" in Arabic. Banks across the Gulf and North Africa print descriptors in Arabic, in Latin script, or a mix of both, sometimes in the same statement.

Why regex alone breaks

The first instinct is a lookup table and some regular expressions. It works for your first 50 merchants and then falls apart:

  1. Truncation. The name field is short, so BLUE BOTTLE COFFEE becomes BLUE BOTTLE COF, and TALABAT GENERAL TRADING becomes TALABAT GENERAL TRA.
  2. Facilitators. A rule for NETFLIX has to survive PAYPAL *NETFLIX, NETFLIX.COM, NETFLIX.COM 866-579-7172, and whatever your bank adds in front.
  3. Same name, different merchant. A short name can belong to two unrelated businesses in two countries.
  4. Line types that are not purchases. Refunds, declines, transfers, card fees, interest, cashback and ATM withdrawals all show up in the same feed and should not be categorized as a "merchant".
  5. Scripts and spellings. CARREFOUR and كارفور are the same store. DXB, DUBAI and دبي are the same city.
  6. The long tail. There are millions of small merchants. No hand-written table will cover them.

A pipeline that does work

A practical approach is a cascade, cheapest step first, where the first step that answers wins:

  1. Cache. Statements repeat. If you have resolved this exact string before, return the same answer.
  2. Rules. Reviewed patterns for known merchants and facilitators. High precision.
  3. Fuzzy matching. Normalize the string (strip bank prefixes, reference numbers, phone numbers, city and country) and compare against known merchant names. This catches truncation and typos.
  4. A language model, as a last resort. For a string nobody has seen before, a model can usually read the merchant out of the line. Treat that answer as a suggestion: mark it unverified, give it a lower confidence, and queue it for human review.

Whatever you build, return a confidence and a source with every answer. Your UI needs both. A reasonable starting point:

  • 0.85 and above: show the merchant as fact.
  • 0.6 to 0.85: show it, but let the user correct it.
  • Below 0.6: show the cleaned descriptor instead of guessing.

What a good result looks like

Here is the shape I settled on, for POS 4521 CAREEM*RIDE DXB AE:

{
  "descriptor": "POS 4521 CAREEM*RIDE DXB AE",
  "normalized": "CAREEM RIDE",
  "merchant": {
    "name": "Careem",
    "website": "https://www.careem.com",
    "category": "transportation",
    "subcategory": "ride_hailing",
    "mcc": "4121"
  },
  "location": { "city": "Dubai", "country": "AE" },
  "processor": null,
  "is_subscription": false,
  "confidence": 0.97,
  "source": "rules",
  "verified": true,
  "kind": "purchase"
}

A few design choices worth copying even if you build this yourself:

  • Keep the original descriptor untouched next to a normalized version. You will want both for debugging.
  • Separate the merchant from the processor, so PAYPAL *NETFLIX gives you Netflix as the merchant and PayPal as the rail.
  • Add a kind field (purchase, refund, declined, transfer, bank) so fees and cashback lines never get counted as spending at a merchant.
  • Return null for the merchant when you do not know. A wrong logo is worse than no logo.

If you would rather not build it

Full disclosure: I ended up turning this pipeline into a product, DescriptorAPI, mostly because I kept needing it and nothing covered Middle East and Africa descriptors well. It is the cascade above (cache, rules, fuzzy, AI) behind one endpoint:

curl https://api.descriptorapi.com/v1/enrich \
  -H "Authorization: Bearer $DESCRIPTORAPI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"descriptor": "POS 4521 CAREEM*RIDE DXB AE", "country": "AE"}'

There is a playground that runs without an API key, and the free tier is 500 enrichments a month. You are only billed when a merchant comes back, so unknown strings cost nothing.

But the main point of this post stands on its own: descriptors are structured data in disguise. Once you know which part is the bank, which part is the facilitator, and which part is the merchant, most of the mess becomes manageable.

What is the strangest descriptor you have seen on a statement? Drop it in the comments and I will break it down.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.