---
title: "Voice-Auth Integration Guide"
description: "Server-side setup, callback verification, and SDK reference for @helix-id/node - everything your engineers need to go live."
source: "/docs"
updated: "2026-09-24"
---

# Voice-Auth Integration Guide

Server-side setup, callback verification, and SDK reference for @helix-id/node - everything your engineers need to go live.

## Overview

The `@helix-id/node` package provides a lightweight backend SDK for integrating Helix Biometric MFA into any Node.js application.

The SDK is built on the universal Web Crypto API - it uses no `node:` builtins, so the same code runs on Node, Bun, Deno and serverless runtimes such as Vercel or Cloudflare Workers. It runs server-side only: it holds your shared secret and must never be bundled into browser JavaScript.

### How the flow works

Authentication follows a redirect-based flow: your server generates a signed URL, the user is sent to the Helix portal to speak a short numeric challenge, and Helix redirects back with an HMAC-signed callback result. Both legs are signed with your shared secret - the outbound URL carries a `sig` query param, and the callback carries its own signature. The nonce stored in the user session prevents replay attacks.

The redirect flow in the [Quickstart](/docs/quickstart.md) needs nothing but this server SDK. [Prompt mode](/docs/prompt-mode.md) has a prompt that builds it in one shot with a coding agent, and the optional `@helix-id/browser` widget wires the same flow to a button - see the [Browser SDK](/docs/browser-sdk.md).

### What's included

A single integration of `@helix-id/node` gives you one redirect, one callback, and one verdict. Your servers never receive or store audio - the user speaks on the Helix portal, and the outcome reaches your callback as a single HMAC-signed `statusCode`.

#### Biometric MFA

Voice-ID enrollment and passwordless re-authentication. The user speaks a short numeric challenge displayed on the Helix portal; Helix matches the audio against the enrolled voice profile and returns a signed verdict to your callback.

| Characteristic            | Typical      |
| ------------------------- | ------------ |
| Enrollment time           | \~6s         |
| Re-auth time              | \~2s         |
| Replay protection         | nonce + HMAC |
| Auth URL integrity        | HMAC-SHA256  |
| Callback freshness window | 300s         |

#### What you receive on the callback

The verdict is exposed as a high-level `status` plus a specific `statusCode` on the result returned by `verifyCallback()`:

| Verdict    | What it means                                                                                                                                                                                                                                                               |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `verified` | The speaker matched their enrolled voice profile, or a profile was enrolled during this session. Codes: `voice_verified`, `voice_enrolled`.                                                                                                                                 |
| `failed`   | The attempt did not produce a match, or the space's age gate rejected it. Codes: `challenge_mismatch`, `voice_mismatch`, `synthetic_voice_detected`, `age_requirement_not_met`, `age_inconclusive`. All seven codes are listed under [Status codes](/docs/status-codes.md). |

Service-level commitments (latency, availability) are governed by your Helix order form. Targets above describe typical observed performance, not contractual SLAs.

### Next steps

- [Install the package](/docs/installation.md) and set your Space ID and shared secret.
- Follow the [Quickstart](/docs/quickstart.md) to wire the redirect and the callback.
- Keep the [API reference](/docs/api.md) and [Status codes](/docs/status-codes.md) open while you build.

## Installation

The prerequisites, the package, and the two environment variables every integration needs.

> **INFO - Prerequisites**
>
> Node.js 20 or later, an active Helix Space ID, a shared secret from the Helix admin panel, and an HTTPS-served
> callback endpoint in production. Test credentials are listed under [Testing & credentials](/docs/testing.md).

### 1. Install the package

Install via npm - or your package manager of choice:

*npm*

```bash
npm install @helix-id/node
```

*pnpm*

```bash
pnpm add @helix-id/node
```

*yarn*

```bash
yarn add @helix-id/node
```

*bun*

```bash
bun add @helix-id/node
```

### 2. Configure environment variables

Add your Space ID and shared secret to your environment. Never commit either to source control.

```bash
# .env
HELIX_SPACE_ID=a1b2c3d4-e5f6-7890-abcd-ef1234567890
HELIX_SECRET=tk_live_hx_...
```

> **INFO - Note**
>
> The default portal URL is `https://capsule.helix.id`. You only need to override it if your contract specifies a
> regional or self-hosted portal - see `baseUrl` in the [API reference](/docs/api.md).

### 3. Confirm the integration

With credentials in place, follow the [Quickstart](/docs/quickstart.md) to wire the redirect and callback. Local development can use the [test credentials](/docs/testing.md) in lieu of a real Space.

## Quickstart

Signed redirects, verified callbacks, Enrollment IDs, and Reference List management using the server SDK.

### 1. Generate the redirect URL (backend)

Call `createAuthUrl()` on your backend to generate a unique, signed URL and a cryptographic nonce. Store the nonce in the user's session before redirecting.

