What is in the card catalog
Every number on this site comes out of one hand-maintained file. This is that file, field by field — including the fields that exist only to stop the site claiming something it cannot back.
Reviewed: Data verified 2026-09-15 · Credit Upside editorial · Updated 2026-09-16 · How we rank
The card catalog, field by field
comes out of: 286 cards, 907 earn rules, 234 statement credits, maintained by hand. This document describes it completely, including the fields that exist only to stop the site claiming something it cannot back.
It is published for the same reason the point values are. The moat here is not the data — anyone can read an issuer's page — it is knowing *when each row was last checked and against what*. A schema you can read is how you judge whether to believe the numbers.
Counts below are as of 2026-09-14 and move with the catalog; the shapes do not.
The file
{
"schemaVersion": "1.1",
"generatedAt": "…",
"source": "…",
"pointValues": { "MR": 0.011, "UR": 0.0125, "CASH": 0.01, … },
"cards": [ … ]
}
dollar figure on the site is points × pointValues[currency]. The values are deliberately mid-range and published at /points; npm run points-check compares them monthly against One Mile at a Time and reports, never applies.
The file is stored ASCII-escaped (\uXXXX, not literal UTF-8). Anything that rewrites it must keep that, or a one-field edit churns 274 unrelated lines.
A card
Every card carries all of these:
| field | type | meaning |
|---|---|---|
id | string | Stable slug, issuer_card_name. Also the URL: /cards/<id>. Never reused. |
name | string | The issuer's own name for the card, including ®/℠. |
issuer | string | Who underwrites it, not who it is branded for. |
network | string | Visa / Mastercard / American Express / Discover. |
currency | string | Reward currency key into pointValues (MR, UR, CASH, HILTON…). |
annualFee | number | In feeCurrency, or USD when absent. 0 means no fee. |
fxFee | number | Foreign transaction fee as a fraction: 0.027 is 2.7%. 0 is none. |
color | string | #rrggbb for the card chip. Validated before it reaches any style. |
cardType | string | personal or business. |
status | string | open (accepting applications) or discontinued (still scored for people who hold one, never recommended). |
country | string | US, CA, MX. Guides are per-country; scoring is not. |
earn | array | The earn rules. See below. |
credits | array | Statement credits. See below. |
perks | array of strings | Non-cash benefits, in the issuer's words. Never valued in dollars. |
verifiedDate | date | The day the full terms were last re-read. The card page prints it. |
sources | array of URLs | Where the rows came from. Checked for link rot by npm run source-check. |
Optional, and each one exists to prevent a specific wrong claim:
| field | when present | why it exists |
|---|---|---|
officialUrl | 275 cards | The issuer's own page. offer-check reads it. |
annualFeeUnknown | flag | The fee is not published. Better than guessing zero. |
isRetailCobrand | flag | Store cards are excluded from general rankings. |
baseRateUnknown | 38 cards | The issuer does not publish a base rate. Charges falling through to it are scored as unknown, not as zero — otherwise every ordinary purchase on such a card looks like a total loss. |
feeCurrency | 37 cards | Non-USD fee (CAD, MXN). The fee is not silently converted. |
shortName | 22 cards | A shorter name only where the issuer itself uses one. Never invented. |
conversionNotes | 102 cards | What a downgrade/upgrade path actually does. |
welcomeBonusText | 96 cards | The offer in the issuer's words, kept alongside the parsed figure. |
signup | 98 cards | The parsed welcome offer. See below. |
conditionalCredits | 4 cards | Credits that depend on spend or status; worth $0 until the holder says otherwise. |
offerVaries | 3 cards | The bonus is targeted and differs per applicant. |
membershipRequired | 2 cards | Needs a separate membership (AAA, a credit union). |
correctedOn | date, when set | The day our own earn or credit rows were fixed without a full re-read (so verifiedDate did not move). When it changes, the /changes/ diff records none of that card's earn or credit differences, which were our error rather than the issuer's move. A change to verifiedDate does the same. Fees, offers and status are still recorded. Set it on every such fix. Change alerts email cardholders from that log. |
earn — 907 rules
{ "category": "groceries", "rate": 4, "cap": 25000, "capPeriod": "year",
"note": "…", "portalOnly": false, "brand": null }
| field | meaning |
|---|---|
category | One of the taxonomy's keys, or * for the catch-all base rate, or ROTATING / CHOICE. |
rate | Points (or percent, for cash cards) per dollar. |
cap | Spend ceiling in dollars before the rate drops to base. |
capPeriod | year, quarter, month, anniversary. |
note | The issuer's qualification, verbatim where it matters. |
portalOnly | The rate applies only through the issuer's travel portal. 98 rules. |
brand | The rate applies only at this brand. |
choice / choiceOptions | The holder picks the category (Custom Cash, Venmo). 17 rules. |
rotating / quarters | 5%-style quarterly categories, keyed 2026Q4 → list of categories. 5 rules. |
quartersAnnouncedThrough | The last quarter the issuer has announced. Past it, the rate is unknown, not base. |
portalOnly and brand are load-bearing. A 5x rule that only pays through a portal, ranked as if it paid everywhere, puts the wrong card at #1. That has shipped three times; npm run catalog-check now fails the deploy on a rule of 3x or more that names a brand or portal in its note without the flag.
credits — 234 statement credits
{ "id": "amex_platinum-airline-fee-credit", "label": "Airline Fee Credit",
"amount": 200, "period": "year", "cadence": "quarterly",
"categories": ["flights"], "enrollmentRequired": true,
"automatic": false, "resetsOn": "anniversary", "endsOn": "2027-12-31" }
| field | meaning |
|---|---|
amount | Face value per period. |
period | Almost always year — the denominator for amount. |
cadence | How it is actually released: monthly, quarterly, semiannual, annual, or a multi-year cadence. This is what decides its real worth: a $10/month credit is twelve separate $10 chances, and one December dinner cannot backfill eleven missed months. |
categories | Where it can be spent, in taxonomy keys. |
enrollmentRequired | The holder must opt in, or it pays nothing. 93 credits. |
automatic | Posts without a matching purchase. 40 credits. |
resetsOn | anniversary when the year runs from the open date, not the calendar. 32 credits — these are excluded from /credits-reset/, because the reset date depends on when *you* opened the card. |
endsOn | A promotional credit with a published end date. 35 credits. Past it the engine counts it as zero and catalog-check fails the deploy. |
Credits are never summed and presented as value. The app allocates them against the holder's own charges, narrowest first, so two credits cannot be paid out of the same dollar — which is how a $795 card ends up "worth" $1,800 elsewhere.
signup — 98 welcome offers
{ "bonusPoints": 100000, "spendReq": 8000, "months": 6,
"offerVerifiedDate": "2026-09-14", "offerEnds": "2026-11-04",
"note": "…", "checkNote": "…" }
| field | meaning |
|---|---|
bonusPoints | The bonus in the card's own currency. Cash offers are stored in cents-as-points: a $300 bonus is 30000 with currency: "CASH". |
spendReq / months | What it takes to earn it. |
offerVerifiedDate | The day the figure was confirmed on the issuer's page. A smaller claim than verifiedDate and deliberately a separate field — conflating them once made 41 cards publish a verification nobody had performed. catalog-check fails a deploy on an offer older than 60 days. |
offerEnds | The issuer's own end date. 13 offers. Past it the bonus counts as zero, the guides stop ranking it, and the deploy gate fails. |
note | The caveat that travels with the number: "as high as", "plus a Free Night Reward", "first tier of up to 115,000". |
checkNote | Why an automated check cannot read this one — Amex renders client-side, Citi injects from an API, AAA is behind a bot wall. 26 offers. |
A bonus is never counted in savings math. It is a year-one event, stated on its own, and a bonus you do not clear is worth zero.
What is deliberately not in the file
- Affiliate links. They live in
affiliates.json, and a test asserts the
ranking code cannot read it. Today no link pays anything and the footer says so.
- Approval odds, credit-score requirements, "recommended for" labels. We do
not have the data, and inventing it is how a comparison site becomes an ad.
- Estimated values for perks. Lounge access is listed; it is not priced.
- Anything about a person. No card in this file knows who holds it.
Keeping it honest
| command | what it checks |
|---|---|
npm run catalog-check | Freshness, portalOnly violations, offers past 60 days, credits and offers past endsOn/offerEnds. Gates every deploy. |
npm run offer-check | Is each welcome bonus still on the issuer's page? |
npm run offer-check-browser | The same, for pages only a browser can read. |
npm run source-check | Link rot across every card's sources. |
npm run points-check | Our point values against One Mile at a Time. |
npm run offers-check | The card-linked offers feed. |
npm run monthly | All of the above, into one dated report. |
Adding a card does not need a deploy: insert a row into D1 catalog_extra and the Worker serves it merged within about five minutes.
*Corrections are welcome and land in the same place everything else does: the report link at the foot of any card page.*
How we value points · What changed · The card database · Open data and feeds