Troubleshooting
Find the message from the response in the tables below. Per-record messages appear in results[].errors; whole-request messages appear in the detail field of an error response.
Whole-request errors
Section titled “Whole-request errors”| HTTP | Message | Cause | Fix |
|---|---|---|---|
401 |
api key required |
No Authorization header, or you used a browser session instead of an API key |
Send Authorization: Bearer spk_… |
401 |
unsupported authorization scheme |
The header doesn’t start with Bearer |
Check spelling and the single space |
401 |
invalid api key |
The key is mistyped, revoked or expired | Check your secret manager; ask an administrator for a new key |
403 |
api key lacks scope ingest:write |
The key was created for something else, e.g. decide |
Create a key with ingest:write |
404 |
The server isn’t running the ingest role, or the path is wrong |
Check the path is /api/v1/ingest/…; ask your operator to enable ingest |
|
413 |
at most N items per request |
The operator lowered the batch limit (SP_INGEST_MAX_BATCH) below 500 and the batch is larger |
Split the batch to at most N records |
413 |
request body too large |
Body over 8 MiB (or the operator’s SP_INGEST_MAX_BODY_BYTES) |
Split the batch |
422 |
validation failed |
At least one record breaks the format rules. The errors list gives each problem and its location, e.g. body.items[37].direction |
Fix those records. The whole batch was refused; resend all of it |
503 |
ingest temporarily unavailable; retry the batch |
The queue couldn’t accept anything | Wait and resend the same batch |
421 |
this API key belongs to the … region; send requests to … |
Your SpeculaPro is hosted in more than one region and the request went to the wrong one, or your workspace was moved | Nothing was accepted. Change your base URL to the base_url in the response and resend. See Regions for integrators |
503 |
the workspace is read-only while it is moved to another region; try again shortly |
A planned move between regions is starting | Keep the batch and resend after Retry-After |
503 |
the workspace is being moved to another region; retry shortly |
The workspace is between regions | Keep the batch and resend after Retry-After; once the move is done, resend to the new region’s base URL |
500 |
Unexpected fault | Wait and resend the same batch. If it persists, give your operator the time of the request |
Typical 422 causes
Section titled “Typical 422 causes”errors[].location ends in |
Likely cause |
|---|---|
.direction, .type, .channel, .status |
A value not in the allowed list, e.g. "inbound" instead of "in", or upper case "MOMO" instead of "momo" |
.occurred_at, .onboarded_at, .opened_at |
Missing (for occurred_at), or not a full date-time with timezone, e.g. "2026-10-09 08:14" |
.date_of_birth, .as_of |
Not in YYYY-MM-DD form |
.amount_minor |
Negative, a decimal (4950.00), or a string ("495000") |
.currency, .country, .nationality |
Not upper case, or wrong length ("ghs", "GHA") |
.rate |
Sent as a number (15.25) instead of a string ("15.25"), or negative |
.mcc |
Not exactly four digits |
.attributes |
More than 64 keys |
.items |
Empty items array, or more than 500 records (the hard ceiling) |
Transactions
Section titled “Transactions”| Message | Cause | Fix |
|---|---|---|
occurred_at is required |
The time was sent as an empty “zero” date (0001-01-01T00:00:00Z), usually a default value in the exporting system |
Send the real time |
occurred_at is in the future |
More than 5 minutes ahead of SpeculaPro’s clock. Usually a local time sent as UTC: 08:14 Accra time sent as 08:14Z is fine (Ghana is on UTC), but Lagos 08:14 sent as 08:14Z is an hour in the future |
Send an explicit offset (+01:00), or convert to UTC first. Check your server clocks are synchronised |
occurred_at is before 2000 |
A corrupted or placeholder date | Send the real time |
external_id has leading or trailing spaces |
Padding from a fixed-width file or a database CHAR column |
Trim the value, the same way every time |
currency must be an ISO 4217 code |
Not three capital letters | Use GHS, USD, XOF… |
ip is not a valid IPv4/IPv6 address |
Includes a port (41.66.12.9:443), or is a hostname |
Send the bare address |
external_id repeated in this batch with different content |
Two different transactions in one batch share an external_id |
Your references aren’t unique. Fix the source system’s ID generation |
external_id was already used for a different transaction |
You sent a transaction with an external_id SpeculaPro already holds, but with something changed |
If it’s a correction or reversal, send a new transaction (how). If nothing should have changed, check whether your exporter rewrites fields such as the time format or attributes |
no FX rate for USD→GHS on or before 2026-10-09; load rates via /api/v1/ingest/fx-rates |
No rate for that currency dated on or before the transaction | Load rates, wait a few minutes, resend |
latest FX rate for USD→GHS is from 2026-09-28, too old for 2026-10-09 |
Rates stopped being loaded | Load current rates, resend. Fix the daily rate job |
temporarily unavailable, retry this item (status retry) |
The queue didn’t confirm this record | Resend it unchanged |
Customers
Section titled “Customers”| Message | Cause | Fix |
|---|---|---|
phone is not a valid number |
Fewer than 8 or more than 15 digits after conversion, or a local number (starting 0) in a workspace whose country SpeculaPro can’t convert |
Send the number in + international format. See How phone numbers are read |
email is not valid |
No @ |
Correct it or leave it out |
invalid data: violates … |
The record broke a database rule. The name after violates says which |
Check values against the field table |
Status stale |
The version you sent is equal to or lower than the stored one |
Expected after a resend: nothing changed. If you expected the update to apply, your version didn’t increase; see Versions |
Accounts
Section titled “Accounts”| Message | Cause | Fix |
|---|---|---|
currency must be an ISO 4217 code |
Not three capital letters | Use GHS, USD, XOF… |
invalid data: violates … |
The record broke a database rule | Check values against the field table |
Status stale |
The version isn’t higher than the stored one |
As for customers |
FX rates
Section titled “FX rates”| Message | Cause | Fix |
|---|---|---|
quote equals the workspace base currency |
A rate for the base currency itself | Leave it out |
rate must be greater than zero |
A rate of zero, written any way ("0", "0.000"). Only that rate is rejected; the rest of the batch is stored. (A negative rate or text that isn’t a number fails the whole request with 422 instead.) |
Send the real rate, or leave that currency out for the day |
Account events
Section titled “Account events”| Message | Cause | Fix |
|---|---|---|
occurred_at is more than 180 days ago |
A backfill reaching too far back | Send events from the last 180 days only. Older ones can’t affect any rule |
ip is not a valid IPv4/IPv6 address |
Malformed address | Send the address alone, without a port |
422 naming type or channel |
A value outside the allowed list. Event channels differ from transaction channels: mobile_app, online, ussd, branch, agent, atm, call_centre, api, other |
See Account events |
The other event rejections are the same as for transactions.
Things that go wrong without an error
Section titled “Things that go wrong without an error”| Symptom | Cause | Fix |
|---|---|---|
| Amounts in CFA francs look 100 times too large | Sent in “centimes” | XOF and XAF have no decimals. See Currencies and FX |
| Foreign-currency amounts look about 200 times too small | The FX rate was loaded inverted | rate is base per 1 unit of foreign currency. See FX rates |
| Customer details disappeared after an update | The update left fields out | Updates replace the whole record. Always send the full customer |
Customer updates always come back stale |
Your version doesn’t increase between updates (for example a fixed value, or a counter that restarted) |
Use a value that only ever increases, such as last-updated time in milliseconds. See Versions |
Customers show as stubs ("_stub": true) |
Transactions or accounts arrived, but the customer record never did | Check the customer feed is running and covers everyone |
| Transfers between your own customers aren’t linked | SpeculaPro didn’t know the numbers were phone numbers (no mobile-money provider and no identifier_type: "msisdn"), so they weren’t converted to one format; or bank account numbers differ in more than spacing and case |
Send provider or identifier_type. See How identifiers are normalised |
| Old transactions counted twice | Resent more than 90 days after first being received | See The 90-day window |
| A fraud rule that counts failed OTPs or PINs never fires | No account events arrive, or they carry a customer_external_id that doesn’t match the transactions’ |
Send account events with the same customer references as your transactions |
| New-device, shared-device or impossible-travel rules never fire | Transactions don’t carry device_fingerprint, or geo_lat and geo_lon |
Add them to transactions. Account events alone don’t set a device as known or move the customer’s location |
| A short-window rule (15 minutes or less) misses activity in a backlog | The transactions or events arrived more than about 20 minutes late | Send in real time. See Late and backdated data |
| A failed-OTP count reads one too many | An accepted event was resent more than 30 minutes later | Resend only retry items, straight away. See Sending the same event twice |