CEL reference
Ce contenu n’est pas encore disponible dans votre langue.
In short
Section titled “In short”A rule’s logic is one expression in CEL (Common Expression Language), a
small, safe language designed for exactly this: checking a condition quickly, with no loops and
no way to touch anything outside the data it’s given. The expression must come out as true
(the rule fires) or false (it doesn’t).
txn.type == 'cash_in' && txn.amount >= params.thresholdThis page lists everything an expression can read. The tables come straight from the engine, so if a field isn’t here, it isn’t available.
The variables
Section titled “The variables”| Variable | What it holds |
|---|---|
txn |
The transaction being evaluated |
customer |
The customer who owns the account |
account |
Your account involved in the transaction |
counterparty |
The other party, when known |
device |
The phone or browser used, when the transaction carried a device_fingerprint |
features |
The aggregates this rule declared, such as “cash-in total over 48 hours” |
params |
This rule version’s tunable parameters |
lists |
Your workspace’s lists, by key |
workspace |
Settings of your workspace that a rule can follow instead of repeating them as a parameter |
| Field | Type | Meaning |
|---|---|---|
amount |
double | Amount in your base currency, in major units (cedis, not pesewas) |
amount_minor |
int | Amount in the original currency’s minor units, as sent |
amount_original |
double | Amount in the original currency, in major units with that currency’s own decimals: USD 12,000.00 is 12000.0, JPY 500 is 500.0, KWD 1.250 is 1.25 |
currency |
string | Original currency code, such as GHS or USD |
base_currency |
string | Your workspace’s base currency, such as GHS: the currency amount is in. Use it to label amounts in explanations, {{txn.base_currency}} {{txn.amount}} |
direction |
string | in, out or internal |
type |
string | transfer, payment, deposit, withdrawal, cash_in, cash_out, airtime, bill_payment, loan_disbursement, loan_repayment, refund, reversal, chargeback, fee, other |
channel |
string | momo, bank_transfer, instant_payment, card, pos, atm, agent, branch, cash, online, mobile_app, ussd, qr, crypto, cheque, other |
status |
string | pending, completed, failed, reversed, blocked |
country |
string | Where the transaction took place (ISO code), or '' |
counterparty_country |
string | The other party’s country, or '' |
mcc |
string | Merchant category code for card payments, or '' |
hour |
int | Hour of day, 0 to 23, in your workspace’s time zone |
weekday |
int | Day of the week in your workspace’s time zone: 1 = Monday … 7 = Sunday |
date |
string | The transaction’s date in your workspace’s time zone, as YYYY-MM-DD. Dates written this way compare in order, so txn.date >= '2026-11-02' is true from that day on. Use it for a limit that changes on a known date |
has_counterparty |
bool | Whether the transaction named another party |
has_device |
bool | Whether a device_fingerprint was sent |
has_location |
bool | Whether geo_lat and geo_lon were sent |
country_is_new |
bool | The first transaction involving this country for the customer. The country is counterparty_country when sent, otherwise country. false when neither was sent |
ip_is_new |
bool | The first transaction from this IP network for the customer: the same /24 for IPv4, /48 for IPv6, so a phone that gets a new address from the same provider isn’t “new”. false when no ip was sent |
km_from_last |
double | Distance in km from the customer’s previous known location, or -1 if unknown |
minutes_since_last_location |
double | Minutes since that previous location, or -1 if unknown |
kmh_from_last |
double | The speed needed to cover that distance in that time, in km/h, or -1 if unknown. It is also -1 when no time has passed since the previous location, or the transaction is dated before it |
attributes |
map | Your own custom fields sent with the transaction. Jurisdiction packs read some optional ones: balance_after, invoice_amount and supplier_country (see Jurisdiction attributes) |
customer
Section titled “customer”| Field | Type | Meaning |
|---|---|---|
type |
string | individual or business |
status |
string | active, dormant, restricted, exited |
risk_rating |
string | unrated, low, medium, high, unacceptable |
segment |
string | Your own segment name, or '' |
kyc_level |
int | KYC tier, or -1 if unknown |
age_days |
int | Days since the customer was onboarded, or -1 if unknown |
designations |
list of strings | Current designations: pep, pep_rca, sanctioned, high_risk, adverse_media, watchlist, custom:… |
is_pep |
bool | Has a pep or pep_rca designation |
is_sanctioned |
bool | Has a sanctioned designation |
screening_match |
string | What sanctions screening knows about the customer: confirmed (a reviewer confirmed a sanctions match and its designation is in force), open (a potential sanctions match nobody has decided yet) or none. Real-time rules only: in post-event and scheduled rules it always reads none, so use is_sanctioned there. The counterparty has the same variable, counterparty.screening_match. Your workspace’s screening policy already acts on the same state without a rule |
is_high_risk |
bool | Has a high_risk designation (separate from risk_rating) |
is_stub |
bool | SpeculaPro has only a placeholder, because the customer record hasn’t arrived yet |
hours_since |
map of event type → double | Hours since the latest account event of each type, for example customer.hours_since.sim_swap. A type that never happened (in the last 180 days) is absent, so check with 'sim_swap' in customer.hours_since first |
last_change |
string | The customer’s most recent profile or credential change, in words: phone change, email change, password change, password reset, pin change, address change, device added, limit change or sim swap. '' if none on record |
hours_since_last_change |
double | Hours since that change, or -1 if none |
attributes |
map | Your custom fields on the customer. The Nigerian packs read the optional bvn_verified (see Jurisdiction attributes) |
last_change covers every kind of change in the list above, not only the ones a rule watches. To
name only the changes your rule cares about, as H5 does, filter
hours_since in a computed placeholder.
account
Section titled “account”| Field | Type | Meaning |
|---|---|---|
type |
string | bank, wallet, card, merchant, agent, loan, virtual, other |
status |
string | active, dormant, frozen, closed |
provider |
string | For example mtn_momo or a bank code |
age_days |
int | Days since the account was opened, or -1 if unknown |
is_stub |
bool | Only a placeholder so far |
attributes |
map | Your custom fields on the account |
counterparty
Section titled “counterparty”| Field | Type | Meaning |
|---|---|---|
country |
string | Country, or '' |
type |
string | individual, business, unknown, or '' |
provider |
string | Provider or bank, in lower case as SpeculaPro stores it (mtn_momo, not MTN_MOMO), or '' |
is_own_account |
bool | The “other party” is actually one of your own monitored accounts (same number) |
designations |
list of strings | Designations recorded on this counterparty |
screening_match |
string | What sanctions screening knows about the counterparty: confirmed (a reviewer confirmed a sanctions match and its designation is in force), open (a potential sanctions match nobody has decided yet, including one the decision found itself by screening the name of a counterparty it had no screening result for: the first payment to them) or none. Real-time rules only: in post-event and scheduled rules it always reads none, so use designations there |
is_new_for_customer |
bool | The first transaction between this customer and this counterparty: a new payee or a new payer |
customer_first_seen_hours |
double | Hours since the customer first dealt with this counterparty, or -1 when new |
confirmed_case_days |
double | Days since the latest case closed as confirmed (true positive, report filed or customer exited) about this counterparty, or about your own account or customer behind the same account number; -1 when none. Post-event only: always -1 in real-time decisions |
confirmed_case_disposition |
string | That case’s disposition: true_positive_no_report, report_filed or customer_exited; '' when none. Post-event only |
When a transaction has no counterparty, these fields are empty: '', false, [] and -1.
device
Section titled “device”| Field | Type | Meaning |
|---|---|---|
known |
bool | A device_fingerprint was sent with the transaction |
is_new |
bool | The first transaction this customer has made from this device |
customer_age_hours |
double | Hours since the customer first transacted from it, or -1 when new or unknown |
How many other customers use the device is a feature, not a field: see the device entity.
What “new” means
Section titled “What “new” means”device.is_new, counterparty.is_new_for_customer, txn.country_is_new and txn.ip_is_new
come from a small profile SpeculaPro keeps for each customer: when each device, counterparty,
country and IP network was first seen in their transactions, and where they last were.
- The profile starts when data starts. On the first day you send transactions, everything is new for everyone. Send a few months of history first (see Load history before go-live), or run the rules that use these fields in shadow until the profiles have filled.
- A customer quiet for 180 days starts afresh, and everything they do next is new again.
- Only transactions build the profile. A device seen in a login account event is still new on the customer’s first payment from it, and an event’s location doesn’t move theirs.
- The current transaction decides “new” for itself. It records what it sees, so the next transaction from the same device is no longer new. A transaction checked twice (in a real-time decision, then post-event) gets the same answer both times.
- Location moves forward only. The previous location is the most recent one on record, from
a transaction that carried
geo_latandgeo_lon. A backdated transaction doesn’t replace a newer location. It is still measured against it, sokm_from_lastis set, but if it is dated at or before the latest known location, no time has passed between the two:minutes_since_last_locationandkmh_from_lastread-1(unknown), and a speed rule such as E3 impossible travel doesn’t fire on it.
features, params, lists
Section titled “features, params, lists”features.<name>is a number (double). You can only read features the rule declares. See the features reference.params.<name>is whatever the version’s parameters hold. Numbers from JSON are doubles; lists and objects work as you’d expect.lists.<key>is a list of strings: the current entries of that workspace list. See Lists in rules.
workspace
Section titled “workspace”| Field | Type | Meaning |
|---|---|---|
ctr_comparison |
string | How your workspace compares cash with its CTR threshold, from Settings → Reporting, Report when the day’s cash is: gte (at or above the threshold) or gt (above it only). See At or above, or above only |
A rule that reads workspace.ctr_comparison always agrees with the drafted cash transaction
reports. A backtest uses the setting as it is today: settings keep
no history. Nigeria’s ng.ctr_cash does this by default: see
Pack contents: Nigeria.
The workspace’s live risk model, for real-time and post-event rules. A shadow model’s scores never appear here.
| Field | Type | Meaning | Without a live model |
|---|---|---|---|
available |
bool | A live model scored this transaction | false |
score |
double | The anomaly score, 0 to 1. About 0.5 is ordinary; higher is more unusual | 0.0 |
percentile |
double | Where the score falls among the scores of the model’s training data, 0 to 100 | 0.0 |
reasons |
list of string | Keys of the features that pushed the score up most, strongest first, up to three, such as amount_vs_avg_30d |
Empty |
reason_labels |
list of string | The same reasons in plain words | Empty |
top_reason |
string | The first of reason_labels, for explanations |
"" |
version |
int | The live model’s version number | 0 |
Every field always exists, so reading one never fails. Start a condition with model.available &&
whenever a score of 0 could make it true by accident. A backtest scores past transactions with
the model that is live today. See
Using model scores in rules.
age_days is measured when the rule runs, which is usually seconds after the transaction.
customer.hours_since is measured from the transaction’s occurred_at; an event reported as
happening after the transaction reads 0.
What you can write
Section titled “What you can write”| You want | Write |
|---|---|
| Compare | ==, !=, <, <=, >, >= |
| Combine | && (and), || (or), ! (not) |
| Arithmetic | +, -, *, /, % |
| Is it one of these? | txn.channel in ['momo', 'agent'], txn.channel in params.channels |
| Does a list contain it? | 'sanctioned' in counterparty.designations |
| How many? | size(params.channels) == 0 |
| Does a key exist? | has(txn.attributes.sim_changed_at), has(lists.high_risk_countries) |
| Convert | int(x), double(x), string(x) |
| Choose | cond ? a : b |
| Text | txn.mcc.startsWith('79'), customer.segment.lowerAscii() == 'sme', s.contains('x'), s.matches('^[0-9]+$') |
| Larger or smaller of two | math.greatest(a, b), math.least(a, b) |
| Lists | params.channels.exists(c, c == txn.channel), lists.x.all(v, v != '') |
| Overlap of two lists | sets.intersects(customer.designations, ['pep', 'pep_rca']) |
The string, math, list and set extension libraries are switched on, so functions such as
lowerAscii, split, trim, math.round and sets.contains are available too.
Type rules that trip people up
Section titled “Type rules that trip people up”-
Mixing whole numbers and decimals.
features.*are always doubles, and so are numbers fromparams. Fields such astxn.hourandcustomer.kyc_levelare ints. Convert explicitly when you mix them, as the shipped packs do:features.senders_7d >= double(params.min_senders)txn.hour >= int(params.from_hour) -
Missing map keys are errors, not false. That includes
customer.hours_since.<type>for an event that never happened: write'sim_swap' in customer.hours_since && …. -
Missing attributes are errors, not false. Reading a custom attribute that a transaction doesn’t have makes the evaluation fail for that transaction. The rule records no hit and a warning goes to the logs. (Lists are the exception: a list named as
lists.my_keyorlists["my_key"]that your workspace hasn’t created reads as empty.) Guard attributes withhas():has(txn.attributes.sim_changed_hours) && txn.attributes.sim_changed_hours < 24.0The pack rules write the same guard with
in, which works for any key:'balance_after' in txn.attributes && double(txn.attributes.balance_after) > params.max_balance. -
Unknown is -1.
customer.kyc_level,customer.age_days,account.age_daysand the distance, speed and hours fields above are -1 when not known.account.age_days <= 30is true for an unknown age. Writeaccount.age_days >= 0 && account.age_days <= 30.
Checks when you save
Section titled “Checks when you save”Every draft and every validate call is checked before it’s stored. All problems are reported together:
| Message | What to do |
|---|---|
expression must evaluate to true or false, got … |
The expression returns a number or text. Add a comparison |
features.x is used but not declared |
Declare x under features, or fix the name |
params.x is used but has no value |
Add x to params. Remember params is replaced as a whole when you draft a version |
feature name "X" must be lower_snake_case |
Feature names are lowercase letters, digits and _, starting with a letter |
feature x: account events can only be counted |
With "direction": "event", use "agg": "count" |
feature x: direction event needs the event type, e.g. login_failed |
Add type |
feature x: distinct_customers applies to device and counterparty entities |
Use distinct_counterparties, or change the entity |
action "block" isn't available to post-event rules (use alert, tag, score_only) |
Each mode has its own actions. Real-time rules use block, review, step_up, allow, tag or score_only; post-event rules alert, tag or score_only |
feature x: window must be a duration between 1m and 4320h (180 days) |
See the features reference |
ERROR: <input>:1:12: Syntax error: … |
A typo in the expression; the numbers are line and column |
ERROR: <input>:1:1: undeclared reference to 'foo' |
foo isn’t one of the variables above |
A pack refuses to load if any of its rules fails these checks, so shipped rules always pass.
Limits
Section titled “Limits”- An expression can be up to 4,000 characters.
- Each evaluation has a cost limit. CEL counts the work an expression does, and one that
would do too much stops with an error instead of slowing everyone down. Ordinary rules are far
below the limit; you’d only reach it with very large lists combined in nested
exists/allcalls.
Related
Section titled “Related”- Features reference
- Writing a custom rule
- Explanations
- Pack contents, for real expressions to learn from