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:
- Detect the visitor's country from their IP
- Look up the exchange rate to their local currency
- Create a Checkout Session with the local currency amount
- 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.countryset 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