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,undeterminedandinsufficient. 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.keyDowntoevents. Without it,captureKeydoes 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)returnsletter(any script),digit(0to9),spaceorother. Report deletions asKeyClass.deleteyourself.- 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: falseand addEventKind.keyUptoevents.
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
);
xandyare global to the app window, in logical pixels. Add the web view's own offset to coordinates measured inside it.atEpochMsis when the touch happened, not when you forward it, so a batch of touches keeps its timing.- Send
down, thenmoves, thenup(orcancel) for each touch, with onepointerIdper 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 |
Related¶
- User Consent — nothing is scored before consent
- Sensitive Screens — paused time does not count as evidence
- API Reference