The Tax Brief real effective rates for 111+ countries — bi-weekly, free.
API GUIDE

Python Income Tax API Integration Guide

CountryTaxCalc Research Team · About Updated July 2026
Tax data in examples is for informational and product integration purposes only, sourced from official government tax authorities. Consumer-facing products must include appropriate disclaimers — see the API Terms of Service and Legal Disclaimer.
Quick start: Install requests, set your API key as an environment variable, and call POST /api/v1/calculate. The full working snippet is in the Basic request section below. This guide assumes Python 3.8+.

Installation

The API works with the standard requests library. For async support (FastAPI, asyncio), use httpx. Neither library requires special configuration for the API.

# Synchronous (most use cases)
pip install requests

# Async support
pip install httpx

# Optional: load .env files for local development
pip install python-dotenv

No other dependencies are required. The API returns JSON, which Python's built-in json module handles — requests and httpx both expose a .json() method that handles parsing automatically.

Basic request

A minimal working example — calculate take-home pay for a UK gross salary of £60,000:

import os
import requests

API_KEY = os.environ.get("COUNTRYTAXCALC_API_KEY")
BASE_URL = "https://api.countrytaxcalc.com/api/v1"

response = requests.post(
    f"{BASE_URL}/calculate",
    headers={
        "Content-Type": "application/json",
        "X-API-Key": API_KEY
    },
    json={
        "income": 60000,
        "country": "GB"
    },
    timeout=10  # always set a timeout
)

response.raise_for_status()  # raises HTTPError for 4xx/5xx

data = response.json()

print(f"Country:        {data['country']}")
print(f"Gross income:   £{data['gross_income']:,}")
print(f"Net income:     £{data['totals']['net_income']:,}")
print(f"Monthly net:    £{data['totals']['monthly_net_income']:,.2f}")
print(f"Effective rate: {data['totals']['effective_tax_rate']}%")
print(f"Data source:    {data['metadata']['data_source']}")
print(f"Confidence:     {data['metadata']['accuracy_confidence']}")

Expected output:

Country:        United Kingdom
Gross income:   £60,000
Net income:     £45,357
Monthly net:    £3,779.75
Effective rate: 24.41%
Data source:    HMRC
Confidence:     0.95

Passing optional fields

# US federal + California state tax
response = requests.post(
    f"{BASE_URL}/calculate",
    headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
    json={
        "income": 120000,
        "country": "US",
        "jurisdiction": "CA",  # California
        "currency": "USD"
    },
    timeout=10
)

# Germany with income in USD (auto-converted to EUR for calculation)
response = requests.post(
    f"{BASE_URL}/calculate",
    headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
    json={
        "income": 100000,
        "country": "DE",
        "currency": "USD"  # API converts USD → EUR, then calculates
    },
    timeout=10
)

Response parsing

The response is nested JSON. Here's a helper that extracts the most commonly needed values:

def parse_tax_result(data: dict) -> dict:
    """Extract key tax figures from the API response."""
    totals = data["totals"]
    breakdown = data["breakdown"]
    metadata = data["metadata"]

    # Sum social contributions
    social_total = sum(
        c["amount"] for c in breakdown.get("social_contributions", [])
    )

    # Format confidence label
    confidence = metadata["accuracy_confidence"]
    confidence_label = "verified" if confidence >= 0.9 else "estimated"

    return {
        "country": data["country"],
        "country_code": data["country_code"],
        "currency": data["currency"],
        "currency_symbol": data["currency_symbol"],
        "gross_income": data["gross_income"],
        "net_income": totals["net_income"],
        "monthly_net": totals["monthly_net_income"],
        "total_tax": totals["total_tax"],
        "income_tax": breakdown["national_income_tax"]["amount"],
        "social_tax": social_total,
        "effective_rate": totals["effective_tax_rate"],
        "take_home_pct": totals["take_home_percentage"],
        "marginal_rate": breakdown["national_income_tax"]["marginal_rate"],
        "data_source": metadata["data_source"],
        "confidence": confidence,
        "confidence_label": confidence_label,
    }

# Usage
data = response.json()
result = parse_tax_result(data)
print(f"{result['country']}: {result['currency_symbol']}{result['net_income']:,} net "
      f"({result['effective_rate']}% effective, {result['confidence_label']})")

Accessing deductions and brackets

data = response.json()
breakdown = data["breakdown"]

