API Reference

submitToBenny

The thin client-lib helper that closes the loop from fingerprint collection to server-side visitor resolution. Builds the v0_spec §8.2 request body, POSTs it with the right headers, handles timeout and cancellation, and throws a single structured error class for every failure mode.

Reviewed

Signature
submitToBenny(fingerprintResult: FingerprintResult, options: SubmitToBennyOptions): Promise<IdentifyResponse>

Returns: Promise resolving to an IdentifyResponse containing the server-assigned visitorId, matchConfidence bucket, isNew flag, firstSeen and lastSeen timestamps, and echoed context fields. Throws SubmitToBennyError on any non-2xx response, network failure, timeout, abort, or JSON-parse failure.

Overview

submitToBenny is the bridge between local fingerprint collection and server-side identity resolution. After calling getFingerprint() or createCollector().getResult(), pass the FingerprintResult and your connection options to submitToBenny; it handles everything else: building the v0_spec §8.2 request body, stamping issuedAt as Date.now(), setting the correct headers, and returning the parsed IdentifyResponse on success.

The helper is designed around three explicit tenets. First, it is standalone — it is never called automatically from getFingerprint or createCollector. This is deliberate: automatic submission would make it impossible to gate the call on user consent or auth state. The customer wires the two calls together. Second, it is thin — there are no retries, no caching, no buffering. The server is the authoritative validator for apiKey format, freshness, and schema; the helper's only job is build, send, parse, and throw on failure. Third, it throws structured errors — every failure mode (non-2xx HTTP, network failure, abort, timeout, JSON-parse failure) surfaces as a SubmitToBennyError so callers can catch a single class and branch on status and errors[0].code without inspecting raw exception types.

SubmitToBennyOptions fields

FieldTypeRequired / DefaultDescription
endpointstringRequiredFull URL to the server's /v1/identify endpoint (e.g. https://api.bennythedoorman.com/v1/identify or http://localhost:3000/v1/identify). The helper does not split a base URL from a path, so future major-version endpoints (/v2/identify) are reachable by changing only this field. An empty string or non-string value throws INVALID_ENDPOINT before any network call.
apiKeystringRequiredPublic API key from the workspace dashboard. Expected format is pk_live_<…> or pk_test_<…>. The helper does not validate the format prefix — the server is the authority. An empty string or non-string value throws INVALID_API_KEY_INPUT before any network call. See the two-error-status callout below for the difference between a malformed key and an unknown-but-well-formed key.
linkedIdstringOptionalCustomer-provided identifier linking this identification to a domain object such as an order, session, or cart. Excluded from any hash; pure consumer context. Echoed back in IdentifyResponse.linkedId when set. Only included in the request body when it is a non-empty string.
subjectIdstringOptional — strongly recommendedHashed end-user identifier the customer uses to look up visitorId[] for a data subject when fulfilling a DSAR. See the DSAR callout below. Only included in the request body when it is a non-empty string.
tagRecord<string, unknown>OptionalArbitrary JSON-serialisable metadata — passed verbatim to the server, never hashed. Echoed back in IdentifyResponse.tag when set. If tag contains a circular reference, JSON.stringify will throw and submitToBenny will surface a BODY_SERIALIZATION_ERROR before sending the request. Only included when tag is not undefined or null.
clientVersionstringOptionalCaller-supplied client lib version override. Defaults server-side to the lib's VERSION constant when omitted. Only included in the request body when it is a non-empty string.
tiersreadonly number[]OptionalTier list used by the collector — typed 1–9 server-side. Only included in the request body when it is an array. The body receives a copy (Array.from) so mutation of the original after the call does not affect the in-flight request.
originstringOptional — required in NodeExplicit Origin header value. Required for Node consumers because Node's native fetch does not stamp Origin automatically and the server checks Origin against the workspace allowlist. Browser callers should leave this unset — the browser stamps Origin from the page URL itself. See the Node escape hatch callout below.
signalAbortSignalOptionalCaller-controlled cancellation signal. When the signal fires, the in-flight fetch is cancelled and submitToBenny rejects with a SubmitToBennyError whose first error code is ABORTED. Composes with timeoutMs — whichever fires first wins.
timeoutMsnumberOptionalLocal timeout in milliseconds. When the timer fires, the in-flight fetch is aborted and submitToBenny rejects with code TIMEOUT. Negative values and non-finite numbers are treated as no timeout. Composes with signal — whichever fires first wins.
fetchtypeof fetchOptional — defaults to globalThis.fetchFetch implementation override. Defaults to globalThis.fetch when unset. Primary use is unit-testing with vi.fn() so tests do not need to monkey-patch globals. Secondary use is Node runtimes older than 18 where a global fetch is unavailable — pass node-fetch or undici here. If neither options.fetch nor globalThis.fetch is available, submitToBenny throws FETCH_UNAVAILABLE before attempting the request.

