Skip to content

Human Scoring

Read whether the current session comes from a real person at the moment your app makes a decision. The SDK scores the session on the device as the user interacts, so reading the classification does not wait and does not call the network.

Note: The classification is one signal about the session, not a decision on its own. Keep a fallback path for bot, undetermined and insufficient. How the score is built is explained in How scoring works.

In this use case

Page What it covers
Human Scoring (this page) Read the classification at a decision point
User Consent Ask the user for permission before anything is recorded
Sensitive Screens Pause capture while a password or payment field is on screen
Custom Events Mark moments in your app on the session timeline
Sessions Start a new session on logout or account switch

When to use it

  • Before creating an account, submitting a form, or redeeming a promotion
  • Before a step that bots abuse: posting, voting, adding to cart, claiming a reward
  • To decide which branch of your own flow to take — let the user through, or add a challenge

Prerequisites

  • The SDK is installed and initialized. See the Quick Start.
  • The user has consented. Without consent nothing is recorded and the classification stays insufficient. See User Consent.
  • The user has interacted with the app. Until the session has as many actions as the model asks for — 8 with the built-in model — it is not sufficient and the classification is insufficient. Right after launch there are none. See Evidence.

Usage

Read the classification

import 'package:dea_sdk/dea_sdk.dart';

final result =
    DeaSdk.instance?.getClassification().result ?? ClassificationResult.insufficient;

switch (result) {
  case ClassificationResult.human:
    allowAction();
  case ClassificationResult.bot:
  case ClassificationResult.undetermined:
    showChallenge(); // your fallback path
  case ClassificationResult.insufficient:
    showChallenge(); // too early to tell: not a bot, but not proven human either
}

getClassification() answers one of four results:

Result When
insufficient Not enough evidence yet, or the SDK cannot score. value is null
human The score is at or above the model's human threshold
bot The score is below the model's bot threshold
undetermined The score lies between the two thresholds

A model that sets no human threshold has one bar: a score at or above its bot threshold is human.

What each result allows is your decision. undetermined is not a softer bot: it means the evidence does not decide either way. insufficient is not a bot either: it means it is too early to tell.

The read is synchronous and includes the gesture that just happened. It does a little work each time, so call it at a decision, not on every frame.

Both thresholds come from the scoring model the SDK has loaded, and nothing else: no account setting and no value built into the SDK. botThreshold, humanThreshold and modelVersion on the result say which ones were used.

If the loaded model has no bot threshold, getClassification() throws ThresholdMissing, with the model's version on modelVersion. It never throws when the SDK cannot score.

Tell "not enough evidence yet" apart from a negative result

The same read reports the score, how many scored actions it stands on (actions):

final dea = DeaSdk.instance;
final read = dea?.getClassification();

if (dea == null || !dea.isScoring) {
  // The SDK cannot score. See "Check that the SDK can score" below.
} else if (read!.result == ClassificationResult.insufficient) {
  debugPrint('Only ${read.actions} actions so far — not enough yet');
} else {
  debugPrint('score ${read.value}, result: ${read.result}');
}

All fields come from one snapshot, so they always agree with each other.

Check that the SDK can score

final dea = DeaSdk.instance;

if (dea == null) {
  // DeaSdk.init has not been called.
} else if (!dea.isScoring) {
  debugPrint('DEA cannot score: ${dea.error}');
}

When isScoring is false, the SDK still runs, getClassification() answers insufficient, and error says why.

Report input the SDK cannot see

The SDK captures every pointer Flutter routes. Two kinds of input never reach Flutter, so your app has to hand them over: typing, and touches inside a web view. Both count toward the score, and both are dropped while capture is paused, before consent, and after dispose().

Report typing

A phone's keyboard gives your text field characters, not key events, so the SDK cannot capture typing by itself. Report each keystroke with captureKey, passing only its class — never the character:

import 'package:dea_sdk/dea_sdk.dart';

DeaSdk.instance?.captureKey(KeyClass.of('a'));  // KeyClass.letter
DeaSdk.instance?.captureKey(KeyClass.delete);   // backspace
  • Add EventKind.keyDown to events. Without it, captureKey does nothing.
  • Never report keystrokes in a password field, not even as classes: the rhythm of typing alone can narrow a guess at a password.
  • The built-in consent dialog lists "What you type" under what is never recorded. If you report typing, change that text — see Change the wording.
  • KeyClass.of(char) returns letter (any script), digit (0 to 9), space or other. Report deletions as KeyClass.delete yourself.
  • A soft keyboard has no key release; send the press alone. Where your app does see a release (a hardware keyboard), report it with pressed: false and add EventKind.keyUp to events.

Report touches inside a web view

A web view handles its own touches, and Flutter routes none of them to the SDK. A task the user completes inside a web view therefore adds no evidence unless your app forwards its touches with capturePointer:

DeaSdk.instance?.capturePointer(
  kind: EventKind.down,
  pointerId: 0,
  x: 120.0,                // logical pixels, relative to the app window
  y: 480.0,
  atEpochMs: 1767225600000, // when the touch happened, Unix milliseconds
);
  • x and y are global to the app window, in logical pixels. Add the web view's own offset to coordinates measured inside it.
  • atEpochMs is when the touch happened, not when you forward it, so a batch of touches keeps its timing.
  • Send down, then moves, then up (or cancel) for each touch, with one pointerId per finger.

