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
/api/shops/{id}/widget-tokenPOST /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 noexpiresAt. TTL is fixed at 24 hours from issuance. Compute expiration locally on receipt:expiresAt = now + 24h.
Cache strategy
Your backend must:
- Cache the token after issuing it, recording
issuedAt = nowlocally. - Rotate at 23 h since
issuedAt(1 h margin before the 24 h cutoff). - Protect with a lock the critical section to avoid concurrent issuance.
- (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
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/refreshendpoint 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
| Symptom | Cause | Fix |
|---|---|---|
401 Unauthorized on issuance | Invalid API Key or crossed environment (DEV vs PROD token) | Check the key and the base URL. |
403 Forbidden on issuance | API Key not authorized for shopId | Confirm the key belongs to the shop. |
429 Too Many Requests on issuance | Hitting the endpoint on every request | Implement cache with a 5 min margin. |
Frontend 401 X-Widget-Token | Expired or unprinted token | Re-render the page or regenerate the cache. |
Next steps
plugins/custom/server-to-server— cross-stack pattern.- Authentication · Widget Token — TTL and rotation.
- Recipe · Next.js + React — real-world case with App Router.