Build a currency converter with Python

Build a Flask form that converts an amount with a dated reference rate and displays the published date beside the result.

Working example

ParseAPI
Python

Set up ParseAPI

Install the SDK in your application and set PARSEAPI_KEY in your server environment.

This Python web app uses Flask for its form and request handler.

Terminal
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install parseapi==1.12.0
python3 -m pip install Flask==3.1.3
export PARSEAPI_KEY='YOUR_SECRET_API_KEY'

Create a client and reuse it for the calls below:

Python
from parseapi import ParseAPI

parse = ParseAPI()

Close the client when your workflow ends, or use with ParseAPI() as parse: to close it automatically.

Get an API key ↗

Put currency conversion in a web form

Build a Python page where someone enters an amount, selects two currencies and chooses a date. The server fetches the reference rate, calculates with Decimal, and shows both the converted amount and the rate's actual publication date.

The complete app uses Flask for the form and template. The secret ParseAPI key stays on the server.

1. Read the amount, pair and date

Keep the amount as decimal text until it becomes a Decimal. The example accepts up to nine whole digits and four decimal places, including zero and negative amounts. It offers a short list of supported currencies and rejects malformed or future dates before making a request.

Flask renders the current value into the amount field:

HTML
<label for="amount">Amount</label>
<input id="amount" name="amount" inputmode="decimal" value="{{ values.amount }}"
       maxlength="15" aria-describedby="amount-error"
       aria-invalid="{{ 'true' if errors.amount else 'false' }}" required>

When the form posts to /, the handler reads these named inputs:

Python
values = {
    "amount": request.form.get("amount", ""),
    "base": request.form.get("base", ""),
    "quote": request.form.get("quote", ""),
    "day": request.form.get("day", ""),
}

Re-render the submitted values when a field needs correction. The app also checks a session-bound CSRF token before requesting a rate.

2. Fetch the dated reference rate

Use the existing SDK operation for the selected pair:

Python
exchange = parse.currency.rate(base, quote, date=day.isoformat())

The rate can be dated earlier than the requested day. Weekends and holidays use the most recent published rate on or before it; missing history remains an error rather than falling back to today's rate.

Ask Currency for the target's usual decimal precision:

Python
currency = parse.currency(quote)

This second lookup supplies a fact needed to round the displayed amount. Zero digits is valid, as with JPY. Missing precision leaves conversion unavailable instead of guessing two places.

3. Calculate with the returned facts

Read the multiplier, applied date and precision:

Python
rate = Decimal(str(exchange["rate"]))
rate_date = exchange["date"]
digits = currency["digits"]

Decimal(str(...)) takes the rate supplied by the SDK into decimal arithmetic. The original form amount also becomes a Decimal, so multiplication does not use binary floating-point amounts.

Round once, at the target currency's precision:

Python
converted = (amount * rate).quantize(
    Decimal(1).scaleb(-digits), rounding=ROUND_HALF_EVEN
)

ROUND_HALF_EVEN resolves an exact tie to the nearest even final digit. Choose the rounding policy your application needs. The complete page formats that value and displays its currency and rate_date beside it.

4. Keep the result attached to its inputs

The result names the original amount and currency, the target currency, the multiplier and the published rate date. A zero result remains visible. Invalid input, unavailable history, request errors and malformed responses produce an error state with the person's entries still in the form.

The handler passes result into the page template, where the converted value and applied rate date stay together:

HTML
<p class="amount">{{ result.converted }} {{ result.quote }}</p>
<p>Rate: {{ result.rate }} · Published {{ result.rate_date }}</p>

These are reference estimates. Card charges, fees and settlement rates can differ. See the Currency reference for rate coverage.

The complete app reuses one SDK client per submitted conversion, with a ten-second timeout and no automatic retries. A failed first request prevents the second lookup.

The example generates a session secret when it starts. Use a stable APP_SECRET when adapting it to your own deployment. Currency selection here belongs to this conversion; the currency picker teaches an app-wide preference.

Complete example

Use the complete implementation as a reference when adding this feature to your app. It includes the validation and failure handling around the calls above.

View and copy the complete source
python-currency-converter.python.py
"""A local web form for dated reference-currency conversions."""
from datetime import date, datetime, timezone
from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN, localcontext
import hmac
import math
import os
import re
import secrets

import httpx
from flask import Flask, render_template_string, request, session
from parseapi import ParseAPI, ParseAPIError

app = Flask(__name__)
app.secret_key = os.environ.get("APP_SECRET") or secrets.token_hex(32)
app.config.update(MAX_CONTENT_LENGTH=8192, SESSION_COOKIE_HTTPONLY=True,
                  SESSION_COOKIE_SAMESITE="Lax", TRUSTED_HOSTS=["127.0.0.1", "localhost"])