Examples

Gate a sign-up button

import 'package:dea_sdk/dea_sdk.dart';
import 'package:flutter/material.dart';

class SignUpButton extends StatelessWidget {
  const SignUpButton({super.key, required this.onSignUp, required this.onChallenge});

  final VoidCallback onSignUp;
  final VoidCallback onChallenge;

  @override
  Widget build(BuildContext context) {
    return FilledButton(
      onPressed: () {
        final read = DeaSdk.instance?.getClassification();
        if (read?.result == ClassificationResult.human) {
          onSignUp();
        } else {
          onChallenge(); // e.g. show a captcha
        }
      },
      child: const Text('Create account'),
    );
  }
}

Report typing in a text field

Initialize with the key kind added:

DeaSdk.init(
  const DeaOptions(
    appId: 'YOUR_APP_ID',
    events: [...pointerKinds, EventKind.keyDown],
  ),
  appKey: 'YOUR_APP_KEY',
);

Then report from the field:

import 'package:dea_sdk/dea_sdk.dart';
import 'package:flutter/material.dart';

class TrackedTextField extends StatefulWidget {
  const TrackedTextField({super.key, required this.label});

  final String label;

  @override
  State<TrackedTextField> createState() => _TrackedTextFieldState();
}

class _TrackedTextFieldState extends State<TrackedTextField> {
  String _previous = '';

  void _onChanged(String text) {
    final dea = DeaSdk.instance;
    if (dea != null) {
      if (text.length < _previous.length) {
        dea.captureKey(KeyClass.delete);
      } else if (text.length > _previous.length) {
        dea.captureKey(KeyClass.of(text.substring(text.length - 1)));
      }
    }
    _previous = text;
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      decoration: InputDecoration(labelText: widget.label),
      onChanged: _onChanged,
    );
  }
}

Forward touches from a web view

With webview_flutter, a small script in the page posts each touch to the app, and the app forwards it in window coordinates:

import 'dart:convert';

import 'package:dea_sdk/dea_sdk.dart';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

const _forwardTouches = '''
(function () {
  var kinds = {touchstart: 'down', touchmove: 'move', touchend: 'up', touchcancel: 'cancel'};
  Object.keys(kinds).forEach(function (type) {
    document.addEventListener(type, function (e) {
      var at = Math.round(performance.timeOrigin + e.timeStamp);
      for (var i = 0; i < e.changedTouches.length; i++) {
        var t = e.changedTouches[i];
        DeaTouch.postMessage(JSON.stringify(
          {kind: kinds[type], id: t.identifier, x: t.clientX, y: t.clientY, at: at}));
      }
    }, {passive: true, capture: true});
  });
})();
''';

class TaskWebView extends StatefulWidget {
  const TaskWebView({super.key, required this.url});

  final Uri url;

  @override
  State<TaskWebView> createState() => _TaskWebViewState();
}

class _TaskWebViewState extends State<TaskWebView> {
  final _viewKey = GlobalKey();

  late final WebViewController _controller = WebViewController()
    ..setJavaScriptMode(JavaScriptMode.unrestricted)
    ..addJavaScriptChannel('DeaTouch', onMessageReceived: _onTouch)
    ..setNavigationDelegate(NavigationDelegate(
      onPageFinished: (_) => _controller.runJavaScript(_forwardTouches),
    ))
    ..loadRequest(widget.url);

  void _onTouch(JavaScriptMessage message) {
    final box = _viewKey.currentContext?.findRenderObject() as RenderBox?;
    if (box == null) return;

    final touch = jsonDecode(message.message) as Map<String, dynamic>;
    final origin = box.localToGlobal(Offset.zero);

    DeaSdk.instance?.capturePointer(
      kind: switch (touch['kind']) {
        'down' => EventKind.down,
        'move' => EventKind.move,
        'up' => EventKind.up,
        _ => EventKind.cancel,
      },
      pointerId: touch['id'] as int,
      x: origin.dx + (touch['x'] as num).toDouble(),
      y: origin.dy + (touch['y'] as num).toDouble(),
      atEpochMs: (touch['at'] as num).toInt(),
    );
  }

  @override
  Widget build(BuildContext context) {
    return WebViewWidget(key: _viewKey, controller: _controller);
  }
}

This assumes the page sets <meta name="viewport" content="width=device-width"> and is not zoomed, so that one CSS pixel equals one logical pixel. Do not forward touches from a page where the user types a password.

API

Member Returns Description
getClassification() Classification human, bot, undetermined or insufficient, with the score, the evidence and the model's thresholds behind it
isScoring bool Whether the SDK is able to score
error Object? Why the SDK cannot score, if it cannot
captureKey(keyClass, {pressed, mods, repeat, field}) void Reports one keystroke your app saw
capturePointer({kind, pointerId, x, y, atEpochMs, tool, force, contact, target}) void Reports one touch from a surface Flutter does not route, such as a web view