@userz-ai/node
Server-side SDK for issuing private-mode user tokens and verifying outbound Userz webhooks.
Install
pnpm add @userz-ai/nodeRequires 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.
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
| Name | Type | Description |
|---|---|---|
| appId* | string | The 24-char hex App id from the dashboard. |
| signingSecret* | string | Uint8Array | The 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* | string | The end-user's id in your system. |
| ctx | Record<string, unknown> | Optional context attached as a `ctx` claim, surfaces in the feedback row's submitter.claims field. Limit: 2 KB JSON. |
Options
| Name | Type | Description |
|---|---|---|
| expiresInSeconds | number | TTL in seconds. Default 3600 (1 hour). Hard cap 86400 (24 hours). Minimum 60. |
Errors
| Name | Type | Description |
|---|---|---|
| Error | expiresInSeconds must be at least 60 | You 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.
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.
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
| Name | Type | Description |
|---|---|---|
| signingSecret* | string | The webhook subscription secret shown once in the dashboard at creation time. Distinct from the App signing secret. |
| body* | Buffer | Uint8Array | string | The raw request body. MUST be raw bytes, JSON-parsing first will break the signature. |
| signatureHeader* | string | Value of the X-Userz-Signature header. |
| toleranceMs | number | Allowed 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.
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 });
});// 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.
// 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.
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:
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
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:
{
"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.