# Velocity and Anomalies

> How Fidro counts what it has seen, the six anomaly rules, what each one adds to the score, and how events are stored and kept.

**Category:** core-concepts | **Last updated:** October 2, 2026

---

Most fraud does not look risky one request at a time. A clean residential IP and a normal Gmail address pass every static check, and so do the next nine signups from the same place. Velocity is how Fidro catches the pattern rather than the request.

## Stateless and stateful calls

A `/api/validate` call is **stateless** unless you send `event` or `user_id`. Stateless calls run the email and IP checks and return a result; nothing about them is counted later.

A call that carries `event` or `user_id` is **stateful**. Fidro:

1. Runs the usual checks and produces the base `risk_score`.
2. Stores the call as an event: the type, the email and its normalised pattern, the IP and its /24 or /64 subnet, the country, the ASN, and your `user_id`.
3. Counts velocity over your account's own events (including this one) and returns the counts in `data.velocity`.
4. Evaluates the anomaly rules. Each rule that fires adds its weight to the score, may floor the recommendation, and is reported in `data.anomalies`, `data.checks` and at the end of `message`.

Events are scoped to your account. Another customer's traffic from the same IP never affects your counts. Test keys write test-mode events that are counted separately from live.

## Event types

| Type | Counted by |
|------|-----------|
| `signup` | The three burst rules |
| `login`, `checkout`, `password_reset`, `check`, `custom:<name>` | The shared-IP rule and, with a `user_id`, the travel rules |

`custom:<name>` accepts lowercase letters, digits and underscores, up to 24 characters. A call with a `user_id` and no `event` is recorded as a `check`.

## Velocity counts

| Key | Meaning |
|-----|---------|
| `ip_events_1h`, `ip_events_24h` | Events of any type from this IP |
| `ip_signups_1h`, `ip_signups_24h` | Signups from this IP |
| `subnet_signups_1h` | Signups from this IP's /24 (IPv4) or /64 (IPv6) |
| `ip_distinct_users_24h` | Different `user_id`s seen from this IP |
| `email_pattern_matches_24h` | Signups whose address reduces to the same pattern as this one |
| `user_distinct_ips_24h` | Different IPs this `user_id` has used |
| `user_countries_30d` | Different countries this `user_id` has appeared from |

Counts include the current call. A key that does not apply (no IP, no `user_id`) is `0`.

## Anomaly rules

| Code | Fires when | Severity | Score | Floor |
|------|-----------|----------|-------|-------|
| `ip_signup_burst` | 3+ signups from one IP in an hour, or 5+ in 24 hours | high | +40 | `challenge` |
| `subnet_signup_burst` | 5+ signups from one subnet in an hour | medium | +20 | none |
| `email_pattern_burst` | 3+ signups in 24 hours reducing to one email pattern | high | +40 | `challenge` |
| `ip_many_users` | 5+ different `user_id`s from one IP in 24 hours, on a non-signup event | medium | +20 | none |
| `new_country_for_user` | A user with 3+ prior events from one country appears from another | medium | +15 | none |
| `impossible_travel` | One `user_id` seen from two countries within 60 minutes | high | +30 | `challenge` |

Weights are added to the base score and the total is capped at 100. A floor means the recommendation is at least that value even if the score alone would say `allow`. The rules and thresholds are fixed in this release; per-account tuning is on the roadmap.

### Email patterns

Before matching, the local part of an address is lowercased, anything after a `+` is dropped, dots are removed and trailing digits are trimmed. `Sam+promo@example.com`, `s.a.m@example.com` and `sam42@example.com` all reduce to `sam@example.com`. The domain is matched exactly.

### Countries

The country comes from the IP's geolocation. In test mode the `country_code` you send on the request stands in for it, so the travel rules can be reproduced with a test key.

## Reading the response

```json
{
  "risk_score": 70,
  "recommendation": "challenge",
  "message": "Low risk signup detected with minor concerns: Tor network. Velocity: impossible travel.",
  "data": {
    "checks": { "tor": true, "impossible_travel": true },
    "velocity": { "user_distinct_ips_24h": 2, "user_countries_30d": 2 },
    "anomalies": [
      {
        "code": "impossible_travel",
        "severity": "high",
        "detail": "usr_42 was seen from US 12 minutes ago and is now in DE."
      }
    ]
  }
}
```

`risk_score` and `recommendation` already include the anomaly. Code that only reads those two fields keeps working. Read `data.anomalies` when you want to log the reason, show it to support, or treat a particular code differently.

## Storage and retention

| Plan | Events kept |
|------|-------------|
| Free | 7 days |
| Starter | 90 days |
| Pro | 365 days |
| Enterprise | 365 days |

Older events are deleted nightly. Each stateful call is one request against your monthly quota, the same as a stateless one. Events and anomalies are shown next to each request in the [audit log](/docs/guides/understanding-audit-logs).

## Where to start

Add `event: "signup"` to your registration call first. It costs nothing extra, needs no `user_id`, and turns on the three burst rules that catch the bulk of free tier abuse. Add `user_id` to logins when you are ready for the account takeover rules. The walkthrough is in [Track Signups and Logins](/docs/guides/track-signups-and-logins).