# Named deductions (e.g. Personal Allowance)
for deduction in breakdown["deductions"]:
    print(f"  {deduction['name']}: {deduction['amount']:,}")

# Tax brackets applied
brackets = breakdown["national_income_tax"]["brackets_applied"]
for bracket in brackets:
    print(f"  {bracket['rate']*100:.0f}% on income above {bracket['threshold']:,}: "
          f"tax = {bracket['tax']:,}")

# Social contributions
for contrib in breakdown["social_contributions"]:
    print(f"  {contrib['name']} ({contrib['type']}): {contrib['amount']:,} "
          f"at {contrib['rate']*100:.1f}%")

Error handling

A production-grade client should handle all error cases explicitly. The API uses standard HTTP status codes with a consistent JSON error body:

import os
import time
import requests
from requests.exceptions import RequestException

class TaxAPIError(Exception):
    def __init__(self, status_code: int, error: str, message: str):
        self.status_code = status_code
        self.error = error
        self.message = message
        super().__init__(f"Tax API {status_code}: {message}")

def calculate_tax(income: float, country: str, currency: str = None,
                  jurisdiction: str = None, retries: int = 2) -> dict:
    """
    Call POST /calculate with retry logic for transient errors.
    Raises TaxAPIError for non-retryable errors.
    """
    API_KEY = os.environ.get("COUNTRYTAXCALC_API_KEY")
    url = "https://api.countrytaxcalc.com/api/v1/calculate"

    payload = {"income": income, "country": country}
    if currency:
        payload["currency"] = currency
    if jurisdiction:
        payload["jurisdiction"] = jurisdiction

    for attempt in range(retries + 1):
        try:
            resp = requests.post(
                url,
                headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
                json=payload,
                timeout=10
            )
        except RequestException as e:
            if attempt < retries:
                time.sleep(2 ** attempt)  # exponential backoff
                continue
            raise

        if resp.status_code == 200:
            return resp.json()

        # Try to parse error body
        try:
            err = resp.json()
            error_code = err.get("error", "unknown_error")
            message = err.get("message", resp.text)
        except Exception:
            error_code = "parse_error"
            message = resp.text

        if resp.status_code == 429:
            # Burst limit — retry after waiting
            retry_after = int(resp.headers.get("Retry-After", 60))
            if attempt < retries:
                time.sleep(retry_after)
                continue
            raise TaxAPIError(429, error_code, message)

        if resp.status_code >= 500 and attempt < retries:
            # Server error — retry with backoff
            time.sleep(2 ** attempt)
            continue

        # Non-retryable: 400, 401, 404, 422
        raise TaxAPIError(resp.status_code, error_code, message)

    raise TaxAPIError(0, "max_retries", "Maximum retries exceeded")


# Usage with error handling
try:
    result = calculate_tax(60000, "GB")
    print(f"Net income: £{result['totals']['net_income']:,}")
except TaxAPIError as e:
    if e.status_code == 401:
        print("Invalid API key — check COUNTRYTAXCALC_API_KEY environment variable")
    elif e.status_code == 404:
        print(f"Country not found: {e.message}")
    elif e.status_code == 429:
        print(f"Rate limit exceeded: {e.message}")
    else:
        print(f"API error {e.status_code}: {e.message}")

Async with httpx

For FastAPI applications or scripts that need to fetch multiple countries concurrently, use httpx with Python's asyncio:

import asyncio
import os
import httpx

API_KEY = os.environ.get("COUNTRYTAXCALC_API_KEY")
BASE_URL = "https://api.countrytaxcalc.com/api/v1"

async def calculate_tax_async(
    client: httpx.AsyncClient,
    income: float,
    country: str,
    currency: str = "USD",
    semaphore: asyncio.Semaphore = None
) -> dict:
    """Single async tax calculation."""
    payload = {"income": income, "country": country, "currency": currency}

    async with (semaphore or asyncio.Semaphore(1)):
        resp = await client.post(
            f"{BASE_URL}/calculate",
            headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
            json=payload,
            timeout=10
        )
        resp.raise_for_status()
        data = resp.json()
        return {
            "country": data["country"],
            "country_code": data["country_code"],
            "net_income": data["totals"]["net_income"],
            "effective_rate": data["totals"]["effective_tax_rate"],
            "currency": data["currency"],
            "confidence": data["metadata"]["accuracy_confidence"]
        }

