Skip to content

CEL reference

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.threshold

This 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.

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)
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.

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

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.

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_lat and geo_lon. A backdated transaction doesn’t replace a newer location. It is still measured against it, so km_from_last is set, but if it is dated at or before the latest known location, no time has passed between the two: minutes_since_last_location and kmh_from_last read -1 (unknown), and a speed rule such as E3 impossible travel doesn’t fire on it.
  • 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.
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.

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.

  • Mixing whole numbers and decimals. features.* are always doubles, and so are numbers from params. Fields such as txn.hour and customer.kyc_level are 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_key or lists["my_key"] that your workspace hasn’t created reads as empty.) Guard attributes with has():

    has(txn.attributes.sim_changed_hours) && txn.attributes.sim_changed_hours < 24.0

    The 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_days and the distance, speed and hours fields above are -1 when not known. account.age_days <= 30 is true for an unknown age. Write account.age_days >= 0 && account.age_days <= 30.

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.

  • 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/all calls.