IdentifyResponse fields

FieldTypeDescription
requestIdstringPer-request correlation id in the format req_<ULID>. Echoed in server logs as reqId. Use this when reporting issues to correlate client-side errors with server-side traces.
visitorIdstringServer-assigned stable visitor identifier in the format vis_<…>. The server returns the same visitorId for subsequent requests that resolve to the same visitor above the match threshold.
isNewbooleantrue when a new visitors row was created on this request. false when the request was matched to an existing visitor. Use this to distinguish first-visit onboarding from returning-visitor flows.
firstSeenstringISO-8601 UTC timestamp recording when this visitor's row was first created. Stable for the lifetime of the visitor record.
lastSeenstringISO-8601 UTC timestamp recording when this visitor was most recently seen. Updated on every request that resolves to this visitor.
matchConfidenceIdentifyMatchConfidenceBucket assigned by the server's identity-resolution scoring. One of 'exact', 'fuzzy', 'ambiguous', 'new', or 'refused'. See the matchConfidence table below.
linkedIdstring | nullEchoed back from the request when options.linkedId was set; null otherwise.
tagRecord<string, unknown> | nullEchoed back from the request when options.tag was set; null otherwise.
errorsIdentifyResponseError[]Always present. An array of structured per-signal or per-field error entries describing any issues the server encountered while processing the request. Empty on clean runs. Each entry has a stable code string, an optional detail string, and an optional signal name when the error is scoped to a single signal.

matchConfidence buckets

ValueScore rangeMeaning
exact85 or aboveHigh-confidence match to an existing visitor. Treat as the same visitor with strong confidence.
fuzzy70 – 84Partial match; some signal drift since last seen (browser update, screen resolution change, etc.). Matched to the existing visitor but the score is below the exact threshold.
ambiguous55 – 69Multiple candidates scored close together; the runner-up visitor_id is recorded for audit. A new visitor row is created rather than a potentially wrong merge.
newBelow 55 (or no candidates)Score too low to match any existing visitor, or no candidates were found. A new visitor row was created.
refusedN/A — server short-circuitedThe server detected a condition (e.g. automationLikelihood: 'high') and did not attempt a match. A new visitor row was created for the audit trail. No scoring was run.

SubmitToBennyError

submitToBenny throws a single error class — SubmitToBennyError — for every failure mode. Callers wrap one try/catch and branch on two fields: error.status (0 for non-HTTP failures, the actual HTTP status code on a server response) and error.errors[0].code (either a synthetic transport code or a server-side code).

The errors array is frozen at construction time so no downstream code can mutate the server's error envelope. The array always contains at least one entry on error paths — when the server response body was missing or unparseable, the helper synthesises a single entry. The requestId field preserves the server's correlation id when the server set one in the error envelope; it is null for pre-network failures (validation, fetch unavailable) and for non-Benny responses (proxies, edge workers).

SubmitToBennyError properties

PropertyTypeDescription
statusnumberHTTP status code from the server response. 0 for any failure that did not produce an HTTP response: network errors, timeouts, aborts, pre-flight validation failures (missing endpoint/apiKey, fetch unavailable), and body serialisation errors.
requestIdstring | nullServer-provided requestId from the error envelope, preserved for support correlation. null when the failure occurred before a response arrived or when the response body was not a Benny error envelope.
errorsReadonlyArray<IdentifyResponseError>Structured error list. On server-side non-2xx responses this is the server's errors[] array when parseable; otherwise one synthetic entry. On transport failures always one synthetic entry. Each entry has a stable code, an optional detail string, and an optional signal field.

Error codes