async def compare_countries(income: float, countries: list[str], currency: str = "USD"):
    """Fetch tax results for multiple countries concurrently."""
    semaphore = asyncio.Semaphore(5)  # max 5 concurrent requests

    async with httpx.AsyncClient() as client:
        tasks = [
            calculate_tax_async(client, income, country, currency, semaphore)
            for country in countries
        ]
        results = await asyncio.gather(*tasks, return_exceptions=True)

    # Filter errors and sort by net income
    valid = [r for r in results if isinstance(r, dict)]
    errors = [r for r in results if isinstance(r, Exception)]

    if errors:
        for err in errors:
            print(f"Warning: {err}")

    return sorted(valid, key=lambda x: x["net_income"], reverse=True)

# Run it
countries = ["GB", "DE", "NL", "SE", "NO", "DK", "IE", "CH", "SG", "AU"]

results = asyncio.run(compare_countries(100000, countries, "USD"))

print(f"{'Country':<20} {'Net Income':>15} {'Effective Rate':>15}")
print("-" * 52)
for r in results:
    net = f"{r['currency']} {r['net_income']:,.0f}"
    rate = f"{r['effective_rate']}%"
    label = "" if r['confidence'] >= 0.9 else " (est.)"
    print(f"{r['country']:<20} {net:>15} {rate:>15}{label}")

Caching responses

For most products, the same country+income combination will be requested many times. Caching saves API quota and reduces latency. The Terms of Service permit caching for up to 24 hours.

In-memory cache (single process)

import time
from functools import lru_cache

# Simple time-aware cache wrapper
_cache: dict[str, tuple[dict, float]] = {}
CACHE_TTL = 86400  # 24 hours in seconds

def calculate_tax_cached(income: float, country: str, currency: str = None) -> dict:
    """Calculate tax with 24-hour in-memory caching."""
    key = f"{country}:{income}:{currency or 'local'}"
    now = time.time()

    if key in _cache:
        data, cached_at = _cache[key]
        if now - cached_at < CACHE_TTL:
            return data

    result = calculate_tax(income, country, currency)
    _cache[key] = (result, now)
    return result

Redis cache (multi-process / production)

import json
import os
import redis
import requests

redis_client = redis.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379"))
CACHE_TTL = 86400  # 24 hours

def calculate_tax_redis(income: float, country: str, currency: str = None) -> dict:
    """Calculate tax with Redis caching."""
    cache_key = f"tax:{country}:{income}:{currency or 'local'}"

    # Check cache
    cached = redis_client.get(cache_key)
    if cached:
        return json.loads(cached)

    # Cache miss — call API
    result = calculate_tax(income, country, currency)

    # Store in Redis with TTL
    redis_client.setex(cache_key, CACHE_TTL, json.dumps(result))
    return result
Cache impact on free tier: On the free 1,000 req/month tier, caching is especially valuable. If 500 users request the same UK £60,000 calculation on the same day, that's 1 API request with a 24-hour cache — not 500. Intelligent caching can make 1,000 requests/month support many thousands of users.

Complete multi-country comparison script

A self-contained Python script that fetches take-home pay for a list of countries and prints a ranked comparison. Uses the synchronous requests library with the caching and error-handling patterns from above.

"""
multi_country_comparison.py
Compares take-home pay across countries using the CountryTaxCalc API.

Usage:
  export COUNTRYTAXCALC_API_KEY=your_key_here
  python multi_country_comparison.py
"""

import os
import time
import requests

API_KEY = os.environ.get("COUNTRYTAXCALC_API_KEY")
if not API_KEY:
    raise ValueError("COUNTRYTAXCALC_API_KEY environment variable not set")

BASE_URL = "https://api.countrytaxcalc.com/api/v1"

COUNTRIES = ["GB", "DE", "NL", "SE", "NO", "DK", "IE", "CH", "FR", "AU",
             "CA", "SG", "NZ", "US", "AE"]
GROSS_INCOME = 100_000
CURRENCY = "USD"


def fetch_tax(country: str) -> dict | None:
    """Fetch tax calculation for a single country. Returns None on error."""
    try:
        resp = requests.post(
            f"{BASE_URL}/calculate",
            headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
            json={"income": GROSS_INCOME, "country": country, "currency": CURRENCY},
            timeout=10
        )
        if resp.status_code == 429:
            retry_after = int(resp.headers.get("Retry-After", 60))
            print(f"  Rate limited — waiting {retry_after}s")
            time.sleep(retry_after)
            resp = requests.post(
                f"{BASE_URL}/calculate",
                headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
                json={"income": GROSS_INCOME, "country": country, "currency": CURRENCY},
                timeout=10
            )
        resp.raise_for_status()
        return resp.json()
    except requests.RequestException as e:
        print(f"  Error fetching {country}: {e}")
        return None


