Account events
In short
Section titled “In short”Most account takeovers start before any money moves. Someone logs in from a new phone, gets three one-time passwords (OTPs) wrong, swaps the SIM card or changes the email address, and only then sends the money. An account event is one of those non-monetary moments. Send them to SpeculaPro as they happen and fraud rules can use them, for example to hold a payout made a few minutes after a phone number changed.
| Endpoint | POST /api/v1/ingest/events |
| API key scope | ingest:write, the same key you use for transactions |
| Batch size | 1 to 500 events (or your operator’s lower limit) |
| Duplicate protection | By your external_id |
Account events never raise an alert by themselves, and they don’t count as transactions in any total. Rules read them in two ways:
- How many of a type happened in a period, such as “failed OTPs in the last 30 minutes”. See Counting account events.
- How long ago the latest of a type happened, such as “hours since the last SIM swap”. See
customer.hours_since.
What to send
Section titled “What to send”Send an event whenever one of these happens on a customer’s account, on any channel:
type |
When to send it |
|---|---|
login |
A successful sign-in to the app, website or USSD menu |
login_failed |
A failed sign-in attempt |
logout |
The customer signed out |
session_start |
A new session started, where your system separates sessions from sign-ins |
otp_sent |
You sent a one-time password |
otp_failed |
A wrong or expired one-time password was entered |
otp_passed |
A one-time password was entered correctly |
password_change |
The customer changed their password |
password_reset |
The password was reset (forgotten password, or by your staff) |
pin_change |
The PIN was changed |
pin_failed |
A wrong PIN was entered |
email_change |
The email address on the account changed |
phone_change |
The phone number on the account changed |
address_change |
The postal or home address changed |
sim_swap |
The mobile network told you the customer’s SIM was replaced |
device_added |
A new device was registered or trusted for the account |
device_removed |
A device was removed |
payee_added |
A new beneficiary was saved |
payee_removed |
A beneficiary was removed |
card_added |
A card was added or issued |
card_blocked |
A card was blocked |
limit_change |
A transaction or daily limit was changed |
kyc_change |
The customer’s KYC level changed |
account_frozen |
The account was frozen |
account_unfrozen |
The account was unfrozen |
Any other value fails the whole request with 422.
Fields
Section titled “Fields”Required
Section titled “Required”| Field | Type | What it is |
|---|---|---|
external_id |
string, 1–128 characters | Your unique reference for this event. It protects against duplicates, so it must never change and never be reused |
customer_external_id |
string, 1–128 characters | The customer it happened to, as in your customer feed |
type |
string | One of the 25 types above |
occurred_at |
date-time (RFC 3339) | When it happened, with a timezone, for example 2026-10-09T09:08:07Z |
Optional
Section titled “Optional”| Field | Type | What it is | Why it matters |
|---|---|---|---|
account_external_id |
string, ≤ 128 | The account involved, if the event concerns one | Stored with the event, and counted on that account as well as on the customer, so rules can count events per account. An account SpeculaPro doesn’t know yet is created as a placeholder for this customer (type other, in your base currency) until your account feed sends the real one |
channel |
mobile_app · online · ussd · branch · agent · atm · call_centre · api · other |
Where it happened | Shown on the customer’s page |
device_fingerprint |
string, ≤ 256 | Your identifier for the phone or browser | Links the event to a device, and counts the customer on that device for shared-device rules |
session_id |
string, ≤ 128 | Your app or web session | Stored with the event. Not shown on screen or used by rules yet |
ip |
IPv4 or IPv6 address | Where the request came from | Stored with the event (see the caution below) |
geo_lat, geo_lon |
numbers | Location, if you have it | Stored with the event (see the caution below) |
success |
true · false |
Whether an attempt worked, for types where the name alone doesn’t say | A failed attempt is highlighted on the customer’s page |
attributes |
object, ≤ 32 keys | Your own fields, such as the reason for a limit change | Stored with the event |
Example
Section titled “Example”Kwabena’s phone number is changed in the app at 09:08, after three wrong OTPs:
curl https://speculapro.example.com/api/v1/ingest/events \ -H "Authorization: Bearer $SPECULAPRO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "items": [ { "external_id": "EVT-2026-10-09-0091273", "customer_external_id": "CUST-20418", "type": "otp_failed", "occurred_at": "2026-10-09T09:03:07Z", "channel": "mobile_app", "device_fingerprint": "android-9c41e2", "session_id": "s-7f20aa", "ip": "102.176.94.17", "success": false }, { "external_id": "EVT-2026-10-09-0091301", "customer_external_id": "CUST-20418", "type": "phone_change", "occurred_at": "2026-10-09T09:08:07Z", "channel": "mobile_app", "device_fingerprint": "android-9c41e2", "session_id": "s-7f20aa", "attributes": { "verified_by": "otp" } } ] }'{ "accepted": 2, "duplicate": 0, "stale": 0, "rejected": 0, "retry": 0, "results": [ { "index": 0, "external_id": "EVT-2026-10-09-0091273", "status": "accepted", "id": "6a1f0c52-9d3e-5b7a-8c21-4e0f9b7d2a61" }, { "index": 1, "external_id": "EVT-2026-10-09-0091301", "status": "accepted", "id": "0b9e3d74-1c25-5f8a-9e40-7d2c6a1b8f93" } ]}If Kwabena tries to send GHS 900 a few minutes later and the H5 rule is running in a real-time decision, the payout is held for review.
Responses
Section titled “Responses”The response has the same shape as for transactions; see Responses and statuses. Events can come back as:
| Status | Meaning | What to do |
|---|---|---|
accepted |
Recorded and queued. id is SpeculaPro’s id for the event |
Nothing |
duplicate |
The same external_id appears earlier in this batch; only the first is used |
Nothing, unless they were really two different events: then fix your references |
rejected |
Failed a check; errors says why |
Fix and resend |
retry |
Not accepted this time | Send the same item again |
Events are never stale. If every event in a batch would be retry, the whole request fails
with 503 ingest temporarily unavailable; retry the batch.
| Rejection | Cause | Fix |
|---|---|---|
occurred_at is required |
An empty “zero” date such as 0001-01-01T00:00:00Z |
Send the real time |
occurred_at is in the future |
More than 5 minutes ahead of SpeculaPro’s clock | Check your server’s clock and send an explicit offset |
occurred_at is more than 180 days ago |
Too old to matter to any rule | Don’t backfill events older than 180 days |
external_id has leading or trailing spaces |
Padding from an export | Trim the value, the same way every time |
ip is not a valid IPv4/IPv6 address |
Malformed address | Send the address alone, without a port |
An unknown type or channel, a missing required field or more than 32 attributes fails the
whole batch with 422 before any event is checked.
Sending the same event twice
Section titled “Sending the same event twice”An event’s id is worked out from your workspace and its external_id, so it is stored once
however often you send it. Resending is safe, with one limit for counts:
The customer doesn’t have to exist yet. As with transactions, an unknown customer_external_id
gets a placeholder customer that your
customer feed later fills in. The same goes for an unknown account_external_id: it becomes a
placeholder account for that customer, of type other and in your base currency, until your
account feed sends the real one.
Late events
Section titled “Late events”Send events as they happen, and no later than your transactions. An event that reaches SpeculaPro late still counts, but not in every window:
The latest time of each event type (customer.hours_since)
is kept for 180 days and isn’t affected: a SIM swap reported an hour late still blocks a payout
under G3.
Where events show up
Section titled “Where events show up”- On the customer’s page, under Digital footprint: the latest 30 events from the last 90 days, and the devices the customer has used. See Customer profile.
- Through
GET /api/v1/customers/{id}, asaccount_eventsanddevices.