---
title: "Quickstart"
description: "Signed redirects, verified callbacks, Enrollment IDs, and Reference List management using the server SDK."
source: "/docs/quickstart"
updated: "2026-09-24"
---

# Verify your first user.

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.


## Sitemap

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