Customers
In short
Section titled “In short”A customer is a person or business you serve. Rules use what you tell SpeculaPro about them (type, risk rating, KYC level, segment, declared income) to decide what is unusual for that customer. Send every customer once, then send them again whenever something about them changes.
POST /api/v1/ingest/customersAuthorization: Bearer spk_…Fields
Section titled “Fields”The body is {"items": [ … ]} with up to 500 customers (the server’s SP_INGEST_MAX_BATCH).
| Field | Type | Required | What it is | Example |
|---|---|---|---|---|
external_id |
string, 1–128 | Yes | Your own reference for the customer. Never changes, never reused | "CUST-77120" |
type |
individual · business |
Yes | Person or organisation | "individual" |
version |
integer ≥ 0 | No, but strongly advised | A number from your system that increases every time the record changes, such as the last-updated time in milliseconds. See Versions | 1760000000000 |
status |
active · dormant · restricted · exited |
No (default active) |
The relationship status | "active" |
segment |
string, ≤ 64 | No | Your own segment name, used to tune rules per segment | "retail", "sme", "agent" |
risk_rating |
unrated · low · medium · high · unacceptable |
No (default unrated) |
Your customer risk assessment | "medium" |
kyc_level |
integer 0–10 | No | KYC tier. Drives tier-limit rules such as mobile money wallet limits | 2 |
full_name |
string, ≤ 200 | No | Name as on their ID | "Ama Serwaa Mensah" |
date_of_birth |
date YYYY-MM-DD |
No | Individuals | "1991-04-17" |
registration_date |
date YYYY-MM-DD |
No | Businesses | "2019-02-01" |
nationality |
2-letter country code | No | ISO 3166, upper case | "GH" |
country |
2-letter country code | No | Country of residence or registration | "GH" |
national_id |
object | No | {"type": …, "value": …}. Encrypted, see Personal data |
{"type": "ghana_card", "value": "GHA-123456789-0"} |
phone |
string, ≤ 32 | No | Mobile number, local or international. Encrypted | "024 412 3456" |
email |
string, ≤ 254 | No | Encrypted | "[email protected]" |
occupation |
string, ≤ 100 | No | Declared occupation | "Trader" |
industry_code |
string, ≤ 16 | No | ISIC code, for businesses | "4711" |
declared_monthly_volume_minor |
integer ≥ 0 | No | What the customer said they would move per month, in minor units of declared_currency |
500000 (GHS 5,000.00) |
declared_currency |
3-letter currency code | No | Currency of the declared volume | "GHS" |
onboarded_at |
date-time (RFC 3339) | No | When the relationship started | "2023-06-12T10:00:00Z" |
attributes |
object, ≤ 64 keys | No | Your own extra fields. Rules can use them | {"branch": "Kumasi Central"} |
national_id.type is one of ghana_card, bvn, nin, national_id, cedeao_id, passport, voter_id, drivers_licence, tin, company_reg, other, and national_id.value is 1–64 characters. Use the type your jurisdiction defines: bvn or nin in Nigeria, national_id or cedeao_id (the ECOWAS biometric card) in the UEMOA states, ghana_card in Ghana.
Example
Section titled “Example”curl https://speculapro.example.com/api/v1/ingest/customers \ -H "Authorization: Bearer $SPECULAPRO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "items": [ { "external_id": "CUST-77120", "version": 1760000000000, "type": "individual", "segment": "retail", "risk_rating": "medium", "kyc_level": 2, "full_name": "Ama Serwaa Mensah", "date_of_birth": "1991-04-17", "nationality": "GH", "country": "GH", "national_id": {"type": "ghana_card", "value": "GHA-123456789-0"}, "phone": "024 412 3456", "occupation": "Trader", "declared_monthly_volume_minor": 500000, "declared_currency": "GHS", "onboarded_at": "2023-06-12T10:00:00Z", "attributes": {"branch": "Kumasi Central"} }, { "external_id": "CUST-90311", "version": 1760000000000, "type": "business", "segment": "sme", "risk_rating": "low", "full_name": "Nkrumah Circle Provisions Ltd", "registration_date": "2019-02-01", "country": "GH", "national_id": {"type": "company_reg", "value": "CS123456789"}, "industry_code": "4711", "phone": "+233 30 123 4567" } ] }'{ "accepted": 2, "duplicate": 0, "stale": 0, "rejected": 0, "retry": 0, "results": [ {"index": 0, "external_id": "CUST-77120", "status": "accepted", "id": "0199c8f0-0b31-71e4-a6c2-5d0e8b3f7a19"}, {"index": 1, "external_id": "CUST-90311", "status": "accepted", "id": "0199c8f0-0b31-71e4-a6c2-6a17c4d2e805"} ]}The id is SpeculaPro’s own identifier for the customer. You don’t need to store it; keep using your external_id.
Updating a customer
Section titled “Updating a customer”Send the customer again with the same external_id. Each record you send is a complete snapshot of the customer: SpeculaPro replaces what it holds with what you sent.
Versions
Section titled “Versions”Records can arrive out of order: a retry, two senders, a back-fill running alongside live updates. version makes sure an older update never overwrites a newer one.
| You send | What happens | Status |
|---|---|---|
A version greater than the stored one |
The record is applied, and the stored version becomes yours | accepted |
A version equal to the stored one |
Nothing changes. Resending the same record is therefore harmless | stale |
A version lower than the stored one |
Nothing changes: SpeculaPro already has newer data | stale |
No version |
The record is always applied. The stored version is not raised: it stays where it was (or 0 for a new customer) | accepted |
stale isn’t an error. It means SpeculaPro already has the same or newer data, so mark the record as sent.
Good choices for version: the record’s last-updated time in milliseconds since 1970, or a row version counter from your database. Anything that only ever increases for a given customer works.
Placeholder customers (“stubs”)
Section titled “Placeholder customers (“stubs”)”If an account or transaction mentions a customer SpeculaPro hasn’t seen, SpeculaPro creates a stub: a customer with just the external_id and type (individual unless the transaction said otherwise), marked with "_stub": true in its attributes and version -1. The first real record you send for that customer replaces the stub completely, whatever its version (or none), because every real version is higher than -1.
Personal data
Section titled “Personal data”SpeculaPro treats some fields as personal identifiers and never stores them in readable form:
| Field | How it is stored |
|---|---|
national_id.value |
Encrypted (AES-256-GCM, a separate key per workspace), plus a keyed fingerprint for matching |
phone |
Normalised (see below), then encrypted, plus a keyed fingerprint |
email |
Lower-cased and trimmed, then encrypted, plus a keyed fingerprint |
The keyed fingerprint (a “blind index”) lets SpeculaPro notice that two customers share the same Ghana Card or phone number, without the number itself being readable in the database. See Data protection.
Names, dates of birth, occupations and your custom attributes are stored as sent. Don’t put identifiers in attributes: they would not be encrypted.
SpeculaPro doesn’t keep the raw request body.
How phone numbers are read
Section titled “How phone numbers are read”So that the same subscriber always produces the same fingerprint, phone numbers are converted to international (E.164) format. Spaces, dashes and brackets are ignored.
| You send | Stored as | Why |
|---|---|---|
"024 412 3456" |
+233244123456 |
Starts with 0: the leading 0 is replaced by your workspace’s country code |
"+233 24 412 3456" |
+233244123456 |
Already international |
"00233244123456" |
+233244123456 |
00 is read as + |
"233244123456" |
+233244123456 |
Already starts with your country code |
"244123456" |
+233244123456 |
Your country code is added |
The country code comes from the workspace’s country, not the customer’s. Numbers must end up with 8 to 15 digits, otherwise the customer is rejected with phone is not a valid number.
National ID numbers
Section titled “National ID numbers”Spaces, dashes, dots and slashes are removed and letters are upper-cased before encryption, so GHA-123456789-0 and gha 123456789 0 are the same number. The ID type is part of the fingerprint: the same digits sent as passport and as ghana_card are treated as different identifiers.
How national identifiers are checked
Section titled “How national identifiers are checked”Your workspace’s jurisdiction knows what a valid identifier of each type looks like: 11 digits for a Nigerian BVN or NIN, GHA-123456789-0 for a Ghana Card, 11 digits for a Côte d’Ivoire NNI, and so on. Each national_id you send is checked against it, after trimming, upper-casing and removing inner spaces (dashes are kept). What happens to a value that doesn’t fit depends on the workspace’s Identifier checks setting:
-
Warn (the default): the customer is
accepted, and its result carries a warning:{"index": 0, "external_id": "CUST-1", "status": "accepted", "id": "…","warnings": ["national_id: bvn doesn't look like a valid Bank Verification Number (BVN) (expected a value like 22123456789)"]} -
Reject: the customer is
rejected, with that message as its error. -
Off: nothing is checked.
Types without a known format, and other, are never checked. Watch the warnings while you connect: they usually mean the wrong type was sent, or the value was cut short.
Jurisdiction attributes
Section titled “Jurisdiction attributes”Some jurisdiction rules read fields you send in attributes. They are optional: a rule that needs one simply skips customers or transactions without it.
| Attribute | On | Used by | Send |
|---|---|---|---|
bvn_verified |
Customer | ng.unverified_bvn_payout |
true once the customer’s Bank Verification Number has been validated with NIBSS. A Nigerian customer without it is treated as unverified |
For example: "attributes": {"bvn_verified": true}. Transaction attributes are listed in Sending transactions.
Common rejections
Section titled “Common rejections”| Message | Cause | Fix |
|---|---|---|
phone is not a valid number |
Too few or too many digits, or a local number in a country SpeculaPro can’t convert | Send the number in + international format |
email is not valid |
No @ |
Correct the address, or leave email out |
invalid data: violates … |
The record broke a database rule, for example an unknown value | Check the field values against the table above |
Whole-request errors (422), such as a misspelt type or a badly formatted date, are covered in Responses and statuses.