CURRENCIES = ("USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "INR")
PAGE = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="data:,"><title>Currency converter</title><style>
:root{color-scheme:light dark;--bg:#f7f8fa;--panel:#fff;--text:#17191d;--muted:#595e68;--line:#d5d9e0;--accent:#2459d3;--error:#a51d2d}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{width:min(100% - 40px,640px);margin:72px auto}h1{font-size:clamp(2rem,6vw,2.8rem);line-height:1.12;letter-spacing:-.045em;margin:0 0 18px}h2{font-size:1rem;margin:0 0 12px}
p{margin:0 0 20px}.intro,.note{color:var(--muted)}form,.result{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:28px;margin-top:28px}.row{display:grid;grid-template-columns:1fr 1fr;gap:18px}
label{display:block;font-size:.9rem;font-weight:600;margin-bottom:8px}input,select,button{font:inherit;min-height:46px;border-radius:9px}input,select{width:100%;min-width:0;color:var(--text);background:var(--panel);border:1px solid var(--line);padding:10px 12px}
select{appearance:none;padding-right:36px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' stroke='%23777' stroke-width='1.5'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E");background-position:right 12px center;background-repeat:no-repeat}
.field{margin-bottom:20px}button{background:var(--accent);color:#fff;border:0;padding:11px 22px;font-weight:600;cursor:pointer}button:hover{filter:brightness(.93)}:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
.error{color:var(--error);font-size:.9rem;margin:8px 0 0}[aria-invalid=true]{border-color:var(--error)}.alert{padding:16px;border:1px solid var(--error);border-radius:10px;margin-top:24px;color:var(--error)}.amount{font-size:clamp(1.8rem,6vw,2.7rem);font-weight:650;letter-spacing:-.035em;overflow-wrap:anywhere;margin-bottom:8px}.note{font-size:.9rem;margin:18px 0 0}
@media(max-width:440px){main{margin:40px auto}.row{grid-template-columns:1fr;gap:0}form,.result{padding:22px}}
@media(prefers-color-scheme:dark){:root{--bg:#111316;--panel:#1b1e23;--text:#f3f4f6;--muted:#b3b8c3;--line:#444a55;--accent:#759cff;--error:#ff9ca8}button{color:#111316}}
</style></head><body><main><h1>Currency converter</h1><p class="intro">Convert an amount using the reference rate for your chosen date.</p>
{% if error %}<p class="alert" role="alert">{{ error }}</p>{% endif %}
<form method="post" action="/">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div class="field">
<!-- example:form -->
<label for="amount">Amount</label>
<input id="amount" name="amount" inputmode="decimal" value="{{ values.amount }}"
       maxlength="15" aria-describedby="amount-error"
       aria-invalid="{{ 'true' if errors.amount else 'false' }}" required>
<!-- /example:form -->
<p class="error" id="amount-error">{{ errors.get('amount', '') }}</p></div>
<div class="row">{% for field,label in [('base','From'),('quote','To')] %}<div class="field"><label for="{{ field }}">{{ label }}</label><select id="{{ field }}" name="{{ field }}"{% if errors[field] %} aria-invalid="true" aria-describedby="{{ field }}-error"{% endif %}>{% for code in currencies %}<option value="{{ code }}"{% if values[field]==code %} selected{% endif %}>{{ code }}</option>{% endfor %}</select>{% if errors[field] %}<p class="error" id="{{ field }}-error">{{ errors[field] }}</p>{% endif %}</div>{% endfor %}</div>
<div class="field"><label for="day">Rate date</label><input id="day" name="day" type="date" value="{{ values.day }}" max="{{ today }}" required{% if errors.day %} aria-invalid="true" aria-describedby="day-error"{% endif %}>{% if errors.day %}<p class="error" id="day-error">{{ errors.day }}</p>{% endif %}</div>
<button type="submit">Convert amount</button></form>
{% if result %}<section class="result" role="status" aria-live="polite"><h2>{{ result.amount }} {{ result.base }} equals</h2>
<!-- example:result -->
<p class="amount">{{ result.converted }} {{ result.quote }}</p>
<p>Rate: {{ result.rate }} · Published {{ result.rate_date }}</p>
<!-- /example:result -->
<p class="note">This is a reference estimate. Card charges, fees and settlement rates can differ.</p></section>{% endif %}
<p class="note">Weekends and holidays can use the last published rate on or before your chosen date.</p>
</main></body></html>"""


@app.after_request
def private_response(response):
    response.headers["Cache-Control"] = "no-store"
    response.headers["X-Content-Type-Options"] = "nosniff"
    return response


@app.errorhandler(413)
def too_large(_error):
    return "<h1>Request too large</h1><p>Go back and enter a shorter amount.</p>", 413


@app.route("/", methods=["GET", "POST"])
def index():
    today = datetime.now(timezone.utc).date().isoformat()
    values = {"amount": "12.50", "base": "EUR", "quote": "USD", "day": today}
    errors, error, result, status = {}, None, None, 200
    token = session.setdefault("csrf", secrets.token_urlsafe(32))
    if request.method == "POST":
        # example:inputs
        values = {
            "amount": request.form.get("amount", ""),
            "base": request.form.get("base", ""),
            "quote": request.form.get("quote", ""),
            "day": request.form.get("day", ""),
        }
        # /example:inputs
        submitted = request.form.get("csrf", "")
        if not submitted.isascii() or not hmac.compare_digest(submitted, token):
            error, status = "This form expired. Refresh the page and try again.", 403
        else:
            amount_text = values["amount"].strip()
            if not re.fullmatch(r"-?(?:0|[1-9][0-9]{0,8})(?:\.[0-9]{1,4})?", amount_text):
                errors["amount"] = "Enter an amount with up to nine whole digits and four decimal places."
            for field in ("base", "quote"):
                if values[field] not in CURRENCIES:
                    errors[field] = "Choose one of the listed currencies."
            try:
                day = date.fromisoformat(values["day"])
                if day.isoformat() != values["day"] or values["day"] > today:
                    raise ValueError()
            except ValueError:
                errors["day"] = "Choose a real date on or before today."
            if errors:
                status = 422
            else:
                key = os.environ.get("PARSEAPI_KEY", "").strip()
                if not key or key.startswith(("parse_public_", "parse_app_")):
                    error, status = "The converter is unavailable. Please try again later.", 503
                else:
                    base, quote = values["base"], values["quote"]
                    try:
                        # One interactive attempt, with one reused official SDK client.
                        with ParseAPI(api_key=key, timeout=10.0, retries=0) as parse:
                            # example:request
                            exchange = parse.currency.rate(base, quote, date=day.isoformat())
                            # /example:request
                            if (not isinstance(exchange, dict) or exchange.get("base") != base
                                    or exchange.get("quote") != quote
                                    or type(exchange.get("rate")) not in (int, float)
                                    or not math.isfinite(exchange["rate"]) or exchange["rate"] <= 0):
                                raise ValueError("Unexpected rate response.")
                            applied = date.fromisoformat(exchange.get("date", ""))
                            if applied.isoformat() != exchange["date"] or applied > day:
                                raise ValueError("Unexpected rate date.")
                            # example:precision
                            currency = parse.currency(quote)
                            # /example:precision
                            if (not isinstance(currency, dict) or currency.get("currency") != quote
                                    or type(currency.get("digits")) is not int or not 0 <= currency["digits"] <= 6):
                                raise ValueError("Currency precision is unavailable.")
                        # example:fields
                        rate = Decimal(str(exchange["rate"]))
                        rate_date = exchange["date"]
                        digits = currency["digits"]
                        # /example:fields
                        amount = Decimal(amount_text)
                        with localcontext() as context:
                            context.prec = 50
                            # example:convert
                            converted = (amount * rate).quantize(
                                Decimal(1).scaleb(-digits), rounding=ROUND_HALF_EVEN
                            )
                            # /example:convert
                        if converted == 0:
                            converted = abs(converted)
                        result = {"amount": format(amount, "f"), "base": base, "quote": quote,
                                  "converted": format(converted, "f"), "rate": format(rate, "f"),
                                  "rate_date": rate_date}
                    except ParseAPIError as failure:
                        if failure.status == 404:
                            error, status = "No reference rate is available for this pair and date. Try another date.", 422
                        else:
                            error, status = "The rate could not be checked. Please try again later.", 503
                    except httpx.RequestError:
                        error, status = "The rate could not be checked. Please try again later.", 503
                    except (ValueError, InvalidOperation, OverflowError, TypeError):
                        error, status = "The rate response could not be used. Please try again later.", 503
    return render_template_string(PAGE, values=values, errors=errors, error=error, result=result,
                                  currencies=CURRENCIES, today=today, csrf=token), status


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8000")), debug=False, use_reloader=False)
Try the complete example on your computer

After completing the setup above, save the source in your application folder and start the app:

Terminal
python3 python-currency-converter.python.py

Open 127.0.0.1:8000 to use the form. The server keeps your secret key out of the page.

For every field and parameter, see Currency API reference.

Build something else

All tutorials →