Examples

Small, runnable projects that put Osprey inside a real workflow. Each one has the rules, a scenario, the integration code, and a script that starts a clean Osprey server and checks every decision. The output on this page is from a real run.

git clone https://github.com/opensource-finance/osprey.git && cd osprey && make examples

Needs Go 1.26+, Python 3 and curl. No packages to install.

01Money-mule detection for payments

mode: detection

In authorised push payment (APP) fraud, a criminal talks a victim into a bank transfer. The money lands in a mule account that forwards it to new payees within minutes and is left empty. The victim's payment looks normal on its own. The mule account shows the pattern.

How it was built

  • Five rules, one signal each: new account, new payee, money in and out within the hour, balance emptied, and a Confirmation of Payee name mismatch.
  • The payments service sends what it already knows (account age, new payee, balances) in metadata, and the CoP result in enrichment. Osprey counts the velocity itself.
  • The four mule signals are .review rules: each one alone is normal, so each adds its weight of 0.2 to the score and four of them together reach the 0.7 alert threshold. The CoP mismatch is a .fail rule: it alerts on its own.
  • A few lines of Python turn the decision into an action: ALRT holds the payment, NALT with reasons shows a scam warning, NALT without reasons releases it.

From the config

{
  "id": "mule-pass-through",
  "expression": "velocity_count >= 3",
  "weight": 0.2,
  "bands": [
    {"lowerLimit": 1, "subRuleRef": ".review",
     "reason": "3 or more payments in or out of this account in the last hour"},
    {"lowerLimit": 0, "upperLimit": 1, "subRuleRef": ".pass",
     "reason": "Normal activity"}
  ]
}

Real output

loaded rule mule-new-account
loaded rule mule-new-payee
loaded rule mule-pass-through
loaded rule mule-balance-drain
loaded rule mule-cop-no-match

pay-001  cust-alice -> landlord-ltd  1200 GBP
  NALT  score 0.00  action RELEASE

pay-002  cust-carol -> ext-supplier  3400 GBP
  ALRT  score 0.40  action HOLD
  - Confirmation of Payee: the name does not match the account
  - First payment to this payee

pay-003  cust-bob -> cust-mule  2500 GBP
  NALT  score 0.20  action WARN
  - First payment to this payee

pay-004  cust-mule -> ext-x1  800 GBP
  NALT  score 0.40  action WARN
  - First payment to this payee
  - Sender account is less than 30 days old

pay-005  cust-mule -> ext-x2  800 GBP
  NALT  score 0.60  action WARN
  - 3 or more payments in or out of this account in the last hour
  - First payment to this payee
  - Sender account is less than 30 days old

pay-006  cust-mule -> ext-x3  850 GBP
  ALRT  score 0.80  action HOLD
  - 3 or more payments in or out of this account in the last hour
  - First payment to this payee
  - Payment empties more than 90% of the balance
  - Sender account is less than 30 days old

OK: all 6 payments got the expected action

The victim gets a warning. The mule's first two transfers collect signals (0.40, then 0.60). The transfer that empties the account scores 0.80 and is held.

Full walkthrough and code on GitHub

02Structuring detection for a money transfer service

mode: compliance

Many reporting rules start at 10,000. Some customers split one large amount into several transfers just below it. Each transfer looks normal on its own. The pattern only shows across them.

How it was built

  • Four rules: amount just below 10,000, three or more transfers in the hour, 25,000 or more moved in the hour, and a sanctions screening hit sent in enrichment.
  • Two typologies group them. Structuring weighs the near-threshold rule at 0.5 and the two velocity rules at 0.25 each, with an alert threshold of 0.75. Sanctions alerts on one hit.
  • In compliance mode the typologies decide, so every rule uses .review.
  • On each alert the service reads GET /evaluations/{id} and writes a case file: which typology fired and what each rule contributed.

From the config

{
  "id": "rem-typology-structuring",
  "alertThreshold": 0.75,
  "rules": [
    {"ruleId": "rem-near-threshold", "weight": 0.5},
    {"ruleId": "rem-repeat-sender",  "weight": 0.25},
    {"ruleId": "rem-hour-total",     "weight": 0.25}
  ]
}

Real output

loaded rule rem-near-threshold
loaded rule rem-repeat-sender
loaded rule rem-hour-total
loaded rule rem-sanctions-hit
loaded typology rem-typology-structuring
loaded typology rem-typology-sanctions

rem-001  cust-eve -> rcp-eve-family  450 USD
  NALT  score 0.00

rem-002  cust-frank -> rcp-notary  15000 USD
  NALT  score 0.00

rem-003  cust-dana -> rcp-dana-1  9500 USD
  NALT  score 0.50
  - Amount is just below the 10,000 reporting threshold

rem-004  cust-dana -> rcp-dana-1  9400 USD
  NALT  score 0.50
  - Amount is just below the 10,000 reporting threshold

rem-005  cust-dana -> rcp-dana-1  9700 USD
  ALRT  score 1.00
  - 25,000 or more moved by this customer in the last hour
  - 3 or more transfers by this customer in the last hour
  - Amount is just below the 10,000 reporting threshold
  case file:
    typology rem-typology-structuring: score 1.00 >= threshold 0.75
      rem-near-threshold   rule score 1 x weight 0.50 = 0.50
      rem-repeat-sender    rule score 1 x weight 0.25 = 0.25
      rem-hour-total       rule score 1 x weight 0.25 = 0.25

rem-006  cust-gus -> rcp-flagged  200 USD
  ALRT  score 1.00
  - Recipient matches a sanctions list entry
  case file:
    typology rem-typology-sanctions: score 1.00 >= threshold 1.00
      rem-sanctions-hit    rule score 1 x weight 1.00 = 1.00

OK: all 6 transfers got the expected decision

A single 15,000 transfer does not alert. Three cash transfers of about 9,500 within the hour do, and the case file shows the 0.50 + 0.25 + 0.25 behind the score.

Full walkthrough and code on GitHub