NPM Package
The @surtai/guardian-web SDK runs Guardian's collect() flow in any modern browser. It gathers device signals, encrypts them locally, and returns an opaque payload your backend sends to Surt's evaluate endpoint. The SDK makes no network calls and holds no API key - with one exception: when you pass an optional geolocationJwt, collect() makes a single best-effort GET /geolocation/client-ip call to resolve the browser's public IP and embed it in the payload. Without geolocationJwt, collect() makes zero network calls. The JWT is a short-lived token minted by your backend, not an API key, so there is still no API key in the browser.
npm install @surtai/guardian-web
Unlike the native SDKs (iOS / Android / React Native), the web SDK does not expose a verify() method. All risk decisions happen server-side once your backend forwards the payload. See Collect (Server-to-Server) for the full backend flow.
Usage
import { collect } from '@surtai/guardian-web';
// Variant 1: no IP lookup, zero network calls.
const { payload } = await collect({ collectLocation: false });
// Variant 2: resolve the browser's public IP into the payload.
// Your backend mints the short-lived JWT via preflight.
const { payload: payloadWithIp } = await collect({
collectLocation: false,
geolocationJwt: jwt,
});
// Forward the payload to your backend.
await fetch('https://your-api.com/verify-device', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: 'user_123', payload }),
});
Your backend mints the geolocationJwt by calling preflight with your sp_live_* key - see Authentication. Pass the JWT to collect() only when you want the public IP resolved; otherwise omit it.
Options
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
collectLocation | boolean | No | false | When true, prompts the browser's Geolocation API. The only thing in collect() that can trigger a permission prompt. |
geolocationJwt | string | No | (none) | When provided, collect() resolves the browser's public IP via GET /geolocation/client-ip and embeds it in the payload. Omit it to skip the IP lookup (and all network calls). |
CollectResult
interface CollectResult {
/** Base64 payload. Pass as `payload.data` in the evaluate request shown below. */
payload: string;
/** What the SDK observed while collecting — additive, safe to ignore. */
diagnostics: {
location?: 'collected' | 'denied' | 'unavailable' | 'timeout' | 'not_requested';
networkIntel?: 'collected' | 'unavailable' | 'not_requested';
warnings: { code: string; signal: string; detail?: string }[];
};
}
The diagnostics object tells you what happened on-device — for example whether location was collected or the user denied it — so you can react in your UI:
const { payload, diagnostics } = await collect({ collectLocation: true });
if (diagnostics.location === 'denied') {
// prompt the user to enable location, then retry
}
See Result diagnostics for the full field reference.
Backend: forward to Surt
Your backend receives the payload from the browser and posts to Surt's evaluate endpoint:
POST https://api.surt.com/geolocation/transactions/evaluate
Content-Type: application/json
Authorization: Bearer YOUR_SURT_API_KEY
{
"customer_id": "user_123",
"transaction_type": "login",
"transaction_name": "Sign in",
"payload": {
"type": "encrypted",
"data": "<payload from collect()>"
},
"config": {
"response": {
"address": { "type": "include" }
}
}
}
The request shape, supported transaction_type values, config options, and response schema are identical across all Guardian SDKs. See Collect (Server-to-Server) for the complete reference, including Node / Java / Python examples.
Errors
collect() throws a single GuardianError for three conditions:
import { collect, GuardianError } from '@surtai/guardian-web';
try {
const { payload } = await collect();
} catch (err) {
if (err instanceof GuardianError) {
switch (err.code) {
case 'CRYPTO_UNAVAILABLE': /* not in a secure context */ break;
case 'ENCRYPTION_FAILED': /* encryption failed (rare) */ break;
case 'INVALID_OPTIONS': /* bad arguments */ break;
}
}
}
| Code | Meaning |
|---|---|
CRYPTO_UNAVAILABLE | window.crypto.subtle is missing. The SDK requires a secure context (HTTPS or localhost). |
ENCRYPTION_FAILED | The encryption step failed. Treat as a bug: capture and report. |
INVALID_OPTIONS | The argument to collect() is not an object. |
Individual collectors (fingerprint, battery, geolocation, network, etc.) never throw: they fail soft. A revoked permission or unsupported API just means the corresponding field is omitted from the payload. The public-IP lookup is also best-effort: if the GET /geolocation/client-ip call fails or the JWT is rejected, the IP field is simply omitted and collect() still returns a payload. The backend tolerates sparse payloads.
Browser support
- Any evergreen browser with
window.crypto.subtle(Chrome, Edge, Firefox, Safari, Opera). - Secure context required: HTTPS in production,
localhostfor development. The SDK throwsCRYPTO_UNAVAILABLEoutside a secure context. - Geolocation, Battery Status, NetworkInformation, and other optional APIs degrade gracefully when unavailable.
Framework examples
- React
- Next.js
- Vue
- Svelte
- Vanilla JS
import { useState } from 'react';
import { collect } from '@surtai/guardian-web';
export function VerifyButton({ userId }: { userId: string }) {
const [loading, setLoading] = useState(false);
const handleClick = async () => {
setLoading(true);
try {
const { payload } = await collect({ collectLocation: false });
await fetch('/api/verify-device', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, payload }),
});
} finally {
setLoading(false);
}
};
return (
<button onClick={handleClick} disabled={loading}>
{loading ? 'Verifying...' : 'Continue'}
</button>
);
}
'use client';
import { collect } from '@surtai/guardian-web';
export function SignInForm() {
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const { payload } = await collect();
await fetch('/api/sign-in', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: new FormData(e.currentTarget).get('email'),
payload,
}),
});
// Your /api/sign-in route forwards `payload` to Surt's /evaluate.
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<button type="submit">Sign in</button>
</form>
);
}
<script setup lang="ts">
import { ref } from 'vue';
import { collect } from '@surtai/guardian-web';
const loading = ref(false);
const props = defineProps<{ userId: string }>();
async function verify() {
loading.value = true;
try {
const { payload } = await collect();
await fetch('/api/verify-device', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: props.userId, payload }),
});
} finally {
loading.value = false;
}
}
</script>
<template>
<button :disabled="loading" @click="verify">
{{ loading ? 'Verifying...' : 'Continue' }}
</button>
</template>
<script>
import { collect } from '@surtai/guardian-web';
export let userId;
let loading = false;
async function verify() {
loading = true;
try {
const { payload } = await collect();
await fetch('/api/verify-device', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, payload }),
});
} finally {
loading = false;
}
}
</script>
<button on:click={verify} disabled={loading}>
{loading ? 'Verifying...' : 'Continue'}
</button>
<button id="verify">Continue</button>
<script type="module">
import { collect } from 'https://esm.sh/@surtai/guardian-web';
document.getElementById('verify').addEventListener('click', async () => {
const { payload } = await collect({ collectLocation: false });
await fetch('/api/verify-device', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: 'user_123', payload }),
});
});
</script>
What's different from the native SDKs
@surtai/guardian-web | @surtai/guardian-rn / iOS / Android | |
|---|---|---|
verify() method | No | Yes |
collect() method | Yes (only API) | Yes |
| App-level initialization | None | initialize(options) |
| Customer / transaction context | Set by your backend | Carried in the backend-minted JWT claims |
| API key in client | No | No (uses a backend-minted JWT, not an API key) |
| Network calls from SDK | None (unless geolocationJwt is passed) | Yes |
| Device integrity check | No (no browser equivalent) | Yes |
| Persists per-customer state | No (stateless per call) | No |
Web is intentionally lean: a single static function that produces an encrypted payload. Customer binding, transaction metadata, and decisions all happen in your backend.