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

EndpointAuthBrowser-callable
POST /api/v1/ticketsSecret keyNo
POST /api/v1/feedbackSecret keyNo
POST /api/v1/widget/ticketsWidget key or slugYes
POST /api/v1/widget/feedbackWidget key or slugYes
GET /api/v1/widget/configWidget key or slugYes
Write-only, for now
Every endpoint creates something. There is no public API to read, list, update, or delete tickets and posts — the only way to work them is through the UI.

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.

Widget endpoints are effectively public
A valid app slug alone is enough to post — no key required. Anyone who can see your board URL can submit tickets and feedback to that app. Rate limits are the real control here. Never assume a widget-created ticket came from an authenticated person.

POST /api/v1/tickets

Create a ticket from your backend. Secret key required.

FieldRequiredNotes
subjectYesOne-line summary
descriptionYesBody text
requester_emailYesLowercased. An account is created if none exists.
requester_nameNoUsed only when creating a new account
priorityNolow | normal | high | urgent — defaults to normal
categoryNogeneral | 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"
}
Creating a ticket can create an account
An unknown 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.

FieldRequiredNotes
titleYesThe idea, in one line
descriptionNoDefaults to empty
categoryNofeedback | bug | feature | question — defaults to feedback
requester_emailNoAttributes 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.

Posts created this way are public immediately
They appear on the board straight away, attributed to a real person. Don't pipe raw internal tickets or error text through this endpoint.

POST /api/v1/widget/tickets

Browser-callable ticket creation. Accepts JSON or multipart/form-data — attachments require multipart.

FieldRequiredNotes
subjectYes
descriptionYes
requester_emailYesAlso accepted as `email`. Must contain @.
requester_nameNoAlso accepted as `name`
categoryNoDefaults to general
priorityNoDefaults to normal
appConditionalApp slug, if no widget key is sent
widget_keyConditionalAlternative to the header
filesNoMultipart 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.

A rejected attachment can leave the ticket behind
File validation runs after the ticket is created. If an upload is rejected you get a 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.

FieldRequiredNotes
titleYes
descriptionNoDefaults to empty
categoryNoDefaults to feedback
requester_emailNoAttributes the post; falls back to the first admin
app / widget_keyConditionalSame 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.

This endpoint returns the widget key for any known slug
No authentication is needed. That's consistent with the widget key being publishable, but it does mean the key offers no secrecy at all — anyone with your board URL can read it.

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.

StatusMeansCommon cause
400Bad requestMissing subject, description, title, or a valid email
400Bad valueAn invalid priority, category, or status value
400Bad fileUnsupported type or a file over 50 MB
401UnauthorizedMissing, malformed, or revoked key
401Invalid or disabled widgetUnknown slug or key, or the widget is off
404Not foundConfig requested for an unknown or disabled app
429Rate limitedWidget submission limit reached — see Retry-After
500Server errorUsually missing server configuration

Handle 429 by honouring the Retry-After header, in whole seconds, rather than retrying immediately.

Rate limits and CORS

EndpointLimitCORS
/api/v1/widget/tickets8 per minute, per app, per IPOpen to any origin
/api/v1/widget/feedback10 per minute, per app, per IPOpen to any origin
/api/v1/widget/configNoneOpen to any origin
/api/v1/ticketsNoneNot browser-callable
/api/v1/feedbackNoneNot 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.
Rate limiting is best-effort
Limits are counted in each server process's memory. Across multiple instances or on serverless, the effective limit is higher than the number suggests, and it resets on deploy. Don't rely on it as a security control — it's there to stop accidents, not determined abuse.