---
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

Four steps from a signed redirect URL to a verified callback. The redirect flow needs nothing but 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 **Banned 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.

## 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

Every function @helix-id/node exports for the redirect flow, with its options, return value 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 key, joined as `key=value` pairs with `&`. 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`. 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.

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? }>` - `enrollmentId` is present when the space has Voice MFA or Banned List enabled, absent otherwise.

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

## 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`.

## Error handling

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

### Error classes

Handle each error class specifically to return appropriate HTTP responses:

| 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. |

### Error codes

Every class extends `VoiceAuthError` and carries a stable `.code` - `INVALID_SIGNATURE`, `NONCE_MISMATCH`, `EXPIRED_TIMESTAMP`, `MALFORMED_CALLBACK`, `INVALID_AUTH_URL_SIGNATURE` - so you can branch on the code instead of the class if you prefer.

## 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.
- **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.
