Countries and currencies
How TeePay markets work, how to discover them at runtime, and how amounts, phone numbers and currency conversion behave in each country.
TeePay is built to collect in more than one country. A market is a country together with its local currency, such as Tanzania with TZS. Every payment names its market explicitly, and your key reports which markets it can use. Build against that model and a new country works in your integration the day TeePay enables it, with no code change.
Never hard-code a country
Read supported_countries and supported_currencies from GET /v1/capabilities, and store the
currency next to every amount. Do not assume Tanzania, TZS or two decimal places anywhere.
Markets
| Market | customer.country | money.currency | Minor units | "amount_minor" example | Status |
|---|---|---|---|---|---|
| Tanzania | TZ | TZS | 2 | "500000" = TZS 5,000.00 | Requires approval |
| Kenya | KE | KES | 2 | "50000" = KES 500.00 | Not available yet |
| Uganda | UG | UGX | 0 | "5000" = UGX 5,000 | Not available yet |
A market becomes available only after TeePay has verified a payment route there and enabled it for your account. The table tells you what to expect; capabilities tells you what your key can do right now. Control Numbers are a Tanzanian payment method and stay TZS only. Payouts are currently sent in TZS.
Discover markets at runtime
curl https://api.teepay.me/v1/capabilities \
-H "Authorization: Bearer $TEEPAY_SECRET_KEY"{
"features": { "collection_create": true, "...": "..." },
"supported_countries": ["TZ"],
"supported_currencies": ["TZS"]
}Both lists are sorted and contain only markets your key can collect in now: TeePay has a working
route there, your key has collection:create, and your account's limits allow it. They are empty when
collection_create is false.
// Cache for a few minutes; re-read after an unsupported_market error.
export async function canCollect(country, currency) {
const response = await fetch('https://api.teepay.me/v1/capabilities', {
headers: { Authorization: `Bearer ${process.env.TEEPAY_SECRET_KEY}` },
});
const caps = await response.json();
return caps.features.collection_create
&& caps.supported_countries.includes(country)
&& caps.supported_currencies.includes(currency);
}Use it to decide which payment options to show at checkout, for example to offer mobile money only to customers in a market your account can serve.
Collect in a market
Every market uses the same mobile money push endpoint. Only the market fields change:
{
"money": { "amount_minor": "500000", "currency": "TZS" },
"customer": {
"country": "TZ",
"phone": "+255712345678",
"name": "Asha Juma",
"email": "asha@example.com"
},
"description": "Order 1001"
}If the pair is not enabled for your key, nothing is sent to the customer and the request fails with
unsupported_market. Checkout sessions take the same ISO 4217 currency and fail with
unsupported_currency when it is not enabled.
Amounts and minor units
Every amount is an integer in the currency's minor unit, as defined by ISO 4217. Requests send it as a string, responses return it as a number, and the currency always travels with it.
// ISO 4217 exponents. Add a currency here when you start selling in it.
const EXPONENT = { TZS: 2, KES: 2, UGX: 0 };
export function toMinor(major, currency) {
const exponent = EXPONENT[currency];
if (exponent === undefined) throw new Error('Unsupported currency ' + currency);
// Work from a decimal string, never floating-point arithmetic, for real money.
const [whole, fraction = ''] = String(major).split('.');
if (fraction.length > exponent) throw new Error('Too many decimal places for ' + currency);
return BigInt(whole + fraction.padEnd(exponent, '0')).toString();
}
export function format(minor, currency, locale = 'en') {
const exponent = EXPONENT[currency];
return new Intl.NumberFormat(locale, { style: 'currency', currency, minimumFractionDigits: exponent, maximumFractionDigits: exponent })
.format(Number(minor) / 10 ** exponent);
}Each market has its own minimum, maximum and amount step, and your account has per-transaction and
daily limits. An amount outside the market's range fails with invalid_amount; one above your
available limit fails with collection_limit_exceeded. In both cases nothing is charged.
Phone numbers
Send the payer's mobile money number in international format with the country code, such as
+255712345678 (Tanzania), +254712345678 (Kenya) or +256712345678 (Uganda). The number must belong
to a mobile money wallet in customer.country.
Currency conversion
Not available yetToday every payment is collected and settled in the same currency, so there is no conversion fee.
When TeePay enables conversion, for example collecting KES and settling to a TZS balance, it will work like this:
- The fee is separate. Conversion is charged as its own fee, never hidden inside the exchange rate.
- It is charged in your settlement currency and rounded up once to that currency's smallest unit.
- The price is fixed when the payment happens. Later price changes never change a past payment.
- Same currency means no conversion fee, even when the payer is in another country.
The response fields that describe conversion will be added to the API and to this page before the feature is switched on. Following versioning rules, they are additive: integrations that tolerate unknown fields keep working unchanged.
Errors
| HTTP | Code | Meaning | What to do |
|---|---|---|---|
| 422 | unsupported_market | customer.country and money.currency are not enabled for your key. | Re-read capabilities and offer another payment method. |
| 422 | invalid_amount | Below the market's minimum, above its maximum or not a valid step. | Use an amount the market accepts. |
| 422 | collection_limit_exceeded | Above your available transaction or daily limit. | Lower the amount or wait. |
| 422 | unsupported_currency | Checkout session currency not enabled for your account. | Use an enabled currency. |
Checklist
- Store
currencynext to every amount in your database. - Convert with the currency's exponent; never assume two decimal places.
- Read capabilities at start-up and cache them for a few minutes.
- Send
customer.countryand an international phone number on every collection. - Treat
unsupported_marketas "not available here", not as a bug in your request.