Skip to content

Customers

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/customers
Authorization: Bearer spk_…

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.

Terminal window
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.

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.

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.

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.

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.

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.

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.

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.

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.

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.