StripePaymentsIP GeolocationCurrencyTutorial

Stripe Checkout with Localized Currency: Show Prices by Country

ApogeoAPI7 min read

Overview

Stripe Checkout supports multi-currency natively. By default, it shows prices in whatever currency you configure on the Price object. With a small wrapper, you can:

  1. Detect the visitor's country from their IP
  2. Look up the exchange rate to their local currency
  3. Create a Checkout Session with the local currency amount
  4. Show "USD 29 → EUR 27" so the user knows the exact amount

Step 1: Get visitor country and currency rate

// lib/geo.ts
const cache = new Map<string, { data: any; expires: number }>();

export async function getVisitorGeo(ip: string) {
  const subnet = ip.split('.').slice(0, 3).join('.') + '.0';
  const cached = cache.get(subnet);
  if (cached && cached.expires > Date.now()) return cached.data;

  const res = await fetch(
    `https://api.apogeoapi.com/v1/geo/${ip}?apikey=${process.env.APOGEO_KEY}`,
    { signal: AbortSignal.timeout(2000) }
  );

  if (!res.ok) {
    return { countryCode: 'US', currencyCode: 'USD', currencyRate: 1, currencySymbol: '$' };
  }

  const data = await res.json();
  cache.set(subnet, { data, expires: Date.now() + 3_600_000 });
  return data;
}

Step 2: Create localized Stripe Checkout Session

// app/api/checkout/route.ts (Next.js App Router)
import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';
import { getVisitorGeo } from '@/lib/geo';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const PLAN_USD_CENTS = {
  basic:    1900,  // $19.00
  starter:  2900,  // $29.00
  pro:      7900,  // $79.00
};

// Stripe-supported currencies (lowercase)
// https://stripe.com/docs/currencies
const STRIPE_CURRENCIES = new Set([
  'usd', 'eur', 'gbp', 'jpy', 'cad', 'aud', 'chf', 'hkd',
  'sgd', 'sek', 'nok', 'dkk', 'pln', 'czk', 'mxn', 'brl',
  'inr', 'nzd', 'zar', 'myr', 'thb', 'php',
  // add more as needed — see Stripe docs for full list
]);

// Zero-decimal currencies (no cents)
const ZERO_DECIMAL = new Set(['jpy', 'krw', 'clp', 'gnf', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'xaf', 'xof']);

export async function POST(req: NextRequest) {
  const { plan } = await req.json();
  const usdCents = PLAN_USD_CENTS[plan as keyof typeof PLAN_USD_CENTS];
  if (!usdCents) return NextResponse.json({ error: 'Invalid plan' }, { status: 400 });

  // Get visitor geo
  const ip = req.headers.get('x-forwarded-for')?.split(',')[0]
    ?? req.headers.get('x-real-ip')
    ?? '127.0.0.1';

  const geo = await getVisitorGeo(ip === '127.0.0.1' ? '8.8.8.8' : ip);

  // Determine currency for Checkout
  const preferredCurrency = geo.currencyCode?.toLowerCase() ?? 'usd';
  const currency = STRIPE_CURRENCIES.has(preferredCurrency) ? preferredCurrency : 'usd';

  // Convert USD cents to local currency
  const rate = currency !== 'usd' ? (geo.currencyRate ?? 1) : 1;
  const usdAmount = usdCents / 100;
  const localAmount = Math.round(usdAmount * rate);

  // Stripe uses smallest currency unit
  // For USD/EUR/GBP: cents (29 → 2900)
  // For JPY: yen directly (2900 → 2900, i.e. ¥2900)
  const stripeAmount = ZERO_DECIMAL.has(currency)
    ? localAmount
    : localAmount * 100;

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    currency,
    line_items: [
      {
        price_data: {
          currency,
          unit_amount: stripeAmount,
          recurring: { interval: 'month' },
          product_data: {
            name: `${plan.charAt(0).toUpperCase() + plan.slice(1)} Plan`,
            description: `Billed monthly · Originally USD ${usdAmount}`,
          },
        },
        quantity: 1,
      },
    ],
    success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
    metadata: {
      plan,
      usd_cents: usdCents.toString(),
      country: geo.countryCode,
    },
  });

  return NextResponse.json({ url: session.url });
}

Step 3: Display localized price on the pricing page

// components/LocalizedPrice.tsx
'use client';
import { useEffect, useState } from 'react';

interface PriceInfo {
  usdAmount: number;
  localAmount: number;
  currency: string;
  symbol: string;
  countryCode: string;
}

async function fetchLocalPrice(plan: string, usdCents: number): Promise<PriceInfo> {
  const geo = await fetch('/api/geo').then(r => r.json());
  const rate = geo.currencyRate ?? 1;
  const currency = geo.currencyCode ?? 'USD';
  const symbol = geo.currencySymbol ?? '$';

  return {
    usdAmount: usdCents / 100,
    localAmount: Math.round((usdCents / 100) * rate),
    currency,
    symbol,
    countryCode: geo.countryCode,
  };
}

export function LocalizedPrice({ plan, usdCents }: { plan: string; usdCents: number }) {
  const [price, setPrice] = useState<PriceInfo | null>(null);

  useEffect(() => {
    fetchLocalPrice(plan, usdCents).then(setPrice);
  }, [plan, usdCents]);

  if (!price) {
    return <div className="animate-pulse h-8 bg-gray-200 rounded w-20" />;
  }

  const isLocal = price.currency !== 'USD';

  return (
    <div>
      <div className="text-3xl font-bold">
        {price.symbol}{price.localAmount.toLocaleString()}
        {isLocal && <span className="text-sm font-normal text-gray-500 ml-1">{price.currency}</span>}
      </div>
      {isLocal && (
        <div className="text-xs text-gray-400">
          ≈ USD {price.usdAmount} · {price.countryCode} pricing
        </div>
      )}
    </div>
  );
}

// Usage in pricing page
// <LocalizedPrice plan="starter" usdCents={2900} />

Step 4: Handle webhook for subscription metadata

// app/api/stripe-webhook/route.ts
import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: NextRequest) {
  const sig = req.headers.get('stripe-signature')!;
  const body = await req.text();

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.CheckoutSession;
    const plan = session.metadata?.plan;
    const country = session.metadata?.country;

    // Activate subscription for the user
    await activateSubscription({
      userId: session.client_reference_id!,
      plan,
      country,
      stripeCustomerId: session.customer as string,
    });
  }

  return NextResponse.json({ received: true });
}

Important considerations

  • Re-verify price server-side: Always recalculate the price in the API route — never trust client-submitted amounts
  • Stripe currency support: Not all currencies are supported. The code above falls back to USD for unsupported currencies
  • Zero-decimal currencies: JPY, KRW, etc. don't use cents — Stripe expects the whole number (2900 = ¥2,900 not ¥29.00)
  • VAT/tax: Use Stripe Tax for EU VAT calculation — it reads billing_details.address.country set during checkout
  • Refunds: Always refund in the charged currency, not USD

ApogeoAPI docs: api.apogeoapi.com/api/docs. Free tier: app.apogeoapi.com/register.

Try ApogeoAPI free

1,000 requests/month forever. 14-day full-access trial. No credit card.

Get your free API key