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

  1. Sign in as an admin and open Admin → Apps. Create an app for the product if you haven't.
  2. Open Keys & widget on that app and copy the snippet — it already contains your key and slug.
  3. 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
      />
    </>
  );
}
Those env vars belong to your app, not to Lunax
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.

This is a convenience, not authentication
Lunax does not verify who is using the widget. Anyone can open dev tools, change the email, and file a ticket as someone else — and an account is created for whatever address they type. Never treat the email on a widget ticket as proof of identity. If you need that guarantee, use proxy mode and stamp the email server-side.

Test it

  1. Load a page with the script and click the Help button.
  2. Submit from the Support tab, then confirm it in /inbox.
  3. Submit from the Feedback tab and confirm it on the public board.
  4. 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

AttributeDefaultDescription
data-widget-keyYour publishable lx_w_ key. Alias: data-key.
data-appApp slug. Alias: data-slug. One of this or the key is required.
data-base-urlScript originOverride the Lunax origin. Set it explicitly if you serve widget.js from a CDN or proxy.
data-user-emailEmptyPre-fills the email field
data-user-nameEmptyPre-fills the name and hides that field
data-positionbottom-rightOnly bottom-left switches sides
data-themeautoauto, dark, or light. auto follows the OS and reacts to changes.
data-primary-color#5e6ad2Accent colour for the button and controls
data-labelHelpSubtitle text in the panel header
data-openfalseSet to true to open on load
data-proxy-urlEmptyRoute 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

LimitValue
Files per ticket5 — extras are dropped silently
Size per file50 MB
ImagesPNG, JPEG, WebP, GIF
VideoMP4, WebM, MOV
DocumentsPDF
WhereSupport 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.

Tickets arrive as multipart, feedback as JSON
The two paths differ because tickets can carry files. Parsing a ticket with 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 });
}
Proxy mode doesn't hide the widget key
The browser still fetches its config directly from Lunax with the key attached, and still sends the key to your proxy. That's fine — widget keys are publishable by design. The value of proxy mode is trustworthy identity and your own validation, not secrecy.

Security

ConsiderationWhere you stand
Widget key exposureExpected — it's publishable, and readable from your slug anyway
Secret keysNever put lx_live_ keys in browser code or NEXT_PUBLIC_ vars
User identityNot verified. Use proxy mode if you need it.
Allowed originsNone — widget endpoints accept requests from any site
Rate limiting8 tickets and 10 feedback posts per minute, per app, per IP
Turning it offDisable the widget on the app in Admin → Apps
Rate limiting is best-effort
Limits are counted in each server process's memory, so across several instances the real ceiling is higher, and it resets on deploy. It stops accidental loops, not a determined attacker. If an app is being abused, disable its widget.

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.