Userz

@userz-ai/node

Server-side SDK for issuing private-mode user tokens and verifying outbound Userz webhooks.

Install

Bash
pnpm add @userz-ai/node

Requires Node 18+. Pure ESM. Built on jose for JWT signing, works on Bun, Deno, and Cloudflare Workers as well as Node.

mintUserToken(input, options?)

Sign an HS256 JWT proving an end-user is authorized to submit feedback for a given App. Returns the token string, pass it back to the browser (e.g. via your /me endpoint), and configure the widget with getUserToken to include it in X-Userz-User-Token on every submission.

TypeScript
import { mintUserToken } from '@userz-ai/node';

const token = await mintUserToken(
  {
    appId: process.env.USERZ_APP_ID!,
    signingSecret: process.env.USERZ_APP_SIGNING_SECRET!,
    sub: user.id,
    ctx: { email: user.email, plan: user.plan }, // optional, ships in the JWT
  },
  { expiresInSeconds: 60 * 60 }, // default 1 hour, max 24 hours
);

Input

NameTypeDescription
appId*stringThe 24-char hex App id from the dashboard.
signingSecret*string | Uint8ArrayThe App's private-mode signing secret. Treat like a Stripe secret key, env var only, NEVER ship to the browser. Hex / base64 / raw string accepted.
sub*stringThe end-user's id in your system.
ctxRecord<string, unknown>Optional context attached as a `ctx` claim, surfaces in the feedback row's submitter.claims field. Limit: 2 KB JSON.

Options

NameTypeDescription
expiresInSecondsnumberTTL in seconds. Default 3600 (1 hour). Hard cap 86400 (24 hours). Minimum 60.

Errors

NameTypeDescription
ErrorexpiresInSeconds must be at least 60You passed a TTL below the minimum.

Verification errors (signature mismatch, expired token, wrong app claim) happen server-side at POST /v1/feedback and surface to the browser as a 401 invalid_user_token. mintUserToken itself never calls our API, it just signs locally.

The signing secret is a bearer credential, anyone who has it can mint tokens that pose as any user in your App. Never commit it. Never expose it to the browser. Rotate via the App settings page if it leaks.

verifyWebhookSignature(input)

For customers subscribing to outbound webhooks. Verifies the X-Userz-Signature header in the format t=<ms>,v1=<hex-hmac> with replay protection.

TypeScript
import { verifyWebhookSignature } from '@userz-ai/node';
import express from 'express';

const app = express();

// Use raw body so the signature includes the exact bytes we sent.
app.post(
  '/userz-webhook',
  express.raw({ type: '*/*' }),
  (req, res) => {
    const sig = req.header('x-userz-signature') ?? '';
    const ok = verifyWebhookSignature({
      signingSecret: process.env.USERZ_WEBHOOK_SECRET!,
      body: req.body,
      signatureHeader: sig,
      // toleranceMs defaults to 5 minutes
    });
    if (!ok) return res.status(400).send('bad signature');
    // …handle event
    res.sendStatus(200);
  },
);

Input

NameTypeDescription
signingSecret*stringThe webhook subscription secret shown once in the dashboard at creation time. Distinct from the App signing secret.
body*Buffer | Uint8Array | stringThe raw request body. MUST be raw bytes, JSON-parsing first will break the signature.
signatureHeader*stringValue of the X-Userz-Signature header.
toleranceMsnumberAllowed clock skew in milliseconds. Default 300_000 (5 minutes).

Returns true on a valid signature, false on anything else (malformed header, stale timestamp, bad HMAC). Constant-time compare; no exception is thrown, pattern-match the boolean and return a 4xx.

Recipe: issue tokens on /me (Express)

Common pattern: piggyback on whatever endpoint your SPA already calls to fetch the current user.