def main():
    print(f"Comparing take-home pay for ${GROSS_INCOME:,} USD gross income\n")
    print(f"{'#':<3} {'Country':<22} {'Net Income (local)':>20} {'Eff. Rate':>12} {'Source':<8}")
    print("-" * 72)

    results = []
    for country in COUNTRIES:
        data = fetch_tax(country)
        if data:
            results.append(data)
        time.sleep(0.1)  # small delay to avoid burst limit

    # Sort by effective tax rate (ascending = lowest tax first)
    results.sort(key=lambda d: d["totals"]["effective_tax_rate"])

    for i, d in enumerate(results, 1):
        symbol = d["currency_symbol"]
        net = d["totals"]["net_income"]
        rate = d["totals"]["effective_tax_rate"]
        source = d["metadata"]["data_source"]
        conf = d["metadata"]["accuracy_confidence"]
        approx = " ~" if conf < 0.9 else "  "

        net_str = f"{symbol}{net:>12,.0f}{approx}"
        print(f"{i:<3} {d['country']:<22} {net_str:>20} {rate:>11.1f}%  {source}")

    print(f"\n~ = estimated (accuracy_confidence < 0.9)")
    print(f"Disclaimer: estimates only. Consult a tax professional for advice.")


if __name__ == "__main__":
    main()

Environment variables and local development

The recommended way to manage your API key in Python:

# .env (add this file to .gitignore — never commit it)
COUNTRYTAXCALC_API_KEY=your_api_key_here
# In your Python code
from dotenv import load_dotenv
import os

load_dotenv()  # loads .env file in local dev; does nothing in production

API_KEY = os.environ.get("COUNTRYTAXCALC_API_KEY")
if not API_KEY:
    raise ValueError("COUNTRYTAXCALC_API_KEY is not set")
# requirements.txt
requests>=2.28.0
python-dotenv>=1.0.0

# For async
httpx>=0.25.0

In production, set COUNTRYTAXCALC_API_KEY via your hosting platform's environment variable / secrets management system rather than a .env file.

Frequently asked questions

Q: Which HTTP library should I use: requests or httpx?

Use requests for simple scripts and synchronous Django/Flask applications. Use httpx if you're working with FastAPI, asyncio, or need async concurrency for calling multiple countries in parallel. Both libraries produce the same API results — it's purely about your application's concurrency model.

Q: How should I handle the API key in a Python web application?

Store the key in an environment variable (COUNTRYTAXCALC_API_KEY) and load it with os.environ.get() or a library like python-dotenv for local development. Never hardcode the key in your Python source files or commit it to version control. In production, set the environment variable via your hosting platform's secrets management (e.g. Railway environment variables, AWS Secrets Manager, Heroku config vars).

Q: How do I handle the 429 rate limit error in Python?

The response body includes retry_after_seconds for burst limits and reset_at (ISO 8601) for monthly limits. Implement exponential backoff for burst limits: catch the 429, sleep for retry_after_seconds, then retry. For monthly limit exhaustion, surface the error to your users with the reset date rather than retrying.

Q: Can I use the API with pandas or a data pipeline?

Yes — the API returns clean JSON that maps naturally to a pandas DataFrame. After calling multiple countries, you can pass the list of result dicts to pd.json_normalize(results) to flatten the nested JSON into a wide DataFrame with columns like totals.net_income, totals.effective_tax_rate, etc. The worked multi-country script at the bottom of this page produces output suitable for pandas loading.

Q: Is there an official Python SDK?

There is no official SDK yet — the API is straightforward enough that a wrapper library would add more ceremony than value. The patterns on this page (a small client class with retry logic) are all you need. If you want a typed client, use dataclasses or Pydantic to model the response object — the full response schema is documented in the API reference.

Q: How do I run the multi-country comparison script against a large list?

For lists of more than 10–15 countries, use the async httpx approach with a semaphore to limit concurrent requests — the burst rate limit applies to all tiers. A semaphore of 5–10 concurrent requests gives you fast results without risk of hitting the per-minute burst limit. The async example on this page includes a semaphore pattern.

Ready to integrate? 1,000 requests/month free. No credit card required.
Get your free API key →