`createAuthUrl` HMAC-SHA256-signs the URL params with your shared secret and appends the signature as a `sig` query param, so it needs the `secret` option. It is **async** - it uses the Web Crypto API, so it returns a Promise and must be awaited.

```js
import { createAuthUrl } from "@helix-id/node";

app.get("/auth/start", async (req, res) => {
  const state = Buffer.from(JSON.stringify({ action: "approve_tx", txId: "abc123", returnTo: "/dashboard" })).toString(
    "base64",
  );

  const { url, nonce } = await createAuthUrl({
    spaceId: process.env.HELIX_SPACE_ID,
    state,
    // Required. The browser origin your callback is served from -
    // Helix targets its postMessage at exactly this origin.
    origin: process.env.APP_ORIGIN,
    // Required. Signs the URL params; the signature rides along as `sig`.
    secret: process.env.HELIX_SECRET,
  });

  req.session.helixNonce = nonce;
  // Return only `url` - it already carries `sig`. Never send the secret to the browser.
  res.json({ url });
});
```

The result also carries the raw signature as `sig` if you need it separately; the `url` already includes it. Do not append, reorder or rewrite query params after signing - any edit invalidates the signature.

> **WARN - Upgrading from 0.1.x**
>
> `createAuthUrl` used to be synchronous and took no `secret`. Add `await` and pass `secret` - a forgotten `await`
> yields a Promise where a `{ url, nonce }` object is expected, and the redirect silently breaks.

### 2. Redirect the user (frontend)

Fetch the URL from your backend and redirect the user's browser to the Helix portal.

> **TIP - UX Note**
>
> Consider showing a brief in-product message before the redirect, e.g. "You'll be briefly redirected to Helix to verify
> your identity, then brought back here." This sets expectations and reduces drop-off when users land on the Helix
> portal.

```js
const { url } = await fetch("/auth/start").then((r) => r.json());
window.location.href = url;
```

### 3. Verify the callback (backend)

After the user completes the voice challenge, Helix redirects to your callback endpoint. Call `verifyCallback()` to validate the HMAC signature, nonce, and timestamp.

```js
import {
  verifyCallback,
  InvalidSignatureError,
  NonceMismatchError,
  ExpiredTimestampError,
  MalformedCallbackError,
} from "@helix-id/node";

app.get("/auth/callback", async (req, res) => {
  // verifyCallback expects every field to be a plain string.
  // Express may parse duplicate query params as arrays -
  // normalize req.query before passing it in.
  const query = Object.fromEntries(Object.entries(req.query).map(([k, v]) => [k, Array.isArray(v) ? v[0] : String(v)]));

  try {
    const result = await verifyCallback(query, {
      nonce: req.session.helixNonce,
      secret: process.env.HELIX_SECRET,
    });

    req.session.helixNonce = null;
    const state = JSON.parse(Buffer.from(result.state, "base64").toString());

    if (result.status === "verified") {
      res.redirect(state.returnTo);
    } else {
      res.redirect(`${state.returnTo}?error=${result.statusCode}`);
    }
  } catch (error) {
    // every Helix error carries a stable .code, e.g. INVALID_SIGNATURE
    if (error instanceof InvalidSignatureError) return res.status(401).json({ error: error.code });
    if (error instanceof NonceMismatchError) return res.status(401).json({ error: error.code });
    if (error instanceof ExpiredTimestampError) return res.status(401).json({ error: error.code });
    if (error instanceof MalformedCallbackError) return res.status(400).json({ error: error.code });
    throw error;
  }
});
```

> **INFO - TypeScript**
>
> The snippet above is JavaScript. In TypeScript, `Object.fromEntries()` widens to `{ [k: string]: string }`, which will not satisfy the declared parameter type - import the `CallbackQuery` type from the package and assert the normalized object with `as CallbackQuery`.

### 4. Store & reuse the Enrollment ID (backend)

If your Space has **Voice MFA** or **Reference List** enabled, `verifyCallback` returns an `enrollmentId`. Store it against the user - it is how Helix recognizes them on their next voice-auth session instead of enrolling a duplicate.

```js
const result = await verifyCallback(query, { nonce: req.session.helixNonce, secret: process.env.HELIX_SECRET });

if (result.enrollmentId) {
  await db.users.update(userId, { helixEnrollmentId: result.enrollmentId });
}
```

Pass it back into `createAuthUrl` the next time that same user starts a voice-auth session:

```js
const { url, nonce } = await createAuthUrl({
  spaceId: process.env.HELIX_SPACE_ID,
  state,
  origin: process.env.APP_ORIGIN,
  secret: process.env.HELIX_SECRET,
  // Omit on a user's first session - Helix enrolls them and returns a fresh Enrollment ID.
  enrollmentId: user.helixEnrollmentId,
});
```

> **INFO - Enroll vs. verify**
>
> Whether this session enrolls or verifies is decided entirely by whether you pass `enrollmentId` - never infer one from
> a missing ID on your own side. A dropped ID silently creates a duplicate enrollment and bills for it.

