FX rates
In short
Section titled “In short”Every transaction is measured in your workspace’s base currency (usually GHS), so a threshold such as “GHS 50,000 in a day” works whatever currency the money moved in. To convert, SpeculaPro needs your exchange rates. You load them through this endpoint, ideally once a day, before that day’s transactions.
If all your transactions are in your base currency, you never need this endpoint.
POST /api/v1/ingest/fx-ratesAuthorization: Bearer spk_…Fields
Section titled “Fields”The body is {"items": [ … ]} with up to 500 rates.
| Field | Type | Required | What it is | Example |
|---|---|---|---|---|
quote |
3-letter code | Yes | The foreign currency | "USD" |
as_of |
date YYYY-MM-DD |
Yes | The day the rate applies from | "2026-10-09" |
rate |
string of digits, up to 12 decimals | Yes | How many units of your base currency one unit of quote buys |
"15.25" |
source |
string, ≤ 64 | No (default api) |
Where the rate came from, kept for audit | "BoG interbank" |
The base currency is always your workspace’s; you don’t send it.
rate is a string, not a JSON number, so it arrives exactly as written with no floating-point rounding: "15.25", not 15.25. It must be greater than zero.
Example
Section titled “Example”curl https://speculapro.example.com/api/v1/ingest/fx-rates \ -H "Authorization: Bearer $SPECULAPRO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "items": [ {"quote": "USD", "as_of": "2026-10-09", "rate": "15.25", "source": "BoG interbank"}, {"quote": "EUR", "as_of": "2026-10-09", "rate": "17.6120", "source": "BoG interbank"}, {"quote": "XOF", "as_of": "2026-10-09", "rate": "0.02685", "source": "BoG interbank"}, {"quote": "NGN", "as_of": "2026-10-09", "rate": "0.0101", "source": "BoG interbank"} ] }'{ "accepted": 4, "duplicate": 0, "stale": 0, "rejected": 0, "retry": 0, "results": [ {"index": 0, "external_id": "USD@2026-10-09", "status": "accepted"}, {"index": 1, "external_id": "EUR@2026-10-09", "status": "accepted"}, {"index": 2, "external_id": "XOF@2026-10-09", "status": "accepted"}, {"index": 3, "external_id": "NGN@2026-10-09", "status": "accepted"} ]}Rates have no external_id of their own; the result shows QUOTE@DATE so you can match results to what you sent.
Correcting a rate
Section titled “Correcting a rate”Send the same quote and as_of again with the right rate. The new value replaces the old one for that day.
How to load rates every day
Section titled “How to load rates every day”- Choose your rate source (for example, your treasury’s daily rates or the central bank’s published rates) and use it consistently.
- Schedule a job early each morning, before the day’s foreign-currency transactions arrive, that sends every currency you deal in with today’s
as_of. - Check the response: every rate should be
accepted. - Alert someone if the job fails. Without fresh rates, foreign-currency transactions are rejected after a few days (see below).
How SpeculaPro chooses a rate
Section titled “How SpeculaPro chooses a rate”For each transaction, SpeculaPro uses the most recent rate dated on or before the transaction’s date (occurred_at, as a UTC calendar day).
- A rate dated after the transaction is never used.
- If the newest usable rate is more than 8 days older than the transaction (the default 7-day maximum age,
SP_INGEST_FX_MAX_AGE, plus one day of grace), the transaction is rejected rather than converted with a stale rate.
So rates loaded every working day cover weekends and public holidays without any extra effort.
Common rejections
Section titled “Common rejections”| Message | Cause | Fix |
|---|---|---|
quote equals the workspace base currency |
You sent a rate for the base currency itself (e.g. GHS in a GHS workspace) | Leave it out: no conversion is needed |
rate must be greater than zero |
A rate of zero, however it is written ("0", "0.000"). Only that rate is rejected; the others in the batch are stored |
Send the real rate, or leave the currency out for that day |
422 for the whole request |
Missing field, a rate sent as a number instead of a string, a negative rate, or a date not in YYYY-MM-DD form |
See Responses and statuses |