Developers
Widget integration
Drop a floating Help button into any product. Users open tickets, post feedback, and reach your board without leaving your app. One script tag, no dependencies, no build step.
What you get
- A floating button, bottom-right by default, that opens a panel.
- Three tabs: Support (files a ticket), Feedback (posts to your public board), and More (links to the board, roadmap, changelog, and their tickets).
- Tickets land in your agent inbox; feedback lands on the app's public board.
- Screenshots and screen recordings attach directly from the Support tab.
- Follows your users' light or dark preference, or you can force either.
The widget is plain JavaScript with no dependencies. It styles itself in isolation, so it won't inherit or leak your app's CSS.
Install
- Sign in as an admin and open Admin → Apps. Create an app for the product if you haven't.
- Open Keys & widget on that app and copy the snippet — it already contains your key and slug.
- Paste it once into the layout that wraps your signed-in app.
<script src="https://YOUR_LUNAX_HOST/widget.js" data-widget-key="lx_w_xxxxxxxx" data-app="your-app-slug" data-user-email="user@example.com" data-user-name="Jane Doe" data-theme="auto" data-position="bottom-right" async ></script>
Next.js App Router
Render a raw <script> rather than next/script — depending on the version, next/script may drop data-* attributes, which silently breaks the widget.
// app/(app)/layout.tsx
export default function AppLayout({ children }) {
return (
<>
{children}
<script
src={`${process.env.NEXT_PUBLIC_LUNAX_URL}/widget.js`}
data-widget-key={process.env.NEXT_PUBLIC_LUNAX_WIDGET_KEY}
data-app={process.env.NEXT_PUBLIC_LUNAX_APP_SLUG}
async
/>
</>
);
}NEXT_PUBLIC_LUNAX_*are names for you to define in the product you're embedding into. Don't add them to the helpdesk's own environment.Identify the signed-in user
Pass the current user so they don't have to type their email, and so tickets attach to the right person.
<script
src="https://YOUR_LUNAX_HOST/widget.js"
data-widget-key="lx_w_xxxxxxxx"
data-app="your-app-slug"
data-user-email="jane@acme.com"
data-user-name="Jane Doe"
async
></script>
<!-- Or set it later, e.g. after your session resolves -->
<script>
window.LunaxHelpdesk?.setUser({
email: currentUser.email,
name: currentUser.name,
});
</script>Setting data-user-name hides the Name field entirely. Setting data-user-email only pre-fills the email — the user can still edit it.
Test it
- Load a page with the script and click the Help button.
- Submit from the Support tab, then confirm it in /inbox.
- Submit from the Feedback tab and confirm it on the public board.
- Check the confirmation email arrived, if Resend is configured.
There's also a live sandbox at /widget-demo that loads the widget against this host.
Script attributes
| Attribute | Default | Description |
|---|---|---|
data-widget-key | — | Your publishable lx_w_ key. Alias: data-key. |
data-app | — | App slug. Alias: data-slug. One of this or the key is required. |
data-base-url | Script origin | Override the Lunax origin. Set it explicitly if you serve widget.js from a CDN or proxy. |
data-user-email | Empty | Pre-fills the email field |
data-user-name | Empty | Pre-fills the name and hides that field |
data-position | bottom-right | Only bottom-left switches sides |
data-theme | auto | auto, dark, or light. auto follows the OS and reacts to changes. |
data-primary-color | #5e6ad2 | Accent colour for the button and controls |
data-label | Help | Subtitle text in the panel header |
data-open | false | Set to true to open on load |
data-proxy-url | Empty | Route submissions through your own backend |
If neither a key nor a slug is present, the widget logs a console error and renders nothing.
JavaScript API
Available on window.LunaxHelpdesk once the script has booted. Use it to open help from your own menu items or keyboard shortcuts.
window.LunaxHelpdesk.open()
window.LunaxHelpdesk.close()
window.LunaxHelpdesk.toggle()
window.LunaxHelpdesk.setUser({ email: "jane@acme.com", name: "Jane Doe" })The script is async, so guard with window.LunaxHelpdesk?.open() if you might call it during page load.
Attachments
| Limit | Value |
|---|---|
| Files per ticket | 5 — extras are dropped silently |
| Size per file | 50 MB |
| Images | PNG, JPEG, WebP, GIF |
| Video | MP4, WebM, MOV |
| Documents | |
| Where | Support tab only — the Feedback tab takes no files |
A screen recording of the bug is the single highest-value thing a user can send you, and the widget makes it a file picker away.
Proxy mode
Point the widget at your own backend instead of Lunax. Your server verifies the session and sets the requester email itself, so users can't file tickets as someone else. This is the only way to get trustworthy identity on widget tickets.
<script src="https://YOUR_LUNAX_HOST/widget.js" data-widget-key="lx_w_xxxxxxxx" data-app="your-app-slug" data-proxy-url="https://your-saas.com/api/help" async ></script>
The widget then posts to {proxy-url}/tickets and {proxy-url}/feedback.
request.json() will throw on every request — forward the form data instead.Ticket handler — multipart
// POST /api/help/tickets
export async function POST(req: Request) {
const user = await requireSession();
const form = await req.formData();
// Overwrite identity with the trusted session values
form.set("requester_email", user.email);
form.set("requester_name", user.name ?? "");
const res = await fetch(`${process.env.LUNAX_URL}/api/v1/widget/tickets`, {
method: "POST",
headers: { "X-Lunax-Widget-Key": process.env.LUNAX_WIDGET_KEY! },
body: form, // pass through — fetch sets the multipart boundary
});
return Response.json(await res.json(), { status: res.status });
}Feedback handler — JSON
// POST /api/help/feedback
export async function POST(req: Request) {
const user = await requireSession();
const body = await req.json();
const res = await fetch(`${process.env.LUNAX_URL}/api/v1/widget/feedback`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Lunax-Widget-Key": process.env.LUNAX_WIDGET_KEY!,
},
body: JSON.stringify({ ...body, requester_email: user.email }),
});
return Response.json(await res.json(), { status: res.status });
}Security
| Consideration | Where you stand |
|---|---|
| Widget key exposure | Expected — it's publishable, and readable from your slug anyway |
| Secret keys | Never put lx_live_ keys in browser code or NEXT_PUBLIC_ vars |
| User identity | Not verified. Use proxy mode if you need it. |
| Allowed origins | None — widget endpoints accept requests from any site |
| Rate limiting | 8 tickets and 10 feedback posts per minute, per app, per IP |
| Turning it off | Disable the widget on the app in Admin → Apps |
Troubleshooting
Nothing renders
Check the console. Missing both data-widget-key and data-app logs an error and stops. If you used next/script, switch to a raw tag — the attributes may not have been forwarded.
401 “Invalid or disabled widget”
The key or slug is wrong, or the app has the widget disabled. Copy a fresh snippet from the keys page.
429 “Too many requests”
You hit the per-minute limit. The response includes Retry-After in seconds.
Tickets arrive from the wrong person
Expected without proxy mode — the email field is user-editable. Switch to proxy mode and stamp the email server-side.
Submissions fail only in production
Usually a stale data-base-url, or NEXT_PUBLIC_APP_URL not set on the helpdesk, so returned links point at localhost.