@sv.sphoro/device-intelligence-web-sdk
v3.2.0
Published
Browser device intelligence SDK
Maintainers
Readme
@sv.sphoro/device-intelligence-web-sdk
Browser signal collection for the Sphoro device intelligence platform.
~63 KB minified as a standalone <script> bundle, no runtime dependencies.
Install
npm install @sv.sphoro/device-intelligence-web-sdkOr drop in the standalone build, which needs no bundler. Serve
dist/device-score.global.js from your own asset host:
<script src="/assets/device-score.global.js"></script>
<script>
const client = DeviceScore.createClient({
endpoint: 'https://api.score.sphoro.com',
publishableKey: 'pk_live_a1b2c3',
});
</script>Usage
import { createClient } from '@sv.sphoro/device-intelligence-web-sdk';
const client = createClient({
endpoint: 'https://api.score.sphoro.com',
publishableKey: 'pk_live_a1b2c3',
requestTimeoutMs: 5000,
});
const result = await client.identify();
// {
// deviceId: '6c2f9b1e-…',
// confidence: 0.9871,
// riskScore: 12,
// matchedExistingDevice: true,
// changedAttributes: ['browser.majorVersion'],
// fingerprintVersion: 1,
// requestDurationMs: 41
// }Collect without sending
To post from your own backend, or to inspect what would be sent:
const envelope = await client.collect();
// or, with no client at all:
import { collect } from '@sv.sphoro/device-intelligence-web-sdk';
const envelope = await collect();Compute a fingerprint locally
For offline scoring, tests, or an on-premise deployment that never calls the hosted API:
import { collect, computeFingerprint } from '@sv.sphoro/device-intelligence-web-sdk';
const { signals } = await collect();
const { canonicalHash, stableHash, confidence } = computeFingerprint(signals);Options
createClient({
endpoint: string; // API origin — no path, the SDK appends /v1/collect
publishableKey: string; // pk_… — safe in page source
requestTimeoutMs?: number; // default 5000
collect?: CollectOptions; // applied to every identify() call
});endpoint is an origin, not a URL. The SDK appends /v1/collect itself, so
pass https://api.score.sphoro.com, not https://api.score.sphoro.com/v1/collect.
Trailing slashes are stripped.
| Environment | endpoint |
|---|---|
| Production | https://api.score.sphoro.com |
| UAT | https://api-uat.score.sphoro.com |
| Local development | http://localhost:8080 |
collect.sphoro.com and collect-uat.sphoro.com still resolve for existing
integrations. New ones should use the api. names.
Registering your origins
A publishable key is bound to an origin allowlist held against the key itself, so each site integrates without any change to the API deployment. Register the origin of the page running the SDK, not the API's, on the client's page in the portal:
https://app.example.com exact origin — no path, no trailing slash
*.example.com that domain's subdomainsA key called from an origin outside its list is rejected with
403 origin_not_allowed. An empty list means any origin, which is convenient
in development and worth closing before launch — anyone who reads the key out of
your page source can otherwise submit under it.
Origins must match exactly, including scheme and port: https://example.com
does not cover https://www.example.com, and http://localhost:5173 does not
cover http://localhost:3000.
collect({
timeoutMs?: number; // outer deadline, default 2000
context?: Record<string, string>; // forwarded to the API
skip?: CollectorName[]; // drop specific collectors
sensors?: boolean; // inertial calibration, default true
});skip trades match quality for latency. fonts is the most expensive collector
by a clear margin; dropping it removes roughly 0.14 of the similarity weight, so
do it only on paths where the latency genuinely matters.
sensors samples the accelerometer and gyroscope to recover this unit's factory
calibration. It was opt-in through 3.1.x, when it cost a flat 700ms; it now stops
as soon as it has enough and gives up within 250ms on any device whose motion
sensor stays silent, which is every desktop and every iOS visit. Set it false to
skip the sampling entirely, and see the note on identical handsets below for what
that costs.
What it collects
Eighteen collectors: navigator, screen, canvas, webgl, audio, fonts,
css, storage, webgpu, media, network, permissions, sensors,
battery, features, intl, timing, tamper.
The tamper collector never contributes to the fingerprint. It reports
observations about whether the environment is honest — automation globals,
patched natives, canvas instability, User-Agent contradictions — which only the
server-side risk engine consumes.
No permission prompts. Every API used requires no user grant.
enumerateDevices is read for counts only, since labels need permission. On iOS,
DeviceMotionEvent needs a grant that must come from a user gesture — this SDK
never asks for it, so sensors simply returns nothing there.
Nothing is written. No cookie, no localStorage value, no IndexedDB
record — not even transiently. Storage APIs are probed for availability by
reading: touching localStorage.length throws in exactly the configurations a
write attempt was there to detect, so the write is unnecessary. Earlier versions
set and removed a namespaced probe key, which left nothing behind but still
appeared in a devtools recording and still counted as storage access under
ePrivacy. The cost of the change is that these fields now report availability
rather than usability: a localStorage that reads fine and throws on write — a
full quota — is reported as available.
Two signals describe the unit rather than the model. sensors recovers
gyroscope zero-rate offset and accelerometer gain, trimmed per part at the
factory. battery reads charge and charging state, which is not a fingerprint
— it is excluded from the attribute registry, since a value that moves every few
minutes would fork a device on every visit — and is used server-side only to test
whether one battery could have been in two observed states. Everything else in
the list above is a property of the model: two units of one phone, on one OS and
browser build, agree on all of it byte for byte. battery is Chromium-only;
Firefox removed the API and Safari never shipped it.
Design notes
Collection always returns. It usually sits in front of a login or a checkout. A partial fingerprint is worth a great deal; a hung promise is worth less than nothing. Every collector is individually time-boxed, every failure is captured rather than thrown, and the whole run has an outer deadline on top.
Failures are reported, not hidden. A collector that times out leaves an
entry in envelope.errors, which discounts confidence server-side. Silently
returning nulls would make a blocked browser look identical to one that simply
lacks the API.
Canvas and audio are each rendered twice. Identical renders that produce different bytes prove the browser is injecting per-call noise. Without that check, a randomising browser mints a brand new device on every page load and nothing downstream can tell.
The SDK does not sign requests. A secret shipped to a browser is readable by anyone who opens the network tab. It authenticates with a publishable key bound to an origin allowlist, and the server treats everything it sends as untrusted.
identify() throws on failure. It does not return a default. Treating an
unreachable fraud service as "low risk" is a policy decision that belongs in
your code:
try {
const result = await client.identify();
if (result.riskScore >= 75) return blockCheckout();
} catch (error) {
// Your call: fail open, fail closed, or degrade.
logger.warn({ error }, 'device-score unavailable');
}Browser support
Chromium, Firefox, Safari and their mobile equivalents, current and previous two majors. Older browsers work with fewer signals — every collector degrades individually rather than failing the run.
The SDK requires fetch, Promise and AbortController. It does not require a
secure context, though crypto.getRandomValues and several collectors are
unavailable without one, which lowers confidence.
