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.
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.
2. Redirect the user (frontend)
Fetch the URL from your backend and redirect the user's browser to the Helix portal.
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.
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;
}
});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.
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:
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,
});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.
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.
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:
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:
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 for every option, category convention, and HTTP status.