# Server-side verification

> Never trust a result the browser reports. Read the stored event by request ID with a secret key, check it belongs to this action, then decide.

Last updated: 2026-09-17

Anything a browser or an app returns can be edited by whoever controls it: the visitor ID, the score, the verdicts. Verification closes that gap. Your backend reads the stored event from Fingerly with a secret key, confirms it belongs to the action being taken, and only then decides.

## The flow

### Step 1: The client identifies

Call `identify({ tag })` when the visitor acts. Send only the `requestId` to your backend, with the action.

### Step 2: Your server reads the event

Fetch the event by request ID with your secret key. The answer comes from Fingerly, not from the browser.

```bash Request
curl "https://us.api.fingerly.io/api/v1/events/01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4" \
  -H "x-api-key: $FINGERLY_SECRET_KEY"
```

### Step 3: Your server checks it

Run the four checks below.

### Step 4: Your server decides

Allow, challenge, review or refuse, and record the request ID with the outcome.

## Four checks

| Check | How | Stops |
| --- | --- | --- |
| It exists | The read succeeds. A `404` means no such event in this key's environment. | Made-up or cross-environment request IDs. |
| It is this action | `tag` equals what you expect, such as `checkout:8412`. | An identification from a harmless page replayed at checkout. |
| It is recent | `occurred_at` is within the window your flow allows, such as two minutes. | Old request IDs saved and reused later. |
| It passes your policy | Read `suspect_level`, `triggers` and `visitor_id`. | The fraud you integrated Fingerly for. |

## In code

The same four checks, with each server SDK.

```ts Node.js
import { load, FingerlyAPIError } from '@fingerly/node'

const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! })
const MAX_AGE_MS = 2 * 60 * 1000

export async function decide(orderId: string, requestId: string) {
  let event
  try {
    event = await fingerly.events.get(requestId)
  } catch (error) {
    if (error instanceof FingerlyAPIError && error.status === 404) return 'refuse'
    throw error
  }

  if (event.tag !== 'checkout:' + orderId) return 'refuse'
  if (Date.now() - Date.parse(event.occurred_at) > MAX_AGE_MS) return 'refuse'

  if (event.suspect_level === 'high') return 'review'
  if (event.suspect_level === 'medium') return 'challenge'
  return 'allow'
}
```

```python Python
from datetime import datetime, timedelta, timezone
from fingerly import Fingerly, FingerlyAPIError

fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"])

def decide(order_id: str, request_id: str) -> str:
    try:
        event = fingerly.events.get(request_id)
    except FingerlyAPIError as error:
        if error.status == 404:
            return "refuse"
        raise

    if event.tag != f"checkout:{order_id}":
        return "refuse"
    if datetime.now(timezone.utc) - event.occurred_at > timedelta(minutes=2):
        return "refuse"

    if event.suspect_level == "high":
        return "review"
    if event.suspect_level == "medium":
        return "challenge"
    return "allow"
```

```python Python (async)
from datetime import datetime, timedelta, timezone
from fingerly import AsyncFingerly, FingerlyAPIError

fingerly = AsyncFingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"])

async def decide(order_id: str, request_id: str) -> str:
    try:
        event = await fingerly.events.get(request_id)
    except FingerlyAPIError as error:
        if error.status == 404:
            return "refuse"
        raise

    if event.tag != f"checkout:{order_id}":
        return "refuse"
    if datetime.now(timezone.utc) - event.occurred_at > timedelta(minutes=2):
        return "refuse"

    return {"high": "review", "medium": "challenge"}.get(event.suspect_level, "allow")
```

```go Go
func decide(ctx context.Context, orderID, requestID string) (string, error) {
    event, err := client.Events.Get(ctx, requestID)
    var apiErr *fingerly.APIError
    if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound {
        return "refuse", nil
    } else if err != nil {
        return "", err
    }

    if event.Tag != "checkout:"+orderID || time.Since(event.OccurredAt) > 2*time.Minute {
        return "refuse", nil
    }

    switch event.SuspectLevel {
    case "high":
        return "review", nil
    case "medium":
        return "challenge", nil
    }
    return "allow", nil
}
```

