# Track Signups and Logins

> Add two fields to your validate call and Fidro remembers what it has seen: repeat signups from one IP, address patterns, users appearing from new countries.

**Category:** getting-started | **Last updated:** October 2, 2026

---

A plain `/api/validate` call answers one question: does this email and IP look risky right now? It does not know that the same IP registered two accounts in the last ten minutes, or that this user has only ever logged in from Germany.

Give it that memory by telling Fidro what is happening. Two optional fields on the same endpoint:

| Field | What to send |
|-------|--------------|
| `event` | `signup`, `login`, `checkout`, `password_reset`, `check`, or `custom:<name>` |
| `user_id` | Your own identifier for the person, once you have one |

A call with either field is recorded as an event, counted against your account's history, and checked against the [anomaly rules](/docs/guides/velocity-and-anomalies). A call with neither behaves exactly as before.

## 1. Tag your signup

```bash
curl -X POST https://fidro.io/api/validate \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@example.com",
    "ip": "203.0.113.1",
    "event": "signup"
  }'
```

The response gains two blocks under `data`:

```json
{
  "risk_score": 0,
  "recommendation": "allow",
  "data": {
    "velocity": {
      "ip_signups_1h": 1,
      "ip_signups_24h": 1,
      "subnet_signups_1h": 1,
      "email_pattern_matches_24h": 1
    },
    "anomalies": []
  }
}
```

Counts include the call you just made, so `1` means "this is the first". On the third signup from that IP inside an hour the counts read `3`, `ip_signup_burst` fires, 40 points are added to the score and the recommendation is at least `challenge`:

```json
{
  "risk_score": 40,
  "recommendation": "challenge",
  "message": "No risk factors detected. Velocity: ip signup burst.",
  "data": {
    "checks": { "ip_signup_burst": true },
    "anomalies": [
      {
        "code": "ip_signup_burst",
        "severity": "high",
        "detail": "3 signups from 203.0.113.1 in the last hour."
      }
    ]
  }
}
```

You do not need to change how you read the response. `risk_score` and `recommendation` already include the anomaly, so existing threshold logic keeps working. The extra fields are there when you want the reason.

## 2. Tag logins with a user_id

Once the account exists, send its ID on every login:

```php
$result = Http::withToken(config('services.fidro.api_key'))
    ->post('https://fidro.io/api/validate', [
        'email' => $user->email,
        'ip' => $request->ip(),
        'event' => 'login',
        'user_id' => (string) $user->id,
    ])->json();

if ($result['recommendation'] !== 'allow') {
    // Ask for a second factor, email a login alert, or hold the session
}
```

With a `user_id` Fidro can see the person across IPs and countries. After three events from one country, a login from another adds `new_country_for_user`. Two countries inside an hour adds `impossible_travel` and floors the recommendation at `challenge`. Five different user IDs logging in from one IP in a day adds `ip_many_users`.

The `user_id` is opaque to Fidro. Use whatever you already have (a database ID, a UUID) and keep it stable; it is the key everything else hangs off.

## 3. Other events

- `checkout` and `password_reset` are recorded and counted like logins, so a password reset from a brand new country for an established user is flagged the same way.
- `custom:<name>` (lowercase letters, digits and underscores, up to 24 characters) records anything else you want in the history: `custom:invite_accepted`, `custom:api_key_created`.
- Sending only a `user_id` with no `event` records a `check`.

## What the recommendation means now

| Recommendation | Score | Suggested action |
|---------------|-------|------------------|
| `allow` | 0 to 49 | Proceed |
| `challenge` | 50 to 79, or any floored anomaly | Add friction: email verification, a second factor, a CAPTCHA, or a review queue |
| `block` | 80 and up | Reject |

"Challenge" is deliberate. A third signup from one IP in an hour is often a shared office or a family, so the right move is a verification step rather than a refusal. Block outright on specific checks (Tor, your blocklist) if your product warrants it.

## Testing it

Test keys keep their own history, separate from live. To reproduce a burst, make three `signup` calls from the same IP with a test key. To reproduce `impossible_travel`, send the same `user_id` twice with different `country_code` values; in test mode `country_code` stands in for the IP's country. Details in [Using Test Mode](/docs/guides/test-mode-and-test-data).

## Good to know

- Each event is one request against your monthly quota, the same as a stateless call.
- Events are kept for 7 days on Free, 90 days on Starter and 365 days on Pro and Enterprise, then deleted.
- Every event and its anomalies show up in the [audit log](/docs/guides/understanding-audit-logs).
- Email addresses are matched by pattern as well as exactly: `sam+1@`, `s.a.m@` and `sam42@` on the same domain count as one pattern for `email_pattern_burst`.