Developers
REST API reference
Create tickets and feedback posts from your own systems. Two families of endpoint: server endpoints authenticated with a secret key, and widget endpoints designed for the browser.
Overview
| Endpoint | Auth | Browser-callable |
|---|---|---|
POST /api/v1/tickets | Secret key | No |
POST /api/v1/feedback | Secret key | No |
POST /api/v1/widget/tickets | Widget key or slug | Yes |
POST /api/v1/widget/feedback | Widget key or slug | Yes |
GET /api/v1/widget/config | Widget key or slug | Yes |
Authentication
Server endpoints
Authorization: Bearer lx_live_xxxxxxxxxxxx
The key determines the app, so you never send an app ID. Revoked keys return 401. Mint keys under Admin → Apps → Keys & widget.
Widget endpoints
X-Lunax-Widget-Key: lx_w_xxxxxxxx
The key can also travel as a widget_key body field, or you can identify the app with an app field containing its slug.
POST /api/v1/tickets
Create a ticket from your backend. Secret key required.
| Field | Required | Notes |
|---|---|---|
| subject | Yes | One-line summary |
| description | Yes | Body text |
| requester_email | Yes | Lowercased. An account is created if none exists. |
| requester_name | No | Used only when creating a new account |
| priority | No | low | normal | high | urgent — defaults to normal |
| category | No | general | billing | bug | account | other — defaults to general |
curl -X POST https://YOUR_LUNAX_HOST/api/v1/tickets \
-H "Authorization: Bearer lx_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"subject": "Nightly export failed",
"description": "Job 4417 exited non-zero.",
"requester_email": "ops@acme.com",
"priority": "high",
"category": "bug"
}'Response — 201
{
"id": "0f8c…",
"app_id": "b21a…",
"number": 42,
"subject": "Nightly export failed",
"description": "Job 4417 exited non-zero.",
"status": "open",
"priority": "high",
"category": "bug",
"requester_id": "9d3e…",
"assignee_id": null,
"created_at": "2026-07-20T09:12:44.201Z",
"updated_at": "2026-07-20T09:12:44.201Z"
}requester_email gets a confirmed account so the ticket has an owner. A leaked secret key therefore lets someone create accounts and file tickets as anyone — treat it accordingly.Attachments are not supported on this endpoint. Files can only be attached through the portal or the widget.
POST /api/v1/feedback
Create a public feedback post. Secret key required.
| Field | Required | Notes |
|---|---|---|
| title | Yes | The idea, in one line |
| description | No | Defaults to empty |
| category | No | feedback | bug | feature | question — defaults to feedback |
| requester_email | No | Attributes the post. Falls back to the first admin. |
curl -X POST https://YOUR_LUNAX_HOST/api/v1/feedback \
-H "Authorization: Bearer lx_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"title": "Dark mode",
"description": "Follow the system theme.",
"category": "feature",
"requester_email": "jane@acme.com"
}'Returns 201 with the full post row. New posts always start at status open with zero votes.
POST /api/v1/widget/tickets
Browser-callable ticket creation. Accepts JSON or multipart/form-data — attachments require multipart.
| Field | Required | Notes |
|---|---|---|
| subject | Yes | — |
| description | Yes | — |
| requester_email | Yes | Also accepted as `email`. Must contain @. |
| requester_name | No | Also accepted as `name` |
| category | No | Defaults to general |
| priority | No | Defaults to normal |
| app | Conditional | App slug, if no widget key is sent |
| widget_key | Conditional | Alternative to the header |
| files | No | Multipart only. Up to 5, 50 MB each. |
Response — 201
{
"id": "0f8c…",
"number": 42,
"subject": "Cannot export CSV",
"status": "open",
"url": "https://YOUR_LUNAX_HOST/my/tickets/0f8c…",
"attachments": 2
}Accepted file types: PNG, JPEG, WebP, GIF, MP4, WebM, MOV, PDF, and plain text. Anything else is rejected.
400, but the ticket may already exist without its files. Validate size and type on your side before posting.POST /api/v1/widget/feedback
Browser-callable feedback creation. JSON only — sending multipart here fails with a parse error.
| Field | Required | Notes |
|---|---|---|
| title | Yes | — |
| description | No | Defaults to empty |
| category | No | Defaults to feedback |
| requester_email | No | Attributes the post; falls back to the first admin |
| app / widget_key | Conditional | Same resolution as widget tickets |
Response — 201
{
"id": "7c2b…",
"title": "Dark mode",
"status": "open",
"category": "feature",
"url": "https://YOUR_LUNAX_HOST/b/acme/p/7c2b…"
}Note this returns a different, smaller shape than the secret-key feedback endpoint, which returns the full row.
GET /api/v1/widget/config
Returns an app's public display details and links. The widget calls this on load; you only need it if you're building your own help UI.
curl "https://YOUR_LUNAX_HOST/api/v1/widget/config?app=acme"
{
"app": {
"id": "b21a…",
"slug": "acme",
"name": "Acme Analytics",
"description": "…",
"logo_url": "https://…"
},
"widget_key": "lx_w_xxxxxxxx",
"links": {
"board": "https://YOUR_LUNAX_HOST/b/acme",
"roadmap": "https://YOUR_LUNAX_HOST/b/acme/roadmap",
"changelog": "https://YOUR_LUNAX_HOST/b/acme/changelog",
"myTickets": "https://YOUR_LUNAX_HOST/my",
"newTicket": "https://YOUR_LUNAX_HOST/my/tickets/new?app=acme",
"login": "https://YOUR_LUNAX_HOST/login"
},
"features": { "tickets": true, "feedback": true }
}Cached for 60 seconds. Returns 404if the app doesn't exist or has the widget disabled.
The featuresflags are reserved and currently always true; don't branch on them.
Errors
Every error is JSON shaped { "error": "message" } with a matching HTTP status.
| Status | Means | Common cause |
|---|---|---|
| 400 | Bad request | Missing subject, description, title, or a valid email |
| 400 | Bad value | An invalid priority, category, or status value |
| 400 | Bad file | Unsupported type or a file over 50 MB |
| 401 | Unauthorized | Missing, malformed, or revoked key |
| 401 | Invalid or disabled widget | Unknown slug or key, or the widget is off |
| 404 | Not found | Config requested for an unknown or disabled app |
| 429 | Rate limited | Widget submission limit reached — see Retry-After |
| 500 | Server error | Usually missing server configuration |
Handle 429 by honouring the Retry-After header, in whole seconds, rather than retrying immediately.
Rate limits and CORS
| Endpoint | Limit | CORS |
|---|---|---|
/api/v1/widget/tickets | 8 per minute, per app, per IP | Open to any origin |
/api/v1/widget/feedback | 10 per minute, per app, per IP | Open to any origin |
/api/v1/widget/config | None | Open to any origin |
/api/v1/tickets | None | Not browser-callable |
/api/v1/feedback | None | Not browser-callable |
- The server endpoints send no CORS headers at all, so calling them from a browser fails at preflight. That's intentional — they need a secret key.
- Widget endpoints accept requests from any origin. There is no domain allow-list tying a key to your site.