@signap/signals-web
v2.2.0
Published
Browser fingerprinting agent for Signap
Readme
@signap/signals-web
TypeScript SDK that collects browser fingerprint signals and identifies a visitor in a single round-trip, returning the resolved visitor id + confidence in the same response. Apache-2.0.
Bundle target: <60KB gzipped (tsup + esbuild, ESM+CJS dual output)
Sync-inline identification (v2)
agent.identify() returns the resolved visitor in a single round-trip — no polling. The Signap endpoint identifies the visitor inline before responding.
import { SignalsAgent } from "@signap/signals-web";
const sig = await SignalsAgent.load({
apiKey: "pk_live_...",
endpoint: "https://your-ingest-host.example", // required — no baked default host
});
const result = await sig.identify({ linkedId: "user_abc", tag: "checkout" });
// { requestId, ingestedAt, region, visitorId, confidence, identifiedAt }Endpoint (required)
endpoint is required — the published SDK ships no baked default host, so
load() throws INVALID_OPTIONS when neither an explicit endpoint nor a resolver
is given. Resolution precedence: an explicit endpoint string → an endpoint
callback. (region selects a baked host, but the region→host map ships empty in
this alpha publish, so region alone does not resolve.)
// (a) explicit string (highest precedence) — the common case.
const sig = await SignalsAgent.load({ apiKey: "pk_live_...", endpoint: "https://ingest.signap.example" });
// (b) a callback you resolve however you like (sync or async).
const sig2 = await SignalsAgent.load({ apiKey: "pk_live_...", endpoint: async () => (await myConfig()).ingestEndpoint });There is no runtime bootstrap fetch — resolution is fully client-static, so the
first identify() posts straight to the resolved host. Sealed mode's key endpoint
follows the same rule (explicit sealedKeyEndpoint string → resolver; also required,
no baked default).
The resolved URL is visible in the browser Network tab — that's fine: the protection is your key's rate-limit + origin policy, not endpoint secrecy.
Custom attributes (linkedId / tag / extra)
identify() takes optional, customer-controlled attributes that are stored on
the event and surface in the operator dashboard. Use them to answer "which
site / page / campaign did this event come from?" — especially when one API
key is embedded across several surfaces:
await sig.identify({
linkedId: "user_12345", // your stable user id (≤ 256 chars)
tag: "shop.example.com/checkout", // free-form label (≤ 1024 chars)
extra: { plan: "pro", experiment: "B" } // up to 16 key/value pairs
});Where they show up in the dashboard:
| Attribute | Dashboard surface |
|---|---|
| tag | Events → "Source" column (chip, next to the auto-captured origin) + filterable via tag:<value> |
| linkedId | Event detail → "Custom" tab |
| extra | Event detail → "Custom" tab (JSON) |
The browser origin (which domain the SDK runs on) is captured automatically — no code needed — and also appears in the "Source" column (filter
origin:<url>). Usetagwhen you need finer granularity than the domain (page, section, campaign).
Reserved extra keys
Some server-side integrations read a specific extra key by name. These are
plain extra entries — no special SDK API, just a convention on the key:
| Key | Used by | Value format |
|---|---|---|
| shopify_order_gid | Shopify Order-Risk push — the Signap Order-Risk integration reads custom.extra["shopify_order_gid"] off the enriched event and pushes a risk assessment to Shopify. Omit it and it simply no-ops. | Shopify order GID, gid://shopify/Order/{numeric-id} |
// On a Shopify checkout/order page, tag the identify call with the order GID:
await sig.identify({
extra: { shopify_order_gid: "gid://shopify/Order/450789469" },
});Error taxonomy
| SdkError.code | When |
|---|---|
| INVALID_OPTIONS | bad apiKey / endpoint / timeoutMs |
| NETWORK_ERROR | fetch + XHR both failed |
| TIMEOUT | per-request timeoutMs exceeded |
| HTTP_ERROR | non-2xx (status on .status) — includes the 503 the server surfaces when the inline Identify hop is unavailable |
| INVALID_RESPONSE | non-JSON or missing required fields |
| ABORTED | external AbortSignal triggered |
Migrating from v1.x
v1 was the async-ack contract (identify() returned an ack, lookup() polled). v2 returns the resolved visitor directly:
// v1
const ack = await sig.identify();
const visitor = await sig.lookupWithPolling(ack.requestId);
// v2
const visitor = await sig.identify();lookup(), lookupWithPolling(), lookupEndpoint, LOOKUP_NOT_READY, LOOKUP_TIMEOUT, and IdentifyAck are removed. See CHANGELOG.md for the full breaking list.
Sources collected (MVP)
- Canvas (2D render → SHA-256 hash)
- WebGL (vendor, renderer, supported extensions, parameters hash)
- AudioContext oscillator (44.1 kHz triangle wave → hash)
navigator.{userAgent, languages, hardwareConcurrency, deviceMemory}screen.{width, height, colorDepth, pixelRatio}Intl.DateTimeFormat().resolvedOptions().timeZone- Touch support
- Network Information (
effectiveType,rtt,downlink) — graceful if absent
Wire format
- Identify:
POST {endpoint}/v1/identify— request: a proto3-JSONFingerprintPayload; response:{ requestId, ingestedAt, region, visitorId, confidence, identifiedAt }.
Browser support
Tested floors: Chromium/Edge 90+, Firefox 90+, Safari 14.1+ (iOS 14.5+). Full matrix, runtime features used, and known cross-browser quirks: docs/BROWSER_COMPAT.md.
Build & test
pnpm install
pnpm build # tsup → dist/
pnpm test # vitest
pnpm e2e # playwright (Chrome+Firefox+Safari+Brave)
pnpm size # bundle size check (fail if >60KB gz)Distribution
- npm:
@signap/signals-web - CDN: a pinned-major
…/signals/v2/agent.min.jsor a floating…/signals/latest/agent.min.js(short cache). The CDN base URL is provided with your Signap account.
Wrappers
- React:
@signap/signals-react(2.0.0+ tracks SDK v2 sync-inline flow) - Next.js:
@signap/signals-nextjs(SSR-safe) - Vue:
@signap/signals-vue(2.0.0+;<SignalsProvider>+useVisitorData()+ a Vue plugin) - Nuxt:
@signap/signals-nuxtjs(SSR-safe)
Security
- No
eval,new Function,setTimeout(string, ...) - API key only via header (never query string, never localStorage)
- CSP nonce-friendly
Timezone
clientTsin payload =new Date().toISOString()(always UTC Z-suffix)- Never parse user-input timestamps with
new Date(string)— usedayjs.utc()
License
Apache-2.0 — see LICENSE.
