Build a rewards optimizer
A rewards optimizer answers one question: of the cards in this wallet, which one earns most here? It needs every reward rate of each card, matched on canonical categories, and a value per point to compare points, miles and cash back.
The plan you need
Starter plan and aboveStarter ($29/mo) returns every field and endpoint below. It starts with a 7-day free trial.
Prototype on the Free plan: it returns each card's basic fields and top 3 reward rates. See what each plan returns.
Fields
- name
- reward_currency
- reward_currency_name
- reward_rates
Types and descriptions: the card object.
Endpoints
- GET /v1/cards/:slug — Get a card
- GET /v1/categories — List spending categories
- GET /v1/reward-valuations — List reward valuations
Going further
- Rank the whole market for a category, not just a walletGET /v1/cards/bestEvery plan
- Value points at their transfer partnersGET /v1/transfer-partnersPro plan and above
- Spend thresholds that unlock a higher ratespend_bonusesPro plan and above
Displaying and caching the data
- Cache what you fetch for as long as your plan is active (ISR, a database, a KV store); re-fetch on a schedule so changes reach your users. The data is re-checked against issuer sites every Friday, so a daily or weekly refresh is plenty.
- Match rates on categories, merchant, booking_channel and choice_group, never on the category_slug label: a rate limited to one merchant or the issuer's travel portal is not a general rate for its category.
- Show the data to your own users. Don't re-serve it raw: no public API or feed of your own, no CSV or spreadsheet download, no bulk export. A backend that only your own site or app calls is fine.
- Data fetched during the Starter trial is for evaluation only until the trial becomes a paid plan: build and test on it, publish once the plan is paid.
The full rules are in the Terms of Service.
Code
Runs on your own key (CARDAPI_API_KEY) and fetches at runtime: nothing here ships card data. Uses @cardapi/client 0.4.0 or later.
npm install @cardapi/client
import { CardAPI } from "@cardapi/client";
const api = new CardAPI({ apiKey: process.env.CARDAPI_API_KEY! });
// picks: for cards with a choice category (Citi Custom Cash, BoA Customized
// Cash), the categories the user chose, by card slug. Without one, a choice
// rate still counts and the result says it depends on that pick.
export async function bestInWallet(
walletSlugs: string[],
category: string,
picks: Record<string, string[]> = {}
) {
const [cards, valuations] = await Promise.all([
Promise.all(walletSlugs.map((slug) => api.getCard(slug))),
api.getRewardValuations(),
]);
// Card programme names are not always spelled like the valuation's
// ("Ultimate Rewards" vs "Chase Ultimate Rewards"), so match loosely.
// A programme with no match is valued at 1 cent per point.
const centsPerPoint = (name: string | null | undefined) => {
const n = (name ?? "").toLowerCase();
const v = n
? valuations.find((v) => {
const m = v.currency_name.toLowerCase();
return n.includes(m) || m.includes(n);
})
: undefined;
return v?.cpp_recommended ?? 1;
};
const scored = cards.map((card) => {
// General rates only: not one merchant, not only through the issuer's portal.
const general = (card.reward_rates ?? []).filter((r) => !r.merchant && r.booking_channel !== "issuer_portal");
const chosen = picks[card.slug];
const rate =
general.find(
(r) => r.categories?.includes(category) && (!r.choice_group || !chosen || chosen.includes(category))
) ?? general.find((r) => r.is_base_rate);
const multiplier = rate?.rate_multiplier ?? 0;
const cents =
rate?.rate_type === "percent_cashback" ? multiplier : multiplier * centsPerPoint(card.reward_currency_name);
return {
card: card.name,
rate: multiplier,
centsPerDollar: cents,
// True when this rate is a choice category the user hasn't told us they picked.
dependsOnPick: !!rate?.choice_group && !chosen,
};
});
return scored.sort((a, b) => b.centsPerDollar - a.centsPerDollar)[0];
}