Integrations

Local Pricing for SaaS Plans

Turn a USD plan price into a defensible local price with IMF PPP factors, explicit floors and ceilings, and a documented rounding rule.


The job

Price a subscription for several markets without treating purchasing-power parity as an exchange rate. Use /v1/ppp for one country’s factor, /v1/convert?ppp=true for a PPP-adjusted amount, and /v1/rates when you need a nominal market conversion.

The goal is a local price you can defend, not a mathematically pure number. PPP is an input to the pricing decision, not the decision itself.

Two different numbers

NumberWhat it answersEndpoint
Market conversionWhat the local currency is worth at the FX rate/v1/convert
PPP conversionWhat a basket of goods costs at local price levels/v1/convert?ppp=true
PPP factorLocal currency units per international dollar/v1/ppp

A PPP factor of 20.397 for India means goods costing one international dollar in the United States would cost about 20.4 units of Indian currency in India at PPP. It is a price level, not a quote. The market FX rate can be very different.

/v1/ppp returns:

{
  "year": 2024,
  "countryCode": "IND",
  "pppFactor": 20.397,
  "projected": false
}

The pricing pipeline

  1. Start from the plan’s USD price.
  2. Fetch the country’s PPP factor for the factor year.
  3. Multiply the USD price by the factor to get a raw local price.
  4. Apply a commercial floor and ceiling in local currency.
  5. Round to a presentable local price.
  6. Record the country, factor, factor year, raw price and posted price.

Steps 4 and 5 are business decisions. Document them, because a support agent will eventually need to explain why the Indian price is not exactly 20.4 times the US price.

A complete pricing function

type PppResponse = {
  year: number;
  countryCode: string;
  pppFactor: number;
  projected: boolean;
};

type LocalPrice = {
  country: string;
  currency: string;
  factorYear: number;
  pppFactor: number;
  projected: boolean;
  basePriceUsd: number;
  rawLocalPrice: number;
  postedLocalPrice: number;
};

const ZERO_DECIMAL = new Set(["JPY", "KRW", "VND", "CLP", "ISK"]);

async function fetchPpp(country: string, year = 2024): Promise<PppResponse> {
  const url = new URL("https://api.currency-core.com/v1/ppp");
  url.searchParams.set("country", country);
  url.searchParams.set("year", String(year));

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.CURRENCECORE_API_KEY}` },
    signal: AbortSignal.timeout(8000),
  });
  const body = await res.json();

  if (!res.ok) {
    throw Object.assign(new Error(body.error?.message ?? `HTTP ${res.status}`), {
      status: res.status,
      code: body.error?.code,
    });
  }
  return body as PppResponse;
}

function roundLocal(value: number, currency: string) {
  // Replace this with your own charm-price rule. This one rounds to .99 and
  // uses whole units for zero-decimal currencies.
  if (ZERO_DECIMAL.has(currency)) return Math.max(1, Math.round(value / 10) * 10 - 1);
  return Math.max(0.99, Math.round(value) - 0.01);
}

export async function localPrice(input: {
  basePriceUsd: number;
  country: string;
  currency: string;
  year?: number;
  floor?: number;
  ceiling?: number;
}): Promise<LocalPrice> {
  const ppp = await fetchPpp(input.country, input.year ?? 2024);
  const raw = input.basePriceUsd * ppp.pppFactor;
  const bounded = Math.min(
    Math.max(raw, input.floor ?? raw),
    input.ceiling ?? raw,
  );

  return {
    country: ppp.countryCode,
    currency: input.currency,
    factorYear: ppp.year,
    pppFactor: ppp.pppFactor,
    projected: ppp.projected,
    basePriceUsd: input.basePriceUsd,
    rawLocalPrice: raw,
    postedLocalPrice: roundLocal(bounded, input.currency),
  };
}

The currency code is not part of the /v1/ppp response. Read it from /v1/countries and pass it in, or keep a small country to currency map for the markets you price.

Worked example

A $29 monthly plan, using the 2024 PPP factors published in the endpoint examples and the site dataset. Factors are rounded for display; the API is the source of truth.

MarketCountryCurrencyFactorRaw localPosted local
United StatesUSAUSD1.00029.0029.00
IndiaINDINR20.397591.51599.00
GermanyDEUEUR0.72220.9420.99
FranceFRAEUR0.64418.6818.99
United KingdomGBRGBP0.66519.2919.99

The posted column uses the charm-price rule in the code above. It is an example, not a recommendation. A different rule will produce different prices from the same raw values.

Floors, ceilings and unit economics

A floor is not “20 percent below the raw price.” It is the lowest price that still covers support, payment fees and tax in that market. Set it in local currency and store it with the price.

Examples:

MarketRawFloorCeilingPosted
India591.51499899599
United Kingdom19.29152519.99

If the raw value is below the floor, charge the floor. If it is above the ceiling, charge the ceiling. A ceiling is useful when a high PPP factor would make the local price look implausible next to competitors or next to the purchasing power of the audience.

Do not use the market FX rate to set the floor. Use your cost to serve and the price you are willing to accept.

Actual years versus projections

/v1/ppp can return projected: true for an IMF forecast year. For a live price, prefer actual data. The configured factor year is 2024, and /v1/convert?ppp=true uses actual years only, not projections.

If you price with a projected year, label it as a scenario in your internal review. Do not present a forecast factor as observed history in a customer-facing explanation.

What not to do

  • Do not treat a PPP factor as an exchange rate.
  • Do not take a PPP price and then route it through a market conversion as if it were an FX charge. Charge the local price you set.
  • Do not use one global multiplier for every market. The factor is country-level for a reason.
  • Do not ignore tax, payment fees and minimum charge amounts. PPP covers price levels, not your cost structure.
  • Do not compare a 2024 factor with a 2026 factor without checking the year.

Failure handling

StatusWhat to do
400 invalid_inputCheck the alpha-3 country code and the four-digit year
401 unauthorizedCheck the server-side key
429 rate_limitedHonor Retry-After; PPP factors change annually, so cache aggressively
5xxRetry with backoff, or use the last known factor with a visible date

/v1/ppp requires a key but has no Growth gate. It is metered like any other keyed request. See rate limits.

Next steps