TypeScript
app.get('/me', requireAuth, async (req, res) => {
  const userzToken = await mintUserToken({
    appId: env.USERZ_APP_ID,
    signingSecret: env.USERZ_APP_SIGNING_SECRET,
    sub: req.user.id,
    ctx: { email: req.user.email },
  });
  res.json({ user: req.user, userzToken });
});
TSX
// In your React app:
const { data } = useSWR('/me');
<UserzProvider
  publicKey="pub_..."
  getUserToken={() => data?.userzToken}
>...

Recipe: Next.js Route Handler

App Router server-side handler. Returns the token alongside the user payload.

TypeScript
// app/api/me/route.ts
import { NextResponse } from 'next/server';
import { mintUserToken } from '@userz-ai/node';
import { getSession } from '@/lib/auth';

export async function GET() {
  const session = await getSession();
  if (!session) return NextResponse.json({ user: null });

  const userzToken = await mintUserToken({
    appId: process.env.USERZ_APP_ID!,
    signingSecret: process.env.USERZ_APP_SIGNING_SECRET!,
    sub: session.user.id,
    ctx: { email: session.user.email },
  });

  return NextResponse.json({ user: session.user, userzToken });
}

Recipe: Hono / Cloudflare Workers

jose runs on Workers' WebCrypto. The same code works on Bun and Deno unchanged.

TypeScript
import { Hono } from 'hono';
import { mintUserToken } from '@userz-ai/node';

type Env = {
  Bindings: {
    USERZ_APP_ID: string;
    USERZ_APP_SIGNING_SECRET: string;
  };
};

const app = new Hono<Env>();

app.get('/me', async (c) => {
  const user = c.get('user'); // populated by your auth middleware
  if (!user) return c.json({ user: null });

  const userzToken = await mintUserToken({
    appId: c.env.USERZ_APP_ID,
    signingSecret: c.env.USERZ_APP_SIGNING_SECRET,
    sub: user.id,
    ctx: { email: user.email },
  });

  return c.json({ user, userzToken });
});

export default app;

Recipe: multi-environment Apps

Most teams keep separate Apps per environment so dev traffic doesn't pollute prod dashboards or trigger prod chat-ops. Pattern:

TypeScript
const APPS = {
  development: {
    appId: process.env.USERZ_APP_ID_DEV!,
    signingSecret: process.env.USERZ_APP_SIGNING_SECRET_DEV!,
  },
  production: {
    appId: process.env.USERZ_APP_ID_PROD!,
    signingSecret: process.env.USERZ_APP_SIGNING_SECRET_PROD!,
  },
} as const;

const app = APPS[process.env.NODE_ENV === 'production' ? 'production' : 'development'];

const token = await mintUserToken({ ...app, sub: user.id });

Mirror the same env split on the browser side, load the matching publicKey from your client config.

Recipe: webhook handler with Fastify

TypeScript
import Fastify from 'fastify';
import { verifyWebhookSignature } from '@userz-ai/node';

const fastify = Fastify();

fastify.addContentTypeParser(
  '*',
  { parseAs: 'buffer' },
  (_req, body, done) => done(null, body),
);

fastify.post('/userz-webhook', (req, reply) => {
  const ok = verifyWebhookSignature({
    signingSecret: process.env.USERZ_WEBHOOK_SECRET!,
    body: req.body as Buffer,
    signatureHeader: (req.headers['x-userz-signature'] as string) ?? '',
  });
  if (!ok) return reply.code(400).send('bad signature');

  const event = JSON.parse((req.body as Buffer).toString('utf8'));
  // …handle event
  reply.code(200).send();
});

JWT shape (for reference)

Inspect a minted token at jwt.io and you'll see:

JSON
{
  "alg": "HS256",
  "typ": "JWT"
}
{
  "sub": "user-id-in-your-system",
  "app": "65fa1f3e8a1e5f2d9c1a5c01",
  "ctx": { "email": "[email protected]" },
  "iat": 1761000000,
  "exp": 1761003600
}

The full server-side verification rules are documented under Private-mode tokens.

Bundle impact

~3 KB gzip after tree-shaking. jose is the only runtime dep. No native modules, works in any JS runtime that has SubtleCrypto.