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

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];
}