Security: why you must use a server-side proxy
const in your frontend bundle, never in a .env file committed to GitHub. The correct architecture is: browser → your proxy → CountryTaxCalc API.
Here's why this matters: browser JavaScript is public. Anyone visiting your page can open DevTools, go to the Network tab, and see every request your JavaScript makes. If you call the tax API directly from the browser, your X-API-Key header is visible in plain text. A bad actor can copy your key and use your monthly quota.
The correct architecture has three layers:
- Frontend — your React/Vue/plain JS makes a request to your own backend endpoint (e.g.
/api/tax-calc). No API key in this request. - Your proxy — an Express route, Next.js API route, Cloudflare Worker, or serverless function. This layer reads the key from an environment variable and forwards the request to the tax API.
- CountryTaxCalc API — receives the request with the key from your proxy, returns the result to your proxy, which passes it back to your frontend.
The one exception: GET /api/v1/countries requires no API key and is safe to call directly from the browser.
Node.js basic example
Using the native fetch API (Node.js 18+):
// server.js — runs server-side only
const API_KEY = process.env.COUNTRYTAXCALC_API_KEY;
async function calculateTax(income, country, currency = null) {
const payload = { income, country };
if (currency) payload.currency = currency;
const response = await fetch(
"https://api.countrytaxcalc.com/api/v1/calculate",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": API_KEY
},
body: JSON.stringify(payload)
}
);
if (!response.ok) {
const err = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Tax API ${response.status}: ${err.message}`);
}
return response.json();
}
// Usage
const data = await calculateTax(60000, "GB");
console.log(`Net income: £${data.totals.net_income.toLocaleString()}`);
console.log(`Effective rate: ${data.totals.effective_tax_rate}%`);
For Node.js versions below 18, install the node-fetch package (npm install node-fetch) and import it as import fetch from 'node-fetch'.
The proxy pattern explained
Your proxy sits between your frontend and the tax API. It does three things:
- Receive a request from your frontend (income, country)
- Validate the input (prevent negative incomes, validate country codes)
- Forward to the tax API with your key, and return the result
Your proxy controls what data gets passed through. This is also where you add caching — the proxy can return a cached result without consuming any API quota.
Express.js proxy
// routes/tax.js
const express = require("express");
const router = express.Router();
const API_KEY = process.env.COUNTRYTAXCALC_API_KEY;
const TAX_API_URL = "https://api.countrytaxcalc.com/api/v1/calculate";
// In-memory cache (replace with Redis in production)
const cache = new Map();
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
router.post("/calculate", async (req, res) => {
const { income, country, currency, jurisdiction } = req.body;
// Input validation
if (!income || typeof income !== "number" || income <= 0) {
return res.status(400).json({ error: "income must be a positive number" });
}
if (!country || typeof country !== "string") {
return res.status(400).json({ error: "country is required" });
}
// Cache key
const cacheKey = `${country}:${income}:${currency || "local"}:${jurisdiction || ""}`;
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return res.json({ ...cached.data, _cached: true });
}
// Forward to tax API
try {
const payload = { income, country };
if (currency) payload.currency = currency;
if (jurisdiction) payload.jurisdiction = jurisdiction;
const apiResp = await fetch(TAX_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": API_KEY
},
body: JSON.stringify(payload)
});
const data = await apiResp.json();
if (!apiResp.ok) {
return res.status(apiResp.status).json(data);
}
// Cache the result
cache.set(cacheKey, { data, timestamp: Date.now() });
res.json(data);
} catch (err) {
console.error("Tax API error:", err);
res.status(502).json({ error: "upstream_error", message: "Tax API unavailable" });
}
});
module.exports = router;
// app.js
const express = require("express");
const cors = require("cors");
const taxRouter = require("./routes/tax");
const app = express();
app.use(express.json());
app.use(cors({ origin: process.env.ALLOWED_ORIGIN || "http://localhost:3000" }));
app.use("/api/tax", taxRouter);
app.listen(3001); Cloudflare Worker proxy
Cloudflare Workers are the lightest deployment option — no server to maintain, global edge delivery, generous free tier. This is ideal if your frontend is a static site (Astro, Next.js static export, plain HTML).
// worker.js
export default {
async fetch(request, env) {
// Allow your frontend origin only
const allowedOrigin = "https://yoursite.com";
// Handle CORS preflight
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Methods": "POST",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400"
}
});
}
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
let body;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
const { income, country, currency, jurisdiction } = body;
// Validate input
if (!income || income <= 0 || !country) {
return new Response(
JSON.stringify({ error: "income (positive number) and country are required" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
// Check KV cache (env.CACHE is a KV namespace bound in wrangler.toml)
const cacheKey = `tax:${country}:${income}:${currency || "local"}:${jurisdiction || ""}`;
const cached = await env.CACHE.get(cacheKey);
if (cached) {
return new Response(cached, {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": allowedOrigin,
"X-Cache": "HIT"
}
});
}
// Forward to tax API
const payload = { income, country };
if (currency) payload.currency = currency;
if (jurisdiction) payload.jurisdiction = jurisdiction;
const apiResp = await fetch(
"https://api.countrytaxcalc.com/api/v1/calculate",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": env.COUNTRYTAXCALC_API_KEY // set as a Worker Secret
},
body: JSON.stringify(payload)
}
);
const responseText = await apiResp.text();
// Cache successful responses for 24 hours
if (apiResp.ok) {
await env.CACHE.put(cacheKey, responseText, { expirationTtl: 86400 });
}
return new Response(responseText, {
status: apiResp.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": allowedOrigin,
"X-Cache": "MISS"
}
});
}
}; # wrangler.toml
name = "tax-api-proxy"
main = "worker.js"
compatibility_date = "2024-01-01"
[[kv_namespaces]]
binding = "CACHE"
id = "your_kv_namespace_id"
# Set the secret via: wrangler secret put COUNTRYTAXCALC_API_KEY Next.js API route
Next.js API routes run server-side — the API key stays on the server and is never exposed to the browser:
// app/api/tax/route.ts (Next.js 13+ App Router)
import { NextRequest, NextResponse } from "next/server";
const API_KEY = process.env.COUNTRYTAXCALC_API_KEY!;
export async function POST(request: NextRequest) {
const { income, country, currency, jurisdiction } = await request.json();
if (!income || income <= 0 || !country) {
return NextResponse.json(
{ error: "income and country are required" },
{ status: 400 }
);
}
const payload: Record = { income, country };
if (currency) payload.currency = currency;
if (jurisdiction) payload.jurisdiction = jurisdiction;
const resp = await fetch("https://api.countrytaxcalc.com/api/v1/calculate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": API_KEY
},
body: JSON.stringify(payload),
next: { revalidate: 86400 } // Next.js caches fetch for 24h
});
const data = await resp.json();
return NextResponse.json(data, { status: resp.status });
} React frontend component
This calls your proxy (not the tax API directly). The proxy handles the API key:
// components/TaxCalculator.tsx
import { useState } from "react";
interface TaxResult {
country: string;
gross_income: number;
currency_symbol: string;
totals: {
net_income: number;
monthly_net_income: number;
effective_tax_rate: number;
take_home_percentage: number;
};
metadata: {
accuracy_confidence: number;
data_source: string;
};
}
export function TaxCalculator() {
const [income, setIncome] = useState("");
const [country, setCountry] = useState("GB");
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleCalculate = async () => {
const incomeNum = parseFloat(income);
if (!incomeNum || incomeNum <= 0) {
setError("Please enter a valid income amount");
return;
}
setLoading(true);
setError(null);
try {
// Calls YOUR proxy — not the tax API directly
const resp = await fetch("/api/tax", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ income: incomeNum, country })
});
if (!resp.ok) {
const err = await resp.json();
setError(err.message || "Calculation failed");
return;
}
const data: TaxResult = await resp.json();
setResult(data);
} catch {
setError("Request failed. Please try again.");
} finally {
setLoading(false);
}
};
const isApproximate = result && result.metadata.accuracy_confidence < 0.9;
return (
<div>
<input
type="number"
value={income}
onChange={e => setIncome(e.target.value)}
placeholder="Gross income"
/>
<select value={country} onChange={e => setCountry(e.target.value)}>
<option value="GB">United Kingdom</option>
<option value="DE">Germany</option>
<option value="US">United States</option>
{/* Populate from GET /api/v1/countries */}
</select>
<button onClick={handleCalculate} disabled={loading}>
{loading ? "Calculating..." : "Calculate"}
</button>
{error && <p style={{color: "red"}}>{error}</p>}
{result && (
<div>
<h3>{result.country} — {result.currency_symbol}{result.gross_income.toLocaleString()} gross</h3>
<p>
{isApproximate ? "~" : ""}
{result.currency_symbol}{result.totals.net_income.toLocaleString()} take-home
{isApproximate && " (estimated)"}
</p>
<p>Monthly: {result.currency_symbol}{result.totals.monthly_net_income.toLocaleString()}</p>
<p>Effective rate: {result.totals.effective_tax_rate}%</p>
<p><small>Source: {result.metadata.data_source}</small></p>
<p><small>
Tax estimates are approximate and for informational purposes only.
Consult a qualified tax professional for advice specific to your situation.
</small></p>
</div>
)}
</div>
);
} Populating the country selector from the API
The GET /api/v1/countries endpoint requires no key and can be called directly from the browser. Use it to build a dynamic country selector:
// Fetches directly from the API — no key needed for this endpoint
const resp = await fetch("https://api.countrytaxcalc.com/api/v1/countries");
const { countries } = await resp.json();
// countries: [{ country_code, country_name, currency, has_jurisdictions }, ...] Error handling in JavaScript
async function callTaxProxy(income, country, currency = null) {
const payload = { income, country };
if (currency) payload.currency = currency;
const resp = await fetch("/api/tax", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (resp.ok) return resp.json();
const err = await resp.json().catch(() => ({ message: "Unknown error" }));
switch (resp.status) {
case 400:
throw new Error(`Bad request: ${err.message}`);
case 404:
throw new Error(`Country not found: ${country}`);
case 429:
const resetAt = err.reset_at ? new Date(err.reset_at).toLocaleDateString() : "next month";
throw new Error(`Rate limit reached. Resets ${resetAt}.`);
case 502:
case 503:
throw new Error("Tax service temporarily unavailable. Please try again.");
default:
throw new Error(err.message || `HTTP ${resp.status}`);
}
} Client-side caching with sessionStorage
For a single-page app, you can cache results in sessionStorage to avoid repeating the same calculation during a user session. This is in addition to your server-side cache:
const SESSION_CACHE_KEY = "tax_results";
function getCachedResult(country, income, currency) {
const key = `${country}:${income}:${currency || "local"}`;
const cacheStr = sessionStorage.getItem(SESSION_CACHE_KEY);
if (!cacheStr) return null;
const cache = JSON.parse(cacheStr);
const entry = cache[key];
if (!entry) return null;
// Expire after 1 hour in session
if (Date.now() - entry.timestamp > 3600000) return null;
return entry.data;
}
function setCachedResult(country, income, currency, data) {
const key = `${country}:${income}:${currency || "local"}`;
const cacheStr = sessionStorage.getItem(SESSION_CACHE_KEY);
const cache = cacheStr ? JSON.parse(cacheStr) : {};
cache[key] = { data, timestamp: Date.now() };
sessionStorage.setItem(SESSION_CACHE_KEY, JSON.stringify(cache));
}
async function calculateWithCache(income, country, currency = null) {
const cached = getCachedResult(country, income, currency);
if (cached) return cached;
const result = await callTaxProxy(income, country, currency);
setCachedResult(country, income, currency, result);
return result;
} Frequently asked questions
Q: Why can't I call the API directly from the browser?
You can, technically — but your API key would be visible to anyone who inspects your network requests or views source. Once extracted, your key can be used by anyone to make requests against your quota. The only safe pattern is to proxy calls through a server you control, where the key is stored as a server-side environment variable. Treat the API key like a database password — it must never be client-side.
Q: What if I'm building a static site with no backend?
You still need a server-side proxy, but it can be serverless. Use Cloudflare Workers (free tier available), Vercel Edge Functions, Netlify Functions, or AWS Lambda. These are lightweight by design and cost nothing at low traffic. The Cloudflare Worker example on this page is the lightest approach — about 30 lines of code, deploys in seconds, and runs at the edge globally.
Q: Can I use the countries list endpoint from the browser without a key?
Yes — GET /api/v1/countries is the one endpoint that requires no API key. You can call it directly from browser-side JavaScript to populate a country selector. It returns the list of all 111 supported countries with their codes and currencies. All other endpoints require a key and must be called server-side.
Q: How do I handle CORS in my proxy?
Your proxy needs to set Access-Control-Allow-Origin headers for the domains that will make requests to it. Set it to your specific frontend domain (e.g. https://yourapp.com) rather than * to prevent any website from using your proxy. The Express, Cloudflare Worker, and Next.js examples on this page all include CORS configuration.
Q: How do I implement caching in a Cloudflare Worker?
Cloudflare Workers have built-in KV storage for key-value caching. Store the JSON result with a TTL of 86400 seconds (24 hours) using env.CACHE.put(key, JSON.stringify(result), {expirationTtl: 86400}) and retrieve with env.CACHE.get(key). The expanded Cloudflare Worker example in this guide shows this pattern.
Q: Is there a rate limit on proxy requests?
Your proxy is making real API calls against your key's quota. Implement caching in your proxy layer to avoid consuming quota on repeated requests for the same country+income combination. If your proxy is itself public-facing, add authentication or rate limiting to prevent your users from exhausting your API quota. The examples here are educational starting points — production proxies should include their own request throttling.