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. A call with neither behaves exactly as before.
1. Tag your signup
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:
{
"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:
{
"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:
$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
checkoutandpassword_resetare 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_idwith noeventrecords acheck.
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.
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.
- Email addresses are matched by pattern as well as exactly:
sam+1@,s.a.m@andsam42@on the same domain count as one pattern foremail_pattern_burst.