Skip to main content

Recipe: Generate Widget Tokens server-to-server

This is the critical piece of any custom integration: the client does not sign the token, it requests it server-to-server with the Shop API Key and hands it to the frontend on every render. The token lives 24 hours and rotates at will.

Estimated time: 15-25 min the first time.

Why this pattern?

  • The Shop API Key (sk_…) never reaches the browser. It only lives in your backend.
  • The Widget Token is opaque (not a JWT). Neuroon issues it, the widget consumes it in the browser.
  • If a key leaks, rotate the key in the dashboard and active tokens stay alive until their natural TTL.

Clarification: earlier documentation described a flow with HMAC signed by the client. It is not real. See Authentication · Widget Token for the official note.

The endpoint

POST/api/shops/{id}/widget-token
POST /api/shops/shop_xxxxxxxx/widget-token HTTP/1.1
Host: dev-api.neuroon.ai
X-Shop-API-Key: sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
User-Agent: my-app/1.0

Response:

{
"token": "wt_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
}

The response only contains token — there is no expiresAt. TTL is fixed at 24 hours from issuance. Compute expiration locally on receipt: expiresAt = now + 24h.

Cache strategy

Your backend must:

  1. Cache the token after issuing it, recording issuedAt = now locally.
  2. Rotate at 23 h since issuedAt (1 h margin before the 24 h cutoff).
  3. Protect with a lock the critical section to avoid concurrent issuance.
  4. (Optional) Distribute the cache across instances (Redis, KV) if you run on serverless or multi-pod.

This lowers the pressure on the Neuroon endpoint and ensures the token never expires mid-render.

Reference implementations

neuroon-token.js
import fetch from 'node-fetch';

const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;     // fixed 24 h
const ROTATE_AFTER_MS = 23 * 60 * 60 * 1000;  // rotate at 23 h

let cache = null; // { token, issuedAt: epoch ms }

async function fetchToken() {
const r = await fetch(
  `${process.env.NEUROON_API_URL}/api/shops/${process.env.NEUROON_SHOP_ID}/widget-token`,
  {
    method: 'POST',
    headers: {
      'X-Shop-API-Key': process.env.NEUROON_API_KEY,
      'User-Agent': 'my-app/1.0',
    },
  },
);
if (!r.ok) throw new Error(`Token issuance failed: ${r.status}`);
const body = await r.json();
return { token: body.token, issuedAt: Date.now() };
}

export async function getWidgetToken() {
const now = Date.now();
if (cache && now - cache.issuedAt < ROTATE_AFTER_MS) return cache.token;
cache = await fetchToken();
return cache.token;
}

// Express endpoint that the frontend consumes
import express from 'express';
const router = express.Router();
router.get('/internal/neuroon-token', async (_req, res) => {
try {
  res.json({ token: await getWidgetToken() });
} catch (e) {
  res.status(502).json({ error: e.message });
}
});
export default router;

Distributed variant with Redis

In serverless environments (Vercel, Lambda, our cloud) local memory is lost between cold starts. Use Redis as a shared cache:

import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);

const ROTATE_AFTER_S = 23 * 60 * 60; // TTL fixed at 24 h; rotate at 23 h

async function getWidgetTokenShared() {
const cached = await redis.get('neuroon:widget-token');
if (cached) {
const { token, issuedAt } = JSON.parse(cached);
if (Date.now() / 1000 - issuedAt < ROTATE_AFTER_S) return token;
}

// Distributed lock (SET NX EX) to avoid thundering herd
const ok = await redis.set('neuroon:widget-token:lock', '1', 'NX', 'EX', 30);
if (!ok) {
// Another instance is rotating: wait 200ms and retry the cache
await new Promise((r) => setTimeout(r, 200));
return getWidgetTokenShared();
}

try {
const { token } = await fetchToken();
const issuedAt = Math.floor(Date.now() / 1000);
// EX = 23 h: the entry expires exactly when it should rotate
await redis.set('neuroon:widget-token', JSON.stringify({ token, issuedAt }), 'EX', ROTATE_AFTER_S);
return token;
} finally {
await redis.del('neuroon:widget-token:lock');
}
}

Token rotation

If you invalidate the token cold (after an incident, credential rotation), regenerate by invoking the same endpoint POST /api/shops/{shopId}/widget-token. Each call issues a new token. There is no instant revocation: the previous token stays valid until it expires by TTL (24 h, application.yml:271). Just stop using it on your servers and start serving the new one.

The POST /api/tokens/refresh endpoint shown in the OpenAPI reference does not rotate Widget Tokens — it refreshes user JWTs for the Neuroon dashboard (TokenController.java:36).

Frontend injection

Once you have the token cached, your frontend receives it via:

  • SSR / RSC: the HTML prints data-token="..." at render time.
  • SPA: your private API (GET /internal/neuroon-token) returns it and an effect injects it into the CDN <script>.

Full pattern in plugins/custom/standalone and plugins/custom/nextjs.

Validate

# Smoke test: issue a token
curl -s -X POST "https://dev-api.neuroon.ai/api/shops/$NEUROON_SHOP_ID/widget-token" \
-H "X-Shop-API-Key: $NEUROON_API_KEY" -H "User-Agent: smoke-test/1.0"

# Test the token against widget search
curl -s "https://dev-api.neuroon.ai/api/widget/search?q=t-shirt&limit=3" \
-H "X-Widget-Token: wt_zzzzzzzzzzzz..."

Common errors

SymptomCauseFix
401 Unauthorized on issuanceInvalid API Key or crossed environment (DEV vs PROD token)Check the key and the base URL.
403 Forbidden on issuanceAPI Key not authorized for shopIdConfirm the key belongs to the shop.
429 Too Many Requests on issuanceHitting the endpoint on every requestImplement cache with a 5 min margin.
Frontend 401 X-Widget-TokenExpired or unprinted tokenRe-render the page or regenerate the cache.

Next steps