```java Java
public String decide(String orderId, String requestId) {
    Event event;
    try {
        event = fingerly.events().get(requestId);
    } catch (FingerlyApiException e) {
        if (e.getStatus() == 404) return "refuse";
        throw e;
    }

    if (!("checkout:" + orderId).equals(event.getTag())) return "refuse";
    if (event.getOccurredAt().isBefore(Instant.now().minus(Duration.ofMinutes(2)))) return "refuse";

    return switch (String.valueOf(event.getSuspectLevel())) {
        case "high" -> "review";
        case "medium" -> "challenge";
        default -> "allow";
    };
}
```

```csharp .NET
public async Task<string> DecideAsync(string orderId, string requestId, CancellationToken ct)
{
    Event ev;
    try
    {
        ev = await _fingerly.Events.GetAsync(requestId, ct);
    }
    catch (FingerlyApiException e) when (e.Status == 404)
    {
        return "refuse";
    }

    if (ev.Tag != $"checkout:{orderId}") return "refuse";
    if (DateTimeOffset.UtcNow - ev.OccurredAt > TimeSpan.FromMinutes(2)) return "refuse";

    return ev.SuspectLevel switch
    {
        "high" => "review",
        "medium" => "challenge",
        _ => "allow",
    };
}
```

```php PHP
<?php

use Fingerly\ApiException;

function decide(string $orderId, string $requestId): string
{
    global $fingerly;

    try {
        $event = $fingerly->events->get($requestId);
    } catch (ApiException $e) {
        if ($e->getStatus() === 404) {
            return 'refuse';
        }
        throw $e;
    }

    if ($event->tag !== "checkout:{$orderId}") {
        return 'refuse';
    }
    if ($event->occurredAt < new DateTimeImmutable('-2 minutes')) {
        return 'refuse';
    }

    return match ($event->suspectLevel) {
        'high' => 'review',
        'medium' => 'challenge',
        default => 'allow',
    };
}
```

```ruby Ruby
def decide(order_id, request_id)
  event = fingerly.events.get(request_id)

  return "refuse" unless event.tag == "checkout:#{order_id}"
  return "refuse" if event.occurred_at < Time.now - 120

  case event.suspect_level
  when "high" then "review"
  when "medium" then "challenge"
  else "allow"
  end
rescue Fingerly::APIError => e
  raise unless e.status == 404
  "refuse"
end
```

```rust Rust
async fn decide(fingerly: &fingerly::Client, order_id: &str, request_id: &str) -> Result<Decision, fingerly::Error> {
    let event = match fingerly.events().get(request_id).await {
        Ok(event) => event,
        Err(fingerly::Error::Api { status: 404, .. }) => return Ok(Decision::Refuse),
        Err(error) => return Err(error),
    };

    if event.tag.as_deref() != Some(&format!("checkout:{order_id}")) {
        return Ok(Decision::Refuse);
    }
    if chrono::Utc::now() - event.occurred_at > chrono::Duration::minutes(2) {
        return Ok(Decision::Refuse);
    }

    Ok(match event.suspect_level {
        Some(Level::High) => Decision::Review,
        Some(Level::Medium) => Decision::Challenge,
        _ => Decision::Allow,
    })
}
```

## Timing

An event is usually readable within a few seconds of the identification. If your client sends the request ID in the same moment it receives it, retry a `404` a few times over a few seconds before refusing.

> **Tip:** If you only need the verdict at the moment of the action, the identify response already contains it. Verification is what makes it trustworthy: read the event when the decision matters.

## Keep secret keys secret

- Secret keys are refused when a request carries an `Origin` header, so they cannot be used from front-end code.
- A secret key only reads its own environment. Use a production secret key to verify production identifications.
- Store keys in your secret manager, and revoke and replace one immediately if it leaks.

## Without a request ID

If identification failed in the client, your server receives no request ID. Treat that as missing evidence rather than as proof of fraud or of innocence: for example, allow low-risk actions and require a second factor for high-risk ones.

> **Warning:** Do not accept a score, visitor ID or verdict sent by the client in place of a request ID. Only the stored event is trustworthy.
