Rules
Rules are versioned. Each version is its own record with its own id. A new version starts as a draft, a different key activates it, and the version it replaces becomes retired. See Rules for how rules score events.
| Status | Meaning |
|---|---|
draft | Created, not yet affecting decisions |
active | Scoring every sync event. At most one version per rule key is active. |
retired | Replaced by a newer active version. Kept for history. |
sentinel-admin rules seed -org <org> before creating your own. It installs them as active rules, and only works on an organisation with no rule versions yet.List rules
GET /v1/rulesReturns every version of every rule, sorted by key, newest version first.
Query parameters
draft, active or retired.Example
curl "$SENTINEL_URL/v1/rules?key=structuring_below_threshold" \
-H "Authorization: Bearer $SENTINEL_KEY"
{
"rules": [
{
"id": "6a1f0c3e-2b4d-4e5f-8a9b-0c1d2e3f4a5b",
"key": "structuring_below_threshold",
"version": 2,
"expr": "amount >= 4500.0 && amount < 5000.0 && features[\"near_threshold_count_24h\"] >= 4",
"weight": 60,
"detail": "repeated transfers just below the reporting threshold within 24h",
"status": "draft",
"created_by": "3f2e1d0c-9b8a-4765-8432-10fedcba9876",
"created_at": "2026-10-15T14:02:11.480Z"
},
{
"id": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"key": "structuring_below_threshold",
"version": 1,
"expr": "amount >= 4500.0 && amount < 5000.0 && features[\"near_threshold_count_24h\"] >= 3",
"weight": 60,
"detail": "repeated transfers just below the reporting threshold within 24h",
"status": "active",
"created_by": "sentinel-admin",
"approved_by": "sentinel-admin (seed)",
"created_at": "2026-10-01T08:00:00.000Z",
"activated_at": "2026-10-01T08:00:00.000Z"
}
]
}
Create a draft
POST /v1/rulesCreates the next version of a rule as a draft. If no rule with that key exists, this is version 1. The expression is compiled straight away, so a broken expression is rejected here and never reaches a live decision. Returns 201 with the new version.
Request body
source (the default) or destination: which party the rule is about. Its alerts go to that party. Use destination for rules about money arriving, such as fan-in.Example
curl -X POST "$SENTINEL_URL/v1/rules" \
-H "Authorization: Bearer $SENTINEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"key": "large_card_payment",
"expr": "rail == \"card\" && amount >= 10000.0",
"weight": 40,
"detail": "card payment of 10,000 or more"
}'
{
"id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
"key": "large_card_payment",
"version": 1,
"expr": "rail == \"card\" && amount >= 10000.0",
"weight": 40,
"detail": "card payment of 10,000 or more",
"subject": "source",
"status": "draft",
"created_by": "3f2e1d0c-9b8a-4765-8432-10fedcba9876",
"created_at": "2026-10-15T14:20:45.913Z"
}
Expression variables
| Variable | Type | Value |
|---|---|---|
type | string | The event type, for example transfer.outbound |
amount | double | amount.value, or 0 if the event has none |
currency | string | amount.currency, or empty |
rail | string | As sent, or empty |
source_id | string | source.entity_id, or empty |
destination_id | string | destination.entity_id, or empty |
features | map of string to int | Counters for the source |
dest_features | map of string to int | The same counters for the destination |
Each counter covers the 24 hours up to and including this event, by occurred_at, and only events that had arrived by the time this one did. Events stored in async mode count too.
| Counter | Counts |
|---|---|
txn_count_24h | Events the entity sent |
near_threshold_count_24h | Of those, amounts from 4,500 up to but not including 5,000. Set the band with SENTINEL_NEAR_THRESHOLD_LOW and SENTINEL_NEAR_THRESHOLD_HIGH |
in_count_24h, in_value_24h | Payments the entity received, and their total in whole units |
in_senders_24h, in_senders_1h | Different senders that paid it, in 24 hours and in the last hour |
out_count_24h, out_value_24h | Payments it made, and their total in whole units |
out_receivers_24h | Different receivers it paid |
dormant_days | Whole days since its previous payment in or out, 0 if none |
seen_before | 1 if it had any earlier payment, else 0 |
txn_count_24h and near_threshold_count_24h are only computed for the source; in dest_features they read 0. Any counter that doesn't apply reads 0. Use amount >= 4500.0, not amount >= 4500: CEL won't compare a double with an int.
A fan-in rule written against the destination:
{
"key": "fan_in_unrelated_senders",
"expr": "dest_features[\"in_senders_1h\"] >= 10",
"weight": 50,
"detail": "10 or more different senders paid this account within an hour",
"subject": "destination"
}
Activate a draft
POST /v1/rules/{id}/activateApproves a draft version. It becomes active, and any other active version of the same rule becomes retired. No body is needed.
This is four-eyes: the key that created the draft can't activate it. It takes effect at once on the instance that handled the request, and within 30 seconds on other instances.
curl -X POST "$SENTINEL_URL/v1/rules/9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b/activate" \
-H "Authorization: Bearer $SENTINEL_REVIEWER_KEY"
The response is the rule with status set to active, plus approved_by and activated_at.
Backtest a version
POST /v1/rules/{id}/backtestReplays stored events through one rule version and reports what it would have flagged. Nothing changes: no decisions, no alerts. Each event sees the same 24-hour counters it saw live. Any version can be backtested, whatever its status.
If the version isn't active and another version of the same rule is, the result also compares the two, so you can see what a change would add and remove.
Request body
All fields are optional. Send no body for the last 7 days.
occurred_at. Defaults to 7 days before until.Example
curl -X POST "$SENTINEL_URL/v1/rules/6a1f0c3e-2b4d-4e5f-8a9b-0c1d2e3f4a5b/backtest" \
-H "Authorization: Bearer $SENTINEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"since": "2026-10-01T00:00:00Z",
"until": "2026-10-15T00:00:00Z"
}'
{
"rule_id": "6a1f0c3e-2b4d-4e5f-8a9b-0c1d2e3f4a5b",
"key": "structuring_below_threshold",
"version": 2,
"since": "2026-10-01T00:00:00Z",
"until": "2026-10-15T00:00:00Z",
"events_evaluated": 18342,
"truncated": false,
"would_flag": 41,
"eval_errors": 0,
"baseline_version": 1,
"baseline_would_flag": 67,
"newly_flagged": 0,
"no_longer_flagged": 26,
"sample_event_ids": []
}
Result fields
| Field | Meaning |
|---|---|
events_evaluated | Events replayed |
truncated | true when max_events cut the window short |
would_flag | Events this version would have fired on |
eval_errors | Events where the expression failed at run time |
baseline_version | The active version compared against. Absent when there isn't one. |
baseline_would_flag | Events the active version fires on |
newly_flagged | Fired by this version but not the active one |
no_longer_flagged | Fired by the active version but not this one |
sample_event_ids | Up to 20 event IDs: newly flagged ones when there's a baseline, otherwise flagged ones |
Rule fields
| Field | Type | Meaning |
|---|---|---|
id | string, uuid | This version's ID |
key | string | The rule's name, shared by all its versions |
version | integer | Starts at 1 and goes up by one per draft |
expr | string | The CEL expression |
weight | integer | Points added when it fires |
detail | string | The explanation stored on decisions |
subject | string | source or destination: whose alert a match goes on |
status | string | draft, active or retired |
created_by | string | The key ID that created it, or sentinel-admin for seeded rules |
approved_by | string | The key ID that activated it |
created_at | string, date-time | When the draft was created |
activated_at | string, date-time | When it was activated |
Errors
| Endpoint | Code | error |
|---|---|---|
| All | 401 | missing or invalid API key |
POST /v1/rules | 400 | key must be lowercase letters, digits and underscores, starting with a letter |
POST /v1/rules | 400 | weight must be between 1 and 100 |
POST /v1/rules | 400 | detail is required |
POST /v1/rules | 400 | The compile error, for example rule large_card_payment: expression must return bool |
.../activate | 403 | the author of a rule cannot approve it |
.../activate | 409 | only a draft can be activated |
.../backtest | 400 | since must be before until |
.../backtest | 400 | window is capped at 31 days |
.../backtest | 400 | max_events must be between 1 and 100000 |
.../activate, .../backtest | 404 | rule not found |