### 5. Handle a Reference List match (backend)

If your Space has **Reference List** enabled, a new enrollment comes back with `result.referenceList`. A match does not block the user - the enrollment still completes - so act on it yourself, using the category you added the person with. `denied` and `vip` below are recommended client-defined labels with no built-in behavior; custom labels work too. Your server adds people using [`addToReferenceList`](/docs/api.md#reference-list-management).

```js
if (result.referenceList?.result === "CONFIRMED_MATCH") {
  const { matchedEnrollmentId, matchedCategory } = result.referenceList;
  if (matchedCategory === "denied") await reviewQueue.add({ userId, matchedEnrollmentId });
  if (matchedCategory === "vip") await vipDesk.notify({ userId, matchedEnrollmentId });
}
```

Treat `UNSCOREABLE`, and any value you do not recognize, as "not screened" rather than "clear". All values are listed under [Reference List results](/docs/status-codes.md#reference-list-results).

### 6. Add or remove a person (backend)

Use the Enrollment ID stored in step 4 when your policy calls for listing a person. Adding requires an active server-side voice template; an ID alone does not guarantee eligibility. Add the person in one server action:

```js
import { addToReferenceList } from "@helix-id/node";

const options = {
  spaceId: process.env.HELIX_SPACE_ID,
  secret: process.env.HELIX_SECRET,
  enrollmentId: user.helixEnrollmentId,
};

await addToReferenceList({ ...options, category: "denied", reason: "manual_review" });
```

After your review upholds an appeal, remove them in a **separate server action**:

```js
import { removeFromReferenceList } from "@helix-id/node";

await removeFromReferenceList({
  spaceId: process.env.HELIX_SPACE_ID,
  secret: process.env.HELIX_SECRET,
  enrollmentId: user.helixEnrollmentId,
  reason: "APPEAL_UPHELD",
});
```

Keep these calls on your server. The helpers sign `POST /api/v1/reference-list` for the selected Space/environment and throw `ReferenceListError` on HTTP failures. See [Reference List management](/docs/api.md#reference-list-management) for every option, category convention, and HTTP status.

## Prompt mode

An alternative to wiring the Quickstart by hand. Paste one prompt into a coding agent and it builds the same redirect flow in one shot.

### The prompt

Your Helix dashboard serves this prompt with your Space ID already filled in, under Integration Guide -> Prompt Mode at `capsule.helix.id/dashboard/spaces/<space-id>/integration`. Copy it from there rather than retyping the placeholder below.

```text
Use https://helix.id/docs.md as reference.
Add @helix-id/node to my app with voice authentication.
Space ID: <your-space-id>.

Create a /helix/start endpoint that generates a redirect URL
and a /helix/callback endpoint that verifies the HMAC-signed callback.
Use the shared secret from the HELIX_SECRET environment variable -
createAuthUrl is async and takes it as the secret option to sign the URL.

Add a frontend button that starts the voice auth flow via redirect.
```

### What the agent will build

| Endpoint              | Purpose                                                              |
| --------------------- | -------------------------------------------------------------------- |
| `GET /helix/start`    | Generates a redirect URL and stores the nonce in the user's session. |
| `GET /helix/callback` | Verifies the HMAC-signed callback and redirects the user onward.     |
| Frontend button       | Triggers the voice auth flow via a full-page redirect.               |

> **WARN - Important**
>
> Never paste your shared secret into an agent prompt. The prompt above deliberately refers to `HELIX_SECRET` by name -
> set it in the environment yourself, and keep it out of anything the agent reads or writes.

## Browser SDK

Optional - the Quickstart is complete on its own. This widget wires the same flow to a button and holds no secret; HMAC verification stays on your server.

### 1. Install the widget

Install it from npm, or load the IIFE build from jsDelivr - it self-attaches to `window.Helix`:

*npm*

```bash
npm install @helix-id/browser
```

*CDN*

```html
<script src="https://cdn.jsdelivr.net/npm/@helix-id/browser@0/dist/helix.iife.js"></script>
```

### 2. Open the flow

```js
import { Helix } from "@helix-id/browser";
// Recommended. Full-page navigation - nothing to block.
document.querySelector("#verify").addEventListener("click", () => {
  Helix.open({
    mode: "redirect",
    startUrl: "/helix/start", // your backend - returns { url }
    onError: (err) => console.error(err),
  });
});
```

### Options

| Option        | Type                               | Description                                                                                                                                                                                                                            |
| ------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode`        | `"popup" \| "embed" \| "redirect"` | Defaults to `popup`; pass `redirect` explicitly. Redirect navigates away, so only `onError` can fire, before the navigation.                                                                                                           |
| `container`   | `string \| Element`                | Embed only, and required there. CSS selector or DOM element the iframe mounts into - give it a height, the iframe is `100%` of it. Embed mode returns a `{ destroy() }` handle so you can tear it down on unmount.                     |
| `startUrl`    | string                             | Your endpoint returning `{ url }`. Defaults to `/helix/start`.                                                                                                                                                                         |
| `callbackUrl` | string                             | Popup and embed. Your endpoint verifying the signed callback. Defaults to `/helix/callback`.                                                                                                                                           |
| `onSuccess`   | `(result) => void`                 | Popup and embed. Called with `{ status, statusCode, logId, enrollmentId? }` on a verified result.                                                                                                                                      |
| `onError`     | `(error) => void`                  | Called with `{ status: "error", message }` when the SDK itself fails - **and with the full callback result** (`{ status: "failed", statusCode, ... }`, no `message`) on any non-verified verdict. Branch on `statusCode`, not `message`. |
| `onExit`      | `() => void`                       | Popup only. Called when the user closes the popup early, and once if the popup is blocked - the SDK then falls back to a full-page redirect.                                                                                           |

There is no `baseUrl` option - the widget derives the Helix origin from the `url` your server returns from `createAuthUrl()`, so it always targets the environment your server is configured for.

> **WARN - Embed mode: three things that break it**
>
> The iframe is sized `100%` of your container - a container with no height renders an invisible widget and no error.
> The parent page must be HTTPS and must not block `microphone` in its own `Permissions-Policy`: the SDK delegates the
> permission via `allow`, but it cannot grant what the top-level document forbids. And an embed that sees no completion
> message within 10 minutes tears its iframe down and calls `onError`.

### Signature check before redirect

The widget checks that the URL from your `startUrl` endpoint carries a `sig` param shaped like an HMAC-SHA256 digest (64 lowercase hex chars). If it is missing or malformed, the widget calls `onError` with `{ status: "error", message }` instead of opening the popup or mounting the iframe.

The widget holds no secret, so this is a shape check, not cryptographic verification - it catches a misconfigured backend or an obviously rewritten URL, and nothing more. The real guarantees stay server-side: `verifyCallback()` on the way back, and `verifyAuthUrlSignature()` if you need to re-check a URL that crossed a service boundary.

> **WARN - Do not rewrite the URL**
>
> The widget no longer overrides `sdk_origin` - your backend now signs it into the URL via the `origin` option. Editing
> any query param client-side invalidates `sig` and the flow is rejected.

## API reference

Server SDK functions and the Reference List endpoint, with options, results, HTTP statuses and errors.

### `createAuthUrl(options)`

`createAuthUrl(options)` -> `Promise<{ url, nonce, sig }>`

Generates an HMAC-signed redirect URL and a cryptographic nonce. **Async** - it signs via the Web Crypto API, so it returns a Promise and must be awaited. It performs no network call.

| Option         | Type                        | Required | Description                                                                                                                                                                      |
| -------------- | --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `spaceId`      | string                      | yes      | Your Space ID (UUID) from the Helix admin panel.                                                                                                                                 |
| `state`        | string                      | yes      | Opaque base64 payload echoed back in the callback.                                                                                                                               |
| `origin`       | string                      | yes      | The browser origin your app is served from, e.g. `https://app.example.com`. Helix targets its completion message at exactly this origin, so it must match. Sent as `sdk_origin`. |
| `secret`       | string                      | yes      | Shared secret from the Helix admin panel. Used to HMAC-SHA256-sign the URL params - server-side only.                                                                            |
| `baseUrl`      | string                      | no       | Override Helix portal URL. Defaults to `https://capsule.helix.id` when omitted.                                                                                                  |
| `env`          | `"sandbox" \| "production"` | no       | Selects which space config Helix looks up. Defaults to `production` - leave it unset against `capsule.helix.id`.                                                                 |
| `enrollmentId` | string                      | no       | Enrollment ID from a previous `verifyCallback` result. Pass it to verify this user against their existing enrollment; omit on their first session to enroll them.                |

**Returns** `Promise<{ url: string, nonce: string, sig: string }>` - store `nonce` in the user session and hand `url` to the browser.

#### URL signing

The signature covers the canonical query string: every param except `sig`, sorted by raw key, then each key and value percent-encoded exactly like JavaScript's `encodeURIComponent` (`state=a&b` -> `state=a%26b`), joined as `key=value` pairs with `&`. Other languages: `!'()*` stay unescaped, which Python's `quote` and PHP's `rawurlencode` escape by default. That string is HMAC-SHA256'd with `secret` and appended as `sig` (64 lowercase hex chars):

```text
/voice-auth?space_id=...&nonce=...&state=...&env=...&sdk_origin=...&sig=<hmac-sha256-hex>
```

Because every other param is covered, changing any of them after signing invalidates `sig`. Build the URL once, on the server, and pass it through untouched.

### `verifyAuthUrlSignature(url, secret)`

`verifyAuthUrlSignature(url, secret)` -> `Promise<void>`

Verifies the `sig` on an auth URL produced by `createAuthUrl`. **Async.** Rebuilds the canonical string from the URL's remaining params and compares in constant time via `crypto.subtle.verify`. Rejects a URL that repeats a query param. Server-side only - it needs the secret.

```js
import { verifyAuthUrlSignature, InvalidAuthUrlSignatureError } from "@helix-id/node";

try {
  await verifyAuthUrlSignature(url, process.env.HELIX_SECRET);
} catch (error) {
  if (error instanceof InvalidAuthUrlSignatureError) {
    // URL is unsigned or was tampered with - do not redirect the user
  }
}
```

**Returns** `Promise<void>` - resolves when the signature is valid.

**Throws** `InvalidAuthUrlSignatureError` when `sig` is missing or does not match, or a query param repeats.

Most integrations never call this: your own server produced the URL, so there is nothing to check. It is there for architectures where the URL crosses a trust boundary between services - a gateway, a BFF, or a queue - and the receiving side wants proof it was not rewritten in transit.

### `verifyCallback(query, options)`

`verifyCallback(query, options)` -> `Promise<VerifyCallbackResult>`

Verifies the HMAC-signed callback payload and returns the parsed result. **Async** - it uses the Web Crypto API, so it returns a Promise and must be awaited. The first argument is the raw callback query, every field a plain string.

| Option   | Type   | Required | Description                                               |
| -------- | ------ | -------- | --------------------------------------------------------- |
| `nonce`  | string | yes      | Nonce returned by `createAuthUrl`, stored in the session. |
| `secret` | string | yes      | Shared secret from the Helix admin panel.                 |

**Returns** `Promise<{ status, statusCode, logId, state, enrollmentId?, referenceList? }>` - `enrollmentId` is present when the space has Voice MFA or Reference List enabled, absent otherwise. `referenceList` is `{ result, matchedEnrollmentId?, matchedCategory? }` on a new enrollment in a Reference List space - see [Reference List results](/docs/status-codes.md#reference-list-results).

**Throws** `InvalidSignatureError`, `NonceMismatchError`, `ExpiredTimestampError`, `MalformedCallbackError`.

### Reference List management

People enter your Reference List only when your server adds them. After verifying a voice-auth callback, store its `enrollmentId` against your user. Use that ID when your own review or customer policy calls for adding or removing the person. Lists are separate for each Space and environment.

Adding requires Reference List to be enabled and an active **server-side voice template** belonging to your client. An Enrollment ID alone is insufficient: a person with only a device-held template cannot be added by this endpoint and receives `no_template_available`. Removing an entry is allowed even when Reference List is disabled.

Both helpers run on your backend, hold the space/environment's shared secret, and perform a network request. They use `https://api.helix.id` by default (`HELIX_DEFAULT_API_URL`); `createAuthUrl` uses the portal at `https://capsule.helix.id`. Override the helpers' `baseUrl` for a local or alternate API deployment.

```js
import { addToReferenceList, ReferenceListError } from "@helix-id/node";

const options = {
  spaceId: process.env.HELIX_SPACE_ID,
  secret: process.env.HELIX_SECRET,
  enrollmentId: user.helixEnrollmentId, // stored from a verified callback
};

try {
  const entry = await addToReferenceList({
    ...options,
    category: "denied", // your label; Helix does not block this person
    reason: "manual_review",
    customerRef: "CASE-7",
    actor: "support-team",
  });
} catch (error) {
  if (error instanceof ReferenceListError) {
    console.error(error.status, error.code); // upstream HTTP status and error code
  } else {
    throw error; // transport or successful-response JSON parsing failure
  }
}
```

Remove the person in a **separate server action**, after your review upholds an appeal:

```js
import { removeFromReferenceList, ReferenceListError } from "@helix-id/node";

try {
  const removal = await removeFromReferenceList({
    spaceId: process.env.HELIX_SPACE_ID,
    secret: process.env.HELIX_SECRET,
    enrollmentId: user.helixEnrollmentId,
    reason: "APPEAL_UPHELD",
  });
} catch (error) {
  if (error instanceof ReferenceListError) console.error(error.status, error.code);
  else throw error;
}
```

#### `addToReferenceList(options)`

`addToReferenceList(options)` -> `Promise<AddToReferenceListResult>`

| addToReferenceList option | Type                        | Required | Description                                                                        |
| ------------------------- | --------------------------- | -------- | ---------------------------------------------------------------------------------- |
| `spaceId`                 | string                      | yes      | Space ID (UUID) whose list you manage.                                             |
| `secret`                  | string                      | yes      | Shared secret for that Space and environment. Server-side only; never transmitted. |
| `enrollmentId`            | string                      | yes      | Stored ID from a verified callback: 64 lowercase hex characters.                   |
| `env`                     | `"sandbox" \| "production"` | no       | Defaults to `production`; use the corresponding environment's secret.              |
| `baseUrl`                 | string                      | no       | API origin, default `https://api.helix.id`.                                        |
| `actor`                   | string                      | no       | Audit actor, 1-128 characters; defaults to `api`.                                  |
| `category`                | string                      | no       | Your label matching `[A-Za-z0-9_-]{1,64}`. See the conventions below.              |
| `reason`                  | string                      | no       | Audit reason, 1-64 characters; free text for additions.                            |
| `customerRef`             | string                      | no       | Your case/reference, 1-256 characters; sent as `customer_ref`.                     |

| addToReferenceList result | Type             | Required | Description                                     |
| ------------------------- | ---------------- | -------- | ----------------------------------------------- |
| `enrollmentId`            | string           | yes      | ID of the listed person.                        |
| `category`                | `string \| null` | yes      | Stored label, or `null` if none was supplied.   |
| `addedAt`                 | string           | yes      | Original addition timestamp in ISO 8601 format. |
| `listSize`                | number           | yes      | Entries in this Space/environment's list.       |
| `alreadyListed`           | true             | no       | Present only for an existing entry (HTTP 200).  |

A new entry returns HTTP 201. If the person is already listed, the endpoint returns HTTP 200 and their original timestamp/category; it does **not** update metadata. The enabled-feature and active-template prerequisites still apply to repeated additions.

#### `removeFromReferenceList(options)`

`removeFromReferenceList(options)` -> `Promise<RemoveFromReferenceListResult>`

| removeFromReferenceList option | Type                        | Required | Description                                       |
| ------------------------------ | --------------------------- | -------- | ------------------------------------------------- |
| `spaceId`                      | string                      | yes      | Space ID (UUID) whose list you manage.            |
| `secret`                       | string                      | yes      | Shared secret for the selected Space/environment. |
| `enrollmentId`                 | string                      | yes      | Listed person's ID: 64 lowercase hex characters.  |
| `env`                          | `"sandbox" \| "production"` | no       | Defaults to `production`.                         |
| `baseUrl`                      | string                      | no       | API origin, default `https://api.helix.id`.       |
| `actor`                        | string                      | no       | Audit actor, 1-128 characters; defaults to `api`. |
| `reason`                       | ReferenceListRemoveReason   | no       | One of the removal reasons below.                 |

| removeFromReferenceList result | Type   | Required | Description                           |
| ------------------------------ | ------ | -------- | ------------------------------------- |
| `enrollmentId`                 | string | yes      | ID of the removed person.             |
| `removedAt`                    | string | yes      | Removal timestamp in ISO 8601 format. |

Removal returns HTTP 200. Removing an absent entry returns HTTP 404 `not_listed`, which the helper throws as `ReferenceListError`.

| Removal reason   | Description                                    |
| ---------------- | ---------------------------------------------- |
| `APPEAL_UPHELD`  | Your review upheld the person's appeal.        |
| `EXPIRED`        | Your retention or listing period ended.        |
| `CUSTOMER_ERROR` | Your organization added the person by mistake. |
| `OTHER`          | Another reason for removal.                    |

These reasons record your decision; they do not schedule automatic removal.

#### Category conventions

`category` is optional, client-defined text within the character/length limits above. These recommended labels are conventions, **not an enum or built-in policies**. Custom labels are supported, and the stored label is returned as `referenceList.matchedCategory` when a new enrollment matches. Your application decides how to act; Helix never blocks or grants privileges based on category.

| Recommended category | Description                                                                           |
| -------------------- | ------------------------------------------------------------------------------------- |
| `denied`             | People your policy marks for denial or review; your application enforces that policy. |
| `vip`                | People your application wants to recognize or give special handling.                  |

#### `POST /api/v1/reference-list`

The raw endpoint is `https://api.helix.id/api/v1/reference-list`. Send **query parameters, no JSON body**. The SDK supplies `action`, `ts`, and `sig`; raw clients must supply them too. Unknown query parameters are rejected.

| Reference List parameter | Type                        | Required | Actions | Description                                                                                   |
| ------------------------ | --------------------------- | -------- | ------- | --------------------------------------------------------------------------------------------- |
| `space_id`               | string                      | yes      | both    | Space UUID.                                                                                   |
| `env`                    | `"sandbox" \| "production"` | yes      | both    | Required on the wire; the SDK defaults it to `production`.                                    |
| `ts`                     | string                      | yes      | both    | Current Unix timestamp in seconds, decimal digits. Must be within 300 seconds of server time. |
| `sig`                    | string                      | yes      | both    | HMAC-SHA256 signature in 64 lowercase hex characters.                                         |
| `action`                 | `"add" \| "remove"`         | yes      | both    | Operation to perform.                                                                         |
| `enrollment_id`          | string                      | yes      | both    | Enrollment ID, exactly 64 lowercase hex characters.                                           |
| `actor`                  | string                      | no       | both    | Audit actor, 1-128 characters; defaults to `api`.                                             |
| `category`               | string                      | no       | add     | Optional label matching `[A-Za-z0-9_-]{1,64}`.                                                |
| `reason`                 | string                      | no       | both    | Add: 1-64 characters of free text. Remove: one of the fixed reasons above.                    |
| `customer_ref`           | string                      | no       | add     | Customer case/reference, 1-256 characters.                                                    |

Sign every transmitted parameter except `sig`, including all optional metadata, with the selected Space/environment's shared secret. Use the same [canonical query encoding](#url-signing) as `createAuthUrl`: sort raw keys; percent-encode each key/value with `encodeURIComponent`; join `key=value` pairs with `&`; compute HMAC-SHA256 and append the lowercase hex digest as `sig`. Do not sign the already-serialized query string (`URLSearchParams` encodes spaces differently). Generate a fresh timestamp/signature for each call, keep clocks synchronized, and use HTTPS.

#### Endpoint HTTP statuses

These are actual HTTP statuses, separate from the voice-auth callback's `statusCode`. Successful outcomes below describe the operation; the JSON response contains the result fields documented above, without an `outcome` field.

| Reference List action | HTTP | Outcome                   | Description                                                                              |
| --------------------- | ---- | ------------------------- | ---------------------------------------------------------------------------------------- |
| add                   | 201  | `added`                   | New entry created.                                                                       |
| add                   | 200  | `already_listed`          | Existing entry returned with `alreadyListed: true`; metadata unchanged.                  |
| remove                | 200  | `removed`                 | Entry deleted and removal journaled.                                                     |
| both                  | 400  | `VALIDATION_ERROR`        | Missing/malformed or unsupported query parameters. Body: `{ error: { code, message } }`. |
| both                  | 401  | `unauthorized`            | Invalid signature, stale/future timestamp, or unavailable Space/environment/secret.      |
| remove                | 404  | `not_listed`              | No entry exists in this Space/environment.                                               |
| add                   | 409  | `reference_list_disabled` | Reference List is disabled for this Space/environment.                                   |
| add                   | 409  | `no_template_available`   | No active server-side voice template belongs to this client's Enrollment ID.             |
| add                   | 409  | `reference_list_full`     | The Space/environment's configured list capacity has been reached.                       |

Business/authentication errors use `{ error: "code" }`; `no_template_available` also includes a message. Both helpers throw `ReferenceListError` for any non-2xx response, exposing the upstream `.status` and backend `.code`. If the failure body has no usable code (including a non-JSON response), `.code` is `REFERENCE_LIST_REQUEST_FAILED`. Transport failures and JSON parsing failures on successful responses propagate unchanged. The helpers do not retry; decide how your server handles each failure rather than forwarding upstream errors to the user automatically.

## Status codes

The seven verdicts a callback can carry, and what each one means for your user.

### Verdicts

| `status_code`              | Numeric | `status`   | Description                                                                                 |
| -------------------------- | ------- | ---------- | ------------------------------------------------------------------------------------------- |
| `voice_verified`           | 200     | `verified` | Voice matched the enrolled profile successfully.                                            |
| `voice_enrolled`           | 201     | `verified` | First-time user - voice profile created during this session.                                |
| `voice_mismatch`           | 401     | `failed`   | Voice biometrics did not match the enrolled profile.                                        |
| `synthetic_voice_detected` | 403     | `failed`   | Audio was flagged as AI-generated, replayed, or otherwise spoofed.                          |
| `challenge_mismatch`       | 422     | `failed`   | Spoken digits did not match the displayed challenge.                                        |
| `age_requirement_not_met`  | 451     | `failed`   | Predicted speaker age is below the space's minimum. Age-gated spaces only - see note below. |
| `age_inconclusive`         | 452     | `failed`   | The age model could not commit to an answer. Age-gated spaces only - see note below.        |

### Age-gated codes

Both age codes only ever appear for spaces with age prediction enabled. Spaces without age prediction never load the model and never see either.

`age_requirement_not_met` is a positive answer: the predicted age bucket is below your configured minimum. A model error also returns this code, because we fail closed when the check cannot run.

`age_inconclusive` is the absence of an answer - the audio was silent or the model's confidence too low to commit. **It is handed back to you unresolved, on purpose.** Whether an inconclusive voice should be blocked, allowed, or routed to another age-verification method is a compliance decision that belongs to you, not to us, so we do not fold it into `age_requirement_not_met`. Note that 452 is a Helix convention, not an IANA-registered HTTP code.

### String and numeric forms

Your callback always receives the string form - `result.statusCode` is what you branch on. The numeric column is the equivalent code used elsewhere in the Helix API; both mappings are exported as `STATUS_CODE_NUMBERS` and `STATUS_CODE_STRINGS`.

### Reference List results

When your Space has **Reference List** enabled, every new enrollment (`voice_enrolled`) is screened against the people you added to the list in that Space and environment - whether you use it for people you have banned, for VIPs, or for both. `verifyCallback` returns the outcome as `result.referenceList`. It is absent on returning-user sessions and in Spaces without Reference List.

| `referenceList.result` | Description                                                                                                                                                          |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CLEAR`                | The voice did not match anyone on your Reference List.                                                                                                               |
| `CONFIRMED_MATCH`      | The voice matched a listed person. `referenceList.matchedEnrollmentId` is the Enrollment ID you added, and `referenceList.matchedCategory` the category you gave it. |
| `UNSCOREABLE`          | Screening could not run for this enrollment. Treat it as unscreened, not as clear.                                                                                   |
| `UNRESOLVED`           | Reserved for multi-sample screening and not returned yet. Handle it like `UNSCOREABLE`, and map any value you do not recognize the same way.                         |

A match never blocks the user: the enrollment completes and `status` stays `verified`. Use `matchedCategory` to tell a banned person from a VIP; what happens next is your decision.

The `reference_list_*` params travel on the callback URL like every other callback param, so the user's browser can read them. Do not echo them back to the user.

## Error handling

The errors the SDK throws while verifying, and the HTTP response each one deserves.

### Error classes

For callback/signature verification errors, the HTTP column suggests a response from your callback handler. For `ReferenceListError`, `upstream` means the API's HTTP status exposed as `.status`; your server decides what to return to its own caller.

| Error Class                    | Thrown by                                      | HTTP     | Cause                                                                                                                                    |
| ------------------------------ | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `InvalidSignatureError`        | `verifyCallback`                               | 401      | Callback HMAC does not match - possible tampering or wrong secret.                                                                       |
| `NonceMismatchError`           | `verifyCallback`                               | 401      | Nonce in callback does not match the session nonce.                                                                                      |
| `ExpiredTimestampError`        | `verifyCallback`                               | 401      | Callback timestamp is outside the accepted time window.                                                                                  |
| `MalformedCallbackError`       | `verifyCallback`                               | 400      | Required callback fields are missing or unparseable.                                                                                     |
| `InvalidAuthUrlSignatureError` | `verifyAuthUrlSignature`                       | 401      | Auth URL `sig` is missing or does not match - the URL was rewritten.                                                                     |
| `ReferenceListError`           | `addToReferenceList / removeFromReferenceList` | upstream | API request returned a non-2xx status. Inspect `.status` and `.code`; see [endpoint HTTP statuses](/docs/api.md#endpoint-http-statuses). |

### Error codes

Every class extends `VoiceAuthError` and carries `.code`. Verification codes are `INVALID_SIGNATURE`, `NONCE_MISMATCH`, `EXPIRED_TIMESTAMP`, `MALFORMED_CALLBACK`, and `INVALID_AUTH_URL_SIGNATURE`. `ReferenceListError.code` preserves the API's error code, with `REFERENCE_LIST_REQUEST_FAILED` as the fallback when the error body has no usable code. Transport failures and successful-response JSON parsing failures are native errors.

## Testing & credentials

Built-in sandbox credentials for local development, importable from the SDK itself.

### Sandbox credentials

Use the built-in test credentials for local development. Import them from the `@helix-id/node/testing` sub-path.

```js
import { createAuthUrl } from "@helix-id/node";
import { HELIX_TEST_SPACE_ID, HELIX_TEST_SECRET, HELIX_TEST_CALLBACK_URL } from "@helix-id/node/testing";

const { url, nonce, sig } = await createAuthUrl({
  spaceId: HELIX_TEST_SPACE_ID,
  state: "test",
  origin: new URL(HELIX_TEST_CALLBACK_URL).origin,
  secret: HELIX_TEST_SECRET,
  env: "sandbox",
});
```

| Credential   | Value                                         |
| ------------ | --------------------------------------------- |
| Space ID     | `a1b2c3d4-e5f6-7890-abcd-ef1234567890`        |
| Secret       | `tk_test_hx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` |
| Callback URL | `http://localhost:8080/helix/callback`        |

> **WARN - Important**
>
> Never use test credentials in production. Rotate your production secret regularly.

## Security

The rules that keep the shared secret, the nonce and the signed URL doing their job.

### Checklist

- **Nonce invalidation** - clear `req.session.helixNonce` immediately after a successful callback to prevent replay attacks.
- **Secret management** - store `HELIX_SECRET` in environment variables or a secrets manager. Never commit it to source control.
- **URL signing is server-side** - `createAuthUrl` needs the shared secret, so it must run on your backend. Calling it from browser code would ship the secret to every visitor.
- **Reference List management is server-side** - `addToReferenceList` and `removeFromReferenceList` also need the Space/environment's secret. Keep them on your backend; their signed query includes a timestamp accepted within 300 seconds of server time.
- **Never mutate a signed URL** - appending, dropping or reordering query params after `createAuthUrl` invalidates `sig`. Pass the returned `url` through verbatim.
- **Query normalization** - always normalize `req.query` to a flat `Record` before calling `verifyCallback` to avoid array injection.
- **HTTPS only** - the callback endpoint must be served over HTTPS in production to protect the HMAC signature in transit.
- **Timestamp window** - the SDK rejects callbacks older than a configured time window. Keep server clocks synchronized (NTP).


## Sitemap

See the full [sitemap](https://helix.id/sitemap.md) for all pages.