CodestatusSourceMeaning
NETWORK_ERROR0Client (synthetic)The fetch rejected with a network-level error that was not an abort or timeout — DNS failure, connection refused, TLS handshake failure, etc.
TIMEOUT0Client (synthetic)The options.timeoutMs timer fired before the server responded. The in-flight request was aborted.
ABORTED0Client (synthetic)The options.signal AbortSignal fired before the server responded. The in-flight request was aborted.
PARSE_ERROR2xxClient (synthetic)The server returned a 2xx status but the response body was not valid JSON, or parsed to null or a primitive rather than an object. The response was not usable.
BODY_SERIALIZATION_ERROR0Client (synthetic)JSON.stringify of the request body threw — most likely because options.tag contains a circular reference. The request was never sent.
FETCH_UNAVAILABLE0Client (synthetic)Neither options.fetch nor globalThis.fetch is a function. The request was never sent. Pass options.fetch on Node < 18.
INVALID_ENDPOINT0Client (synthetic)options.endpoint was missing or an empty string. Thrown by assertOptions before any network activity.
INVALID_API_KEY_INPUT0Client (synthetic)options.apiKey was missing or an empty string. Thrown by assertOptions before any network activity. Distinct from the server's INVALID_API_KEY which is a 401 for an unknown-but-well-formed key.
HTTP_ERRORNon-2xx (varies)Client (synthetic fallback)The server returned a non-2xx response but the body was not a Benny error envelope (e.g. a 502 from a proxy). The helper synthesises this code so callers always see at least one entry.
RATE_LIMITED429Client (synthetic on bare 429)Fastify's default rate-limit plugin returns { statusCode, error, message } rather than the Benny error envelope. When the helper sees a 429 whose body has no errors[] array, it synthesises this code so callers can branch on errors[0].code === 'RATE_LIMITED' for retry/backoff logic without inspecting the HTTP status directly.
typescript
import { getFingerprint, submitToBenny, SubmitToBennyError } from 'doorman-benny'; const result = await getFingerprint(); try { const visitor = await submitToBenny(result, { endpoint: 'https://api.bennythedoorman.com/v1/identify', apiKey: 'pk_live_a1b2c3d4', subjectId: hashedUserId, // strongly recommended for DSAR lookup linkedId: 'order_8821', tag: { flow: 'checkout' }, }); console.log(visitor.visitorId); // 'vis_01HYEXAMPLE…' console.log(visitor.isNew); // true on first visit console.log(visitor.matchConfidence); // 'exact' | 'fuzzy' | 'ambiguous' | 'new' | 'refused' console.log(visitor.firstSeen); // '2026-05-31T09:00:00.000Z' } catch (err) { if (err instanceof SubmitToBennyError) { // status is 0 for pre-network failures; the actual HTTP code otherwise. console.error('HTTP status:', err.status); // requestId is null when no server response arrived. console.error('requestId:', err.requestId); // errors[] always has at least one entry. const code = err.errors[0]?.code; if (code === 'RATE_LIMITED') { // Retry with exponential back-off. } else if (code === 'TIMEOUT') { // Increase timeoutMs or degrade gracefully. } else if (code === 'ABORTED') { // Caller cancelled — no action needed. } else if (code === 'INVALID_API_KEY') { // Well-formed key but unknown — check the workspace dashboard. } else if (code === 'MALFORMED_REQUEST') { // Key format is wrong — check the pk_live_ / pk_test_ prefix. } } throw err;
}

submitToBenny is always called explicitly after getFingerprint — it is never invoked automatically.

Things worth knowing

  • submitToBenny is standalone and is NOT auto-called from getFingerprint or createCollector. This is deliberate — automatic submission would make it impossible to gate the call on user consent or authentication state. The customer wires the two calls together at the point in their flow where submission is appropriate.
  • There are no retries. The server is the authoritative validator for rate limits, staleness, and schema conformance. Client-side retry logic would fight the server's rate limiter and complicate the DSAR audit trail. Retry logic belongs in the caller, not the helper.
  • issuedAt is always stamped as Date.now() at the moment submitToBenny is called. The caller does not set it and cannot override it. The server uses this value to enforce a staleness window (requests older than the configured threshold return 410 STALE_PAYLOAD), so do not batch or delay calls after collection.
  • The errors[] array on IdentifyResponse is always present and often empty on success paths. It carries structured per-signal or per-field warnings from the server — for example a signal that was absent or a tag field that was truncated. Checking errors.length === 0 is safe on a 200 response; a non-empty array on 200 is informational, not a failure.
  • options.fetch exists for testability. In vitest, pass a vi.fn() as options.fetch to inject canned responses without monkey-patching globalThis. In Node < 18, pass an undici or node-fetch compatible implementation. Production browser and Node 18+ callers leave it unset.

Last reviewed 2026-06-04