Open the dashboard
Luna · Internal feedback · v1

Luna Feedback API

Fetch the form definition, render it, post the tester's answer. Everything the front end needs to integrate the Stage-only feedback prompt.

Base URL
https://luna-feedback.buildsage.tech
Auth
x-api-key · shared out of band
Schema version
1
Format
JSON · UTF-8
01

Overview

The API does three things for the client.

  1. Describes the form. GET /v1/feedback/schema returns every feature (Home, Sleep, Activity, Workout, Apps, Other), its issue categories, and the extra fields to render. Build the form from this response rather than hard-coding it, so categories can be added or renamed on the server without an app release.
  2. Accepts submissions. POST /v1/feedback/{feature} validates the payload against the same schema and stores it.
  3. Reads submissions back. GET /v1/feedback and /stats power the dashboard; the app does not need them.

Environments

environmentbase URLnotes
Productionhttps://luna-feedback.buildsage.techsame deployment as luna-internal-feedback.vercel.app
Localhttp://localhost:3000npm run dev in the backend repo

Authentication

Send the shared app key on every request under /v1:

x-api-key: <APP_API_KEY>
Stage only

The key is distributed out of band by the backend owner. Compile the feedback module out of Release builds; never ship the key in a public binary.

While integrating

Send "is_test": true on every submission from development and integration runs so it can be separated from real feedback. Details in Test submissions.

GET /healthz, /docs, and /dashboard are the only unauthenticated routes. Admin routes under /v1/admin need a separate key (x-admin-key) and are not for the app.

The CX team's tool has its own key (CX_API_KEY, also sent as x-api-key). It files customer problems through /v1/cx/* and can read the form schema, nothing else. The app key cannot file CX reports.

02

Quick start

Three calls, in order.

# 1. Fetch the form definition (cache it; see ETag)
curl https://luna-feedback.buildsage.tech/v1/feedback/schema \
  -H "x-api-key: $APP_API_KEY"

# 2. Render the form for the feature the user is on (client side)

# 3. Submit  (while integrating, keep "is_test": true — see Test submissions)
curl -X POST https://luna-feedback.buildsage.tech/v1/feedback/sleep \
  -H "x-api-key: $APP_API_KEY" \
  -H "content-type: application/json" \
  -H "Idempotency-Key: 2E5C1D1C-6D6B-4B3E-9C6A-3F1E8B7A5D42" \
  -d '{
    "is_test": true,
    "is_positive": false,
    "occurred_on": "2026-09-01",
    "user_id": 10482,
    "email": "tester@luna.app",
    "issue_categories": ["incorrect_sleep", "vitals_not_recorded"],
    "feedback_text": "Ring said 4h, I slept 7h.",
    "device_serial": "R2N08250600302",
    "details": { "actual_start_time": "11:30 PM", "actual_end_time": "06:45 AM" },
    "client": { "platform": "ios", "app_version": "2.4.0", "build_number": "512", "build_channel": "stage",
                "firmware_version": "1.9.2", "os_version": "iOS 19.1" }
  }'
03

Conventions

Request headers

headerrequiredpurpose
x-api-keyyesapp key, all /v1 routes
content-type: application/jsonon POSTbody encoding
Idempotency-Keyrecommendedany string ≤ 200 chars, unique per submission attempt. Reuse the same value when retrying.
If-None-Matchoptionalsend the ETag from a previous schema response to get 304 Not Modified

Data formats

kindwire formatrules
date (occurred_on)"2026-09-01"YYYY-MM-DD, a real calendar date, not after today in IST
12-hour time (time_12h)"10:45 PM" · "7:05 am"H:MM AM/PM or HH:MM AM/PM, space before the meridiem, case-insensitive. Stored and returned as "07:05 AM"
timestamps (responses)created_at "2026-09-02T08:20:23.521Z"
created_at_ist "2026-09-02 13:50:23 +05:30"
created_at is ISO 8601 UTC; created_at_ist is the same instant in Asia/Kolkata for display
user_id10482JSON number, positive integer
email"tester@luna.app"valid email, ≤ 254 chars, stored as given
issue_categories["incorrect_sleep"]array of category keys (not labels), at least one, no duplicates, only keys the schema lists for that feature
ids"178239d2-…"UUID

Error envelope

Every non-2xx response has this shape:

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Request failed validation",
    "issues": [
      { "path": "details.peak_score_value", "message": "must be <= 100" },
      { "path": "issue_categories.0", "message": "unknown category \"incorrect_sleep\"" }
    ]
  }
}

issues is present only for VALIDATION_FAILED. path is dot-separated and matches the request body, so it maps straight back to a form field. Array elements are indexed (issue_categories.0). (body) means the whole body was unparseable.

Status codes

statuscodewhen
200read succeeded, or a POST whose Idempotency-Key was already seen (returns the original row)
201POST created a new submission
304schema unchanged since the If-None-Match ETag
400VALIDATION_FAILEDbody is not valid JSON
401UNAUTHORIZEDmissing or wrong x-api-key
404NOT_FOUNDunknown route, unknown or deactivated feature, unknown id
409CONFLICTadmin only: duplicate category key
422VALIDATION_FAILEDbody parsed but failed field validation; see issues
500INTERNALserver fault; safe to retry with the same Idempotency-Key

Idempotency and retries

Network on internal test devices is unreliable. To make retries safe:

  1. Generate a UUID when the user taps Submit, and keep it with the queued payload.
  2. Send it as Idempotency-Key on every attempt of that submission.
  3. 201 means stored now. 200 means an earlier attempt already stored it; the body is the original row. Treat both as success.
  4. Retry on 5xx, timeouts, and connection failures. Do not retry 4xx; surface the issues to the user instead.

Caching the schema

Schema responses carry a weak ETag and Cache-Control: no-cache. Fetch on app launch or before first showing the form, send If-None-Match with the stored ETag, and keep the last good copy on disk so the form can render offline. A 304 means the cached copy is current.

Limits

limitvalue
request body64 KB
feedback_text500 characters
issue_categories≥ 1 item
list page size1–200, default 50
03b

Test submissions

Real tester feedback and integration traffic share one database. The is_test flag keeps them apart, so you can hit production freely while building the feature.

How to set it

Add one boolean at the top level of the POST body (not inside details or client):

{
  "is_test": true,
  "is_positive": false,
  "occurred_on": "2026-09-01",
  …
}

Omit it, or send false, in the build that real testers use. It defaults to false.

Positive feedback is one tap

When is_positive is true, only user_id and email are required. Skip the category picker and the date; the server stores issue_categories: [] and today's date:

{ "is_positive": true, "user_id": 10482, "email": "tester@luna.app", "client": { "platform": "ios", "app_version": "2.4.0" } }

What it does

  • The row is stored exactly like real feedback and comes back with "is_test": true.
  • The dashboard tags it TEST and can show real only, test only, or both.
  • Test rows can be bulk-deleted later from the dashboard (or DELETE /v1/admin/test-data?confirm=delete). Real feedback is never touched by that action.
  • Listing and stats accept ?is_test=true|false if you want to check your own test rows: GET /v1/feedback?is_test=true&user_id=<your id>.
Suggested wiring

Bind it to the build channel: is_test = buildChannel != "stage" for local and CI builds, or expose it as a hidden developer toggle. A submission that arrives without the flag is treated as real.

03c

Screenshots

A submission can carry up to five screenshots. Images are hosted on ImageKit; the app uploads them directly to ImageKit with a short-lived signature from this API, then references them in the feedback POST. Files never pass through this server, so there is no size limit beyond ImageKit's own and the 8 MB cap below.

1. Get upload credentials

GET /v1/uploads/screenshot-auth
x-api-key: <APP_API_KEY>

{
  "upload_url": "https://upload.imagekit.io/api/v1/files/upload",
  "public_key": "public_…",
  "token": "3b0f…",            // single use
  "expire": 1789124567,        // unix seconds, ~15 min ahead
  "signature": "a94a8f…",      // HMAC of token+expire, computed server-side
  "folder": "/luna-feedback-screenshots",
  "use_unique_file_name": true,
  "tags": ["luna-feedback"],
  "transformation": { "pre": "w-1600,h-1600,c-at_max,q-80" },   // forward as-is; ImageKit resizes before storing
  "max_bytes": 8388608, "max_count": 5,
  "accepted_types": ["image/jpeg", "image/png", "image/heic", "image/webp"],
  "client_resize": { "max_dimension": 1600, "jpeg_quality": 0.8, "target_bytes": 512000, "note": "…" },
  "url_endpoint": "https://ik.imagekit.io/noisekaranikid"
}
Resize on the phone first

Upload speed on mobile networks is dominated by file size, so downscale before uploading: longest side ≤ 1600 px, JPEG at quality ~0.8 (typically 150–500 KB). Convert HEIC and camera photos to JPEG on device (UIImage.jpegData(compressionQuality: 0.8) after resizing; Bitmap.compress(JPEG, 80) on Android). The server-side transformation.pre is a safety net that caps what gets stored, not a substitute: a 12 MP original still has to travel over the network.

Each call returns a fresh token; request one per image. The schema response also advertises this under uploads.screenshots (with enabled: false when the server has no ImageKit keys).

2. Upload to ImageKit

Multipart POST to upload_url with these fields, all as plain form fields except file:

fieldvalue
filethe image bytes (JPEG preferred; PNG, HEIC, WebP accepted), ≤ 8 MB, resized on device first
fileNamee.g. home_2026-09-17.png
publicKey, token, expire, signaturecopied from step 1
foldercopied from step 1
useUniqueFileNametrue
tagsluna-feedback
transformationthe transformation object from step 1, JSON-encoded, e.g. {"pre":"w-1600,h-1600,c-at_max,q-80"}

ImageKit answers with JSON including fileId, url, thumbnailUrl, name, width, height, size (these describe the stored file, after the storage cap). Keep fileId and url, and also record the byte size and pixel dimensions of the file you sent as upload_size, original_width, original_height.

3. Reference them in the feedback POST

{
  "is_positive": false, "occurred_on": "2026-09-17", "user_id": 10482, "email": "tester@luna.app",
  "issue_categories": ["peak_score_not_loaded"],
  "screenshots": [
    { "file_id": "68c9f1e2…", "url": "https://ik.imagekit.io/noisekaranikid/luna-feedback-screenshots/home_2026-09-17_abc123.png",
      "name": "home_2026-09-17_abc123.png", "width": 739, "height": 1600, "size": 73912,
      "upload_size": 318204, "original_width": 1170, "original_height": 2532 }
  ]
}

The response echoes screenshots. The dashboard shows them on the submission page, and the AI diagnosis reads the first two alongside the logs. Screenshots on test submissions are deleted from ImageKit together with the test data.

Do not

Do not embed base64 images in the feedback body (64 KB body limit), and do not send URLs from any other host; both are rejected with 422.

04

Rendering a form from the schema

The schema is a list of field definitions. Each has key (JSON key to send), label, type, required, and an optional help hint. Some common fields also carry requiredIf: { "field", "equals" }, which overrides required: the field is mandatory only while field equals equals. Today issue_categories and occurred_on carry { "field": "is_positive", "equals": false }, so a positive-feedback form can hide both. Type-specific properties:

typeextra propertiesrender assend as
booleantoggle / segmented controltrue / false
textmaxLength, multilinetext area with a counterstring
dateformat, allowFuture (absent = false)date picker, max = today"YYYY-MM-DD"
numbermin, max, integer, unitnumeric keypad; stepper if integerJSON number
time_12hformattime picker in 12-hour mode"HH:MM AM/PM"
stringmaxLength, format (email), options, optionLabelstext field; a dropdown if options is present, showing optionLabels[value] when givenstring (the option key)
multi_selectminItems, optionsFrommulti-select chips from the feature's issue_categoriesarray of option keys

Where fields live in the request body. common_fields go at the top level. features[].fields go inside details. The client block takes the fields listed in client_context_fields (same shape as any other field definition; platform carries options).

Rules. features[].rules lists cross-field constraints the server enforces. There is one kind today:

{ "kind": "time_pair_distinct", "start": "actual_start_time", "end": "actual_end_time" }

If both fields are filled they must not be identical. There is no ordering check, because sleep and workouts can cross midnight.

Forward compatibility

Skip any field whose type the client doesn't recognise and ignore unknown properties. The server rejects unknown keys in the body, so only send keys the schema lists. schema_version increments on breaking changes.

05

Endpoints

GET/healthzno auth

Returns { "ok": true, "db": "up" }, or 503 with { "ok": false, "db": "down" }.


GET/v1/feedback/schemax-api-key

Everything needed to build the form for every active feature. Features come back in display order; deactivated features are omitted. Response headers: ETag, Cache-Control: no-cache.

Response 200 (live, verbatim)
{
  "schema_version": 1,
  "common_fields": [
    { "key": "is_positive",      "type": "boolean",      "label": "Was this a positive experience?", "required": true },
    { "key": "occurred_on",      "type": "date",         "format": "YYYY-MM-DD", "label": "Date the issue occurred", "required": true,
      "requiredIf": { "field": "is_positive", "equals": false }, "help": "Required for issues. Optional for positive feedback; defaults to today (IST)." },
    { "key": "user_id",          "type": "number",       "label": "User ID", "required": true, "integer": true, "min": 1 },
    { "key": "email",            "type": "string",       "format": "email", "label": "Email", "required": true, "maxLength": 254 },
    { "key": "issue_categories", "type": "multi_select", "label": "What went wrong?", "required": true, "minItems": 1, "optionsFrom": "issue_categories",
      "requiredIf": { "field": "is_positive", "equals": false }, "help": "Required for issues. Omit (or send []) for positive feedback." },
    { "key": "feedback_text",    "type": "text",         "label": "Tell us more", "required": false, "maxLength": 500, "multiline": true },
    { "key": "device_serial",    "type": "string",       "label": "Ring or band serial number", "required": false, "maxLength": 64,
      "help": "Fill automatically from the connected ring or band, e.g. R2N08250600302. Not typed by the tester. Used to fetch device logs; email is the fallback." }
  ],
  "client_context_keys": ["environment", "platform", "app_version", "build_number", "build_channel", "firmware_version", "os_version", "device_id", "session_id"],
  "client_context_fields": [
    { "key": "platform",         "type": "string", "label": "Platform",              "required": false, "options": ["ios", "android", "backend", "firmware"],
      "optionLabels": { "ios": "iOS", "android": "Android", "backend": "Backend", "firmware": "Firmware" },
      "help": "The phone the app runs on (ios, android), or backend / firmware for an issue that lives there rather than in the app." },
    { "key": "app_version",      "type": "string", "label": "App version",           "required": false, "maxLength": 200 },
    { "key": "build_number",     "type": "string", "label": "Build number",          "required": false, "maxLength": 200 },
    { "key": "build_channel",    "type": "string", "label": "Build channel",         "required": false, "maxLength": 200 },
    { "key": "firmware_version", "type": "string", "label": "Ring firmware version", "required": false, "maxLength": 200 },
    { "key": "os_version",       "type": "string", "label": "OS version",            "required": false, "maxLength": 200 },
    { "key": "device_id",        "type": "string", "label": "Device ID",             "required": false, "maxLength": 200 },
    { "key": "session_id",       "type": "string", "label": "Session ID",            "required": false, "maxLength": 200 }
  ],
  "features": [
    {
      "key": "home", "label": "Home",
      "issue_categories": [
        { "key": "wrong_peak_score",      "label": "Wrong peak score" },
        { "key": "peak_score_not_loaded", "label": "Peak score not loaded" },
        { "key": "guidance_incorrect",    "label": "Guidance incorrect" },
        { "key": "something_else",        "label": "Other" }
      ],
      "fields": [
        { "key": "peak_score_value", "type": "number", "label": "Peak score shown", "required": false, "min": 0, "max": 100 }
      ],
      "rules": []
    },
    {
      "key": "sleep", "label": "Sleep",
      "issue_categories": [
        { "key": "incorrect_sleep",       "label": "Incorrect sleep" },
        { "key": "sleep_not_recorded",    "label": "Sleep not recorded" },
        { "key": "incorrect_sleep_stage", "label": "Incorrect sleep stage" },
        { "key": "vitals_not_recorded",   "label": "Vitals not recorded" },
        { "key": "something_else",        "label": "Other" }
      ],
      "fields": [
        { "key": "actual_start_time",   "type": "time_12h", "format": "HH:MM AM/PM", "label": "Actual sleep start",   "required": false },
        { "key": "actual_end_time",     "type": "time_12h", "format": "HH:MM AM/PM", "label": "Actual sleep end",     "required": false },
        { "key": "recorded_start_time", "type": "time_12h", "format": "HH:MM AM/PM", "label": "Recorded sleep start", "required": false },
        { "key": "recorded_end_time",   "type": "time_12h", "format": "HH:MM AM/PM", "label": "Recorded sleep end",   "required": false }
      ],
      "rules": [
        { "kind": "time_pair_distinct", "start": "actual_start_time",   "end": "actual_end_time" },
        { "kind": "time_pair_distinct", "start": "recorded_start_time", "end": "recorded_end_time" }
      ]
    },
    {
      "key": "activity", "label": "Activity",
      "issue_categories": [
        { "key": "incorrect_steps",           "label": "Incorrect steps" },
        { "key": "incorrect_total_calories",  "label": "Incorrect total calories" },
        { "key": "incorrect_active_calories", "label": "Incorrect active calories" },
        { "key": "incorrect_training_load",   "label": "Incorrect training load" },
        { "key": "workout_not_showing",       "label": "Workout not showing" },
        { "key": "something_else",            "label": "Other" }
      ],
      "fields": [
        { "key": "steps",           "type": "number", "label": "Steps",           "required": false, "integer": true, "min": 0 },
        { "key": "active_calories", "type": "number", "label": "Active calories", "required": false, "min": 0, "unit": "kcal" },
        { "key": "total_calories",  "type": "number", "label": "Total calories",  "required": false, "min": 0, "unit": "kcal" }
      ],
      "rules": []
    },
    {
      "key": "workout", "label": "Workout",
      "issue_categories": [
        { "key": "incorrect_duration",  "label": "Incorrect duration" },
        { "key": "incorrect_calories",  "label": "Incorrect calories" },
        { "key": "hr_not_showing",      "label": "HR not showing" },
        { "key": "map_not_loading",     "label": "Map not loading" },
        { "key": "incorrect_zones",     "label": "Incorrect zones" },
        { "key": "incorrect_intensity", "label": "Incorrect intensity" },
        { "key": "start_workout_fail",  "label": "Start workout fail" },
        { "key": "end_workout_fail",    "label": "End workout fail" },
        { "key": "something_else",      "label": "Other" }
      ],
      "fields": [
        { "key": "workout_type", "type": "string",   "label": "Workout type",  "required": false, "maxLength": 100 },
        { "key": "start_time",   "type": "time_12h", "format": "HH:MM AM/PM", "label": "Workout start", "required": false },
        { "key": "end_time",     "type": "time_12h", "format": "HH:MM AM/PM", "label": "Workout end",   "required": false },
        { "key": "intensity",    "type": "string",   "label": "Intensity",     "required": false, "maxLength": 50 }
      ],
      "rules": [
        { "kind": "time_pair_distinct", "start": "start_time", "end": "end_time" }
      ]
    },
    {
      "key": "apps", "label": "Apps",
      "issue_categories": [
        { "key": "app_not_loading",  "label": "App not loading" },
        { "key": "app_crashed",      "label": "App crashed or froze" },
        { "key": "wrong_data",       "label": "Wrong data or numbers" },
        { "key": "data_missing",     "label": "Data missing" },
        { "key": "cannot_log_entry", "label": "Could not log or save an entry" },
        { "key": "plan_or_guidance", "label": "Plan or guidance looks wrong" },
        { "key": "reminders",        "label": "Reminders or notifications" },
        { "key": "display_glitch",   "label": "Display or UI glitch" },
        { "key": "something_else",   "label": "Other" }
      ],
      "fields": [
        { "key": "app", "type": "string", "label": "Which app?", "required": true,
          "options": ["cycle_tracker", "calorie_tracker", "circadian", "luna_age", "vo2_max", "sleep_planner", "supplement_planner", "hydration", "caffeine_window", "jet_lag_planner", "pregnancy_tracker", "hyrox_training", "running_trainer"],
          "optionLabels": { "cycle_tracker": "Cycle tracker", "calorie_tracker": "Calorie tracker", "circadian": "Circadian", "luna_age": "Luna Age", "vo2_max": "VO2 max", "sleep_planner": "Sleep planner", "supplement_planner": "Supplement planner", "hydration": "Hydration", "caffeine_window": "Caffeine window", "jet_lag_planner": "Jet lag planner", "pregnancy_tracker": "Pregnancy tracker", "hyrox_training": "Hyrox training", "running_trainer": "Running trainer" },
          "help": "The app inside Luna you were using when it happened." }
      ],
      "rules": []
    },
    {
      "key": "other", "label": "Other",
      "issue_categories": [
        { "key": "app_crash",         "label": "App crashed" },
        { "key": "app_slow_or_froze", "label": "App slow or froze" },
        { "key": "login_or_signup",   "label": "Login or sign-up problem" },
        { "key": "ring_pairing_sync", "label": "Ring pairing or sync" },
        { "key": "battery_drain",     "label": "Battery drain" },
        { "key": "notifications",     "label": "Notifications" },
        { "key": "display_glitch",    "label": "Display or UI glitch" },
        { "key": "data_missing",      "label": "Data missing" },
        { "key": "something_else",    "label": "Something else" }
      ],
      "fields": [
        { "key": "screen", "type": "string", "label": "Where in the app did it happen?", "required": false, "maxLength": 100 }
      ],
      "rules": []
    }
  ]
}

GET/v1/feedback/schema/{feature}x-api-key

One feature only: a single element of features[] above (key, label, issue_categories, fields, rules). It does not include common_fields; fetch the full schema for those. 404 if the feature is unknown or deactivated.


POST/v1/feedback/{feature}x-api-key

Create a submission. {feature} is one of home, sleep, activity, workout, apps, other. Use apps for Luna's mini apps (Cycle tracker, Hydration, …) and send which one in details.app. Use other for anything that does not belong to one screen (crashes, login, pairing, battery, and so on).

Request body

fieldtyperequirednotes
is_positivebooleanyes
occurred_ondateissuesnot in the future (IST). Required when is_positive is false; for positive feedback it may be omitted and defaults to today (IST)
user_idinteger ≥ 1yes
emailemail stringyes
issue_categoriesstring[]issuescategory keys for this feature, ≥ 1, unique. Required when is_positive is false; for positive feedback omit it or send []
feedback_textstring ≤ 500nofree text
device_serialstring ≤ 64nosend whenever a ring or band is connected, e.g. "R2N08250600302". Read it from the SDK; never ask the tester to type it. The backend uses it to fetch that device's logs for AI diagnosis and falls back to email when absent
detailsobjectnofeature fields, see Feature reference. Unknown keys are rejected. Defaults to {}
clientobjectnoany subset of the client context keys. Unknown keys are rejected
screenshotsobject[]noup to 5 images uploaded to ImageKit first; each { file_id, url, name?, width?, height?, size?, thumbnail_url?, upload_size?, original_width?, original_height? }: the first seven as returned by the upload, plus the bytes and dimensions the app actually sent so the dashboard can show that on-device downsizing happened. Only URLs under our ImageKit account are accepted. See Screenshots
is_testbooleannodefault false. Send true from integration runs and test builds; see Test submissions
sprint"current" · "next"noissues only: files the issue into the current or the next QA sprint instead of the main list. Leave it out (or null) for the main list. Refused on positive feedback and on CX reports. Labels for the two choices: GET /v1/sprints/options

Optional fields may be omitted or sent as null. Whitespace is trimmed from strings.

Device serial matters

When a ring or band is connected, always send its serial in device_serial. Log lookup by serial is exact; lookup by email can return several devices for testers who use more than one phone. This field is common to every feature and is filled by the app, not the tester.

Example request

POST /v1/feedback/workout HTTP/1.1
Host: luna-feedback.buildsage.tech
x-api-key: <APP_API_KEY>
content-type: application/json
Idempotency-Key: 9B2E6D3A-0C41-4E0F-8F1B-7D2A5C9E4B11

{
  "is_test": true,
  "is_positive": false,
  "occurred_on": "2026-09-01",
  "user_id": 10482,
  "email": "tester@luna.app",
  "issue_categories": ["hr_not_showing", "incorrect_zones"],
  "feedback_text": "HR stayed at -- for the whole run.",
  "device_serial": "R2N08250600302",
  "details": {
    "workout_type": "Outdoor Run",
    "start_time": "6:00 AM",
    "end_time": "6:45 AM",
    "intensity": "High"
  },
  "client": {
    "platform": "ios",
    "app_version": "2.4.0",
    "build_number": "512",
    "build_channel": "stage",
    "firmware_version": "1.9.2",
    "os_version": "iOS 19.1",
    "device_id": "3F2B0C7A-1D2E-4F5A-9B8C-7D6E5F4A3B2C",
    "session_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
  }
}

Response 201 Created (live, verbatim)

{
  "id": "178239d2-2581-4207-a378-742b0ac186ef",
  "ref": "LN-00042",
  "feature_key": "workout",
  "is_positive": false,
  "occurred_on": "2026-09-01",
  "user_id": 10482,
  "email": "tester@luna.app",
  "issue_categories": ["hr_not_showing", "incorrect_zones"],
  "created_at": "2026-09-02T08:20:23.521Z",
  "feedback_text": "HR stayed at -- for the whole run.",
  "device_serial": "R2N08250600302",
  "details": {
    "workout_type": "Outdoor Run",
    "start_time": "06:00 AM",
    "end_time": "06:45 AM",
    "intensity": "High"
  },
  "platform": "ios",
  "app_version": "2.4.0",
  "build_number": "512",
  "build_channel": "stage",
  "firmware_version": "1.9.2",
  "os_version": "iOS 19.1",
  "device_id": "3F2B0C7A-1D2E-4F5A-9B8C-7D6E5F4A3B2C",
  "session_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "schema_version": 1,
  "is_test": true,
  "created_at_ist": "2026-09-02 13:50:23 +05:30"
}

Times come back zero-padded, and the client block is flattened to top-level fields. Client keys not sent come back as null.

ref is the report's readable reference (LN-00042). Show it to the tester after they submit ("Thanks — your reference is LN-00042") so they can quote it; everyone on the dashboard finds the report by it. Responses also carry origin (internal for everything the app sends) and status (triage state).

Response 200 OK: same body, returned when the Idempotency-Key was already used. Treat as success.

Response 422

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Request failed validation",
    "issues": [
      { "path": "details.end_time", "message": "must differ from start_time" },
      { "path": "feedback_text", "message": "must be at most 500 characters" }
    ]
  }
}

Response 404: { "error": { "code": "NOT_FOUND", "message": "Unknown feature \"nutrition\"" } }


GET/v1/feedbackx-api-key

List submissions, newest first. Used by the dashboard; the app does not need it.

paramtypenotes
featurestringhome / sleep / activity / workout / apps / other
environmentstringstage / uat / production
originstringinternal (testers, through the app) / cx (customers, through CX)
refstringone report by reference: LN-00042, ln-42 or 42
platformstringios / android / backend / firmware
app_versionstringthe app version the report came from, exactly as sent (2.4.0)
is_testtrue / falseomit for both
user_idinteger
from, todateinclusive bounds on occurred_on
is_positivetrue / false
categorystringsubmissions whose issue_categories contains this key
limit1–200default 50
cursorstringvalue of next_cursor from the previous page
{
  "items": [ { …submission… }, { …submission… } ],
  "next_cursor": "2026-09-02T08:20:23.521Z|178239d2-2581-4207-a378-742b0ac186ef"
}

next_cursor is null on the last page. Pass it back URL-encoded as cursor. Invalid parameters return 422.

GET/v1/feedback/{id}x-api-key

One submission by UUID or by reference (LN-00042, ln-42 and 42 all work). 404 if not found.


GET/v1/feedback/statsx-api-key

Aggregates for the dashboard. Accepts the same filters as the list (feature, environment, origin, platform, is_test, user_id, from, to, is_positive, category). to defaults to today in IST and from to 30 days earlier. Also returns by_origin: issues, open, and when the oldest open one arrived, per origin. users counts people: testers by user id, customers by user id or ring serial.

{
  "range": { "from": "2026-08-04", "to": "2026-09-02" },
  "totals": { "submissions": 42, "positive": 30, "negative": 12, "users": 9 },
  "by_day": [ { "date": "2026-08-30", "positive": 3, "negative": 1 }, … ],
  "by_feature": [ { "feature_key": "home", "label": "Home", "positive": 8, "negative": 2 }, … ],
  "by_category": [ { "feature_key": "sleep", "key": "incorrect_sleep", "label": "Incorrect sleep", "count": 5 }, … ]
}

GET/v1/feedback/{id}/diagnosisx-api-key

The AI diagnosis for a reported issue, produced on demand from the dashboard (or automatically when the backend runs with DIAGNOSIS_AUTO=true) from the tester's device logs. 404 until the first run has started. Not needed by the app; useful for tooling.

{
  "status": "done",                       // pending · running · waiting_logs · done · no_logs · failed
  "root_cause_side": "firmware",          // firmware · sdk · app · backend · user_expectation · not_a_bug · insufficient_logs
  "confidence": 0.9, "severity": "medium", "tags": ["algorithm_output"], "reproducible": "likely",
  "summary": "…", "suggested_fix": "…", "questions_for_tester": ["…"],
  "evidence": [ { "source": "firmware", "ts": "05:31:14", "line": "sleep save long …", "why": "…" } ],
  "log_files": { "app": ["https://…"], "ring": ["https://…"], "firmware": ["https://…"] },
  "log_excerpt": "===== [app] … =====\n…", "log_excerpt_lines": 52, "log_coverage": "full",
  "fw_version_seen": "1.2.6", "app_version_seen": "2.0.0.staging.luna",
  "model": "google/gemini-3.1-flash-lite", "cost_usd": 0.0021, "duration_ms": 6300
}

Submissions also carry ai_status, ai_side, ai_severity, and ai_checked_at; the list and stats accept ai_status, ai_side, and ai_severity filters. Same-day reports usually wait for the evening log sync before a verdict exists.


GET/v1/sprints/optionsx-api-key

The two sprint choices the issue form offers: the current sprint and the next one. Sprints run half a month (1st–15th, 16th–end) and move on only when QC closes one, so the current sprint can be past its end (overdue_days). Fetch it when the form opens; it changes only when a sprint closes or is renamed. next.exists: false means it will be created when the first report asks for it.

{
  "current": { "ref": "SPR-202610-01", "name": "Sprint · October 2026 · 01", "starts_on": "2026-10-01", "ends_on": "2026-10-15",
               "range": "1–15 Oct 2026", "days_left": 1, "overdue_days": 0, "exists": true },
  "next":    { "ref": "SPR-202610-02", "name": "Sprint · October 2026 · 02", "starts_on": "2026-10-16", "ends_on": "2026-10-31",
               "range": "16–31 Oct 2026", "days_left": null, "overdue_days": 0, "exists": true }
}

Send "sprint": "current" or "next" with the issue; the response's sprint is { ref, name, status } (or null). The schema carries a sprints entry pointing here, so the app can tell the field exists.


Admin routesx-admin-key

Not for the app. Listed so the front-end team knows how categories change. The dashboard wraps these.

methodpathbody
GET/v1/admin/features
PATCH/v1/admin/features/{feature}{ "label"?, "sort_order"?, "is_active"? }
GET/v1/admin/features/{feature}/issue-categoriesincludes inactive
POST/v1/admin/features/{feature}/issue-categories{ "key", "label", "sort_order"? } · key is a lowercase slug · 409 on duplicate
PATCH/v1/admin/issue-categories/{id}{ "label"?, "sort_order"?, "is_active"? }
PATCH/v1/admin/submissions/{id}{ "is_test": true | false } marks one submission as test data or real (also a button in the dashboard)
GET/v1/feedback/{id}/similarlook-alikes of a report, best first, each with reasons, strength and the latest AI verdict
GET/v1/feedback/{id}/kindsthe report's problems (linked and suggested), each with counts
POST/v1/admin/submissions/{id}/same{ "submission_ids", "kind_id"?, "title"? } puts the report and the given ones under one problem
POST/v1/admin/submissions/{id}/kinds/suggest{ "kind_id" } proposes a problem (CX may)
POST/v1/admin/submissions/{id}/kinds/{kindId}/decision{ "decision": "confirm" | "reject" }; a rejection is remembered
POST/v1/admin/submissions/{id}/similar/ai-checkasks the model which look-alikes are really the same issue (about $0.002)
GET/v1/kinds/{id}/similarreports that look like a problem's own and are not part of it yet
POST/v1/admin/kinds/{id}/reports{ "submission_ids" } adds reports to a problem
POST/v1/admin/kinds/{id}/merge{ "into" } folds a problem into another; the old one is archived
PATCH/v1/admin/submissions/{id}also { "assigned_to": email | null, "priority": "p0".."p3" | null } and status needs_info; { "platform": "ios" | "android" | "backend" | "firmware" | null } (QC and admins) says where the issue lives: the app sends its phone's platform and QC can change it, for instance to backend or firmware; { "sprint": "current" | "next" | "SPR-…" | null } moves it between sprints and the main list. Status fixed means the developer says the bug is fixed and QC still checks it: developers may set only that, and only on reports that are not resolved, closed or won't fix; resolving and closing stay with QC and admins. A report newly marked fixed or wont_fix (here or in bulk) is assigned back to whoever filed it (their dashboard account, else the account with the report's email); with no active account, to Kuldeep Sachan, or Shaifali when his is disabled. A change that also sends assigned_to keeps that owner
GET/v1/feedback/{id}/activitythe report's history and notes, oldest first
POST/v1/admin/submissions/{id}/notes{ "body", "visibility": "internal" | "customer" }; customer notes are ones CX may pass on
POST/v1/admin/submissions/{id}/ask-reporter{ "questions" } posts them as a note and sets needs_info
POST/v1/admin/submissions/{id}/tags{ "add"?, "remove"? }: tags such as app or firmware, lowercased, up to 10 per report. Lists and stats filter with tag: one tag, several comma-separated (any of them), or none
GET/v1/admin/tagstags in use with their counts, plus app and firmware
POST/v1/admin/submissions/{id}/screenshots{ "screenshots" }: images uploaded first with GET /v1/uploads/screenshot-auth, added to a report already filed, up to five per report (409 past that). Each records added_by and added_at. Admins, QC, developers and CX
DELETE/v1/admin/submissions/{id}/screenshots/{file_id}removes a screenshot added that way and deletes the image: whoever added it, or QC and admins. The reporter's own stay
POST/v1/admin/submissions/bulk{ "ids", "status"?, "assigned_to"?, "priority"?, "platform"?, "is_test"?, "kind_id"?, "add_tags"?, "remove_tags"?, "sprint"? }
GET/v1/admin/assigneeseveryone a report can be assigned to
PATCH/v1/admin/kinds/{id}also { "fixed_in_app_version", "fixed_in_firmware_version" }: the versions a fix ships in. A report linked later that ran one of them or newer is flagged as a regression and a fixed problem reopens as watching
GET/v1/kinds/{id}adds skew (firmware, app, OS and platform shares against all reports in the same filters), oldest_versions and regressions
GET/v1/feedback/{id}/same-deviceproblem reports from the same ring (or reporter) in the 14 days up to this one; flagged at 3
GET/v1/attentionadds growing (problems and categories at 3× their usual rate over 3 days, and problems that came back) and repeat_devices
GET/v1/homethe home page in one request: issues today and over 7 days, issues per category, labels, badge counts and the first page of issues
GET/v1/home/reportsone page of the home list: filters plus view, data, received, state (open · done), page, page_size → { items, total, page, page_size }. Leaves sprint issues out unless sprint is given
GET/v1/sprintsevery sprint, newest first, with its counts (open, done, by status, P0/P1 open, carried in and out), timing (day, days_left, overdue_days) and delete_blocked_by. GET /v1/sprints/{ref} (or current / next) adds issues by feature, by platform (by_platform), the app versions they came from (app_versions) and per day
POST/v1/admin/sprints{ "starts_on"?, "ends_on"?, "name"?, "goal"? }: a planned sprint; without dates, the next free half month. One per half month, no overlaps (QC, admin)
PATCH/v1/admin/sprints/{ref}{ "name"?, "goal"?, "starts_on"?, "ends_on"? }; the start stays in its half and is fixed once the sprint is current; closed sprints are read-only
POST/v1/admin/sprints/{ref}/close{ "default": "next" | "main", "overrides"?: { "LN-00042": "main" | "next" | "SPR-…" } }: moves the open issues, makes the next sprint current and plans another
DELETE/v1/admin/sprints/{ref}?confirm={ref}admins only: deletes a sprint with no open issues, and the closed issues in it, for good. Never the current sprint
GET/v1/attention/countsjust the badge and My queue numbers, from one query
GET/v1/analytics/overviewadds caught_first: how many problems customers hit that internal testing reported first, and by how long
GET/v1/analytics/model-usagerough token use per day for the last 30 days, split into input and output, by model; with current_model and chat_model. Not filtered
GET/v1/admin/test-data→ { "count" } of rows flagged is_test
DELETE/v1/admin/test-data?confirm=deletedeletes only is_test rows → { "deleted" }; 422 without the confirm parameter
Stale schema

Categories are never deleted. Deactivating one removes it from the schema and makes the server reject it on new submissions, so a client holding a stale cached schema may get a 422 on issue_categories.N. Handle that by refetching the schema and asking the user to re-pick.

For the CX tool

CX integration

For the CX team's tool, not the app. An agent checks that a customer's problem is real and belongs here, then presses a button in the tool, which calls this API with CX_API_KEY. The key decides that the report is a CX report; there is no origin field to send.

POST/v1/cx/feedback/{feature}CX key

The same feature fields, categories and rules as POST /v1/feedback/{feature}, with these differences:

fieldrequirednotes
device_serialyesthe customer's ring or band serial, 3–64 characters. It identifies the customer and finds their logs
cxyes{ "ref", "url"?, "channel"?, "agent"?, "transcript"? }. ref is the ticket id in the CX tool (≤ 100). channel: email · chat · call · whatsapp · social · app_store · play_store · other. agent: who pressed the button (≤ 120). transcript: the customer's own words (≤ 5000)
emailneverrejected with 422 on email: customer email addresses are not stored. Any email address or phone number inside feedback_text or cx.transcript is redacted before storing
user_idnothe customer's Luna user id if the tool has it; otherwise it is looked up from the serial
client.environmentnodefaults to production
feedback_textnothe agent's summary, ≤ 500

The server fills in the Luna user id, platform, app and firmware version from the logging service by serial, shortly after the response.

curl -X POST https://luna-feedback.buildsage.tech/v1/cx/feedback/sleep \
  -H "x-api-key: $CX_API_KEY" -H "content-type: application/json" \
  -d '{
    "is_positive": false,
    "occurred_on": "2026-09-25",
    "device_serial": "R2N08250600302",
    "issue_categories": ["incorrect_sleep"],
    "feedback_text": "Customer says sleep shows 3h; they slept about 7h.",
    "cx": { "ref": "FD-48213", "url": "https://support.example/tickets/48213", "channel": "email", "agent": "Asha" }
  }'

Response 201 (new) or 200 (already filed)

200 means this cx.ref was already filed for this feature: the first report comes back, never a second one.

{
  "id": "5d0c…", "ref": "LN-00042", "feature_key": "sleep", "is_positive": false,
  "occurred_on": "2026-09-25", "environment": "production", "device_serial": "R2N08250600302",
  "cx": { "ref": "FD-48213", "url": "https://support.example/tickets/48213", "channel": "email", "agent": "Asha" },
  "status": "open", "status_changed_at": null,
  "created_at": "2026-09-27T10:02:11.000Z", "created_at_ist": "2026-09-27 15:32:11 +05:30",
  "problems": [],
  "likely_problem": { "ref": "LNK-0007", "title": "Sleep start recorded hours late", "report_count": 14 },
  "latest_update": { "body": "We found the cause; the fix ships in 2.5.1.", "at": "2026-10-02T09:12:00.000Z" },
  "dashboard_url": "https://luna-feedback.buildsage.tech/i/LN-00042"
}

Store ref on the CX ticket. problems lists the recurring problems (LNK-0007) the report is confirmed as part of. likely_problem is the open problem it most looks like, not yet confirmed: show it to the agent as "Looks like …", or null. latest_update is the team's newest customer-safe note, meant to be passed on as is; when status is needs_info it holds the questions for the customer.


GET/v1/cx/feedback/{ref}CX key

Where a CX report stands, by reference or id, in the same shape as above. Internal tester reports are not readable with the CX key (404).


POST/v1/cx/feedback/{ref}/notesCX key

{ "body": "…", "agent"?: "Asha" } adds a note from the CX tool, such as what the customer replied. It is redacted like everything else CX sends. If the report was waiting on the customer (needs_info), it goes back to the status it had before. Returns the report in the shape above, with 201.

What the CX key can reach

/v1/cx/*, GET /v1/feedback/schema and /v1/feedback/schema/{feature} (to build the button's form), and GET /v1/uploads/screenshot-auth (to attach screenshots the same way the app does). Anything else answers 403.

06

Feature reference

Everything below is also in the schema response; duplicated for quick reading. All details fields are optional except Apps’ app. Every feature’s categories end with a catch-all, something_else (shown as “Other”, and “Something else” on the Other feature), for a problem none of the others names.

Home

category keylabel
wrong_peak_scoreWrong peak score
peak_score_not_loadedPeak score not loaded
guidance_incorrectGuidance incorrect
something_elseOther
details fieldtypeconstraints
peak_score_valuenumber0–100

Sleep

category keylabel
incorrect_sleepIncorrect sleep
sleep_not_recordedSleep not recorded
incorrect_sleep_stageIncorrect sleep stage
vitals_not_recordedVitals not recorded
something_elseOther
details fieldtypeconstraints
actual_start_timetime_12h
actual_end_timetime_12hmust differ from actual_start_time
recorded_start_timetime_12h
recorded_end_timetime_12hmust differ from recorded_start_time

Activity

category keylabel
incorrect_stepsIncorrect steps
incorrect_total_caloriesIncorrect total calories
incorrect_active_caloriesIncorrect active calories
incorrect_training_loadIncorrect training load
workout_not_showingWorkout not showing
something_elseOther
details fieldtypeconstraints
stepsnumberinteger ≥ 0
active_caloriesnumber≥ 0, kcal
total_caloriesnumber≥ 0, kcal

Workout

category keylabel
incorrect_durationIncorrect duration
incorrect_caloriesIncorrect calories
hr_not_showingHR not showing
map_not_loadingMap not loading
incorrect_zonesIncorrect zones
incorrect_intensityIncorrect intensity
start_workout_failStart workout fail
end_workout_failEnd workout fail
something_elseOther
details fieldtypeconstraints
workout_typestring≤ 100 chars, free text
start_timetime_12h
end_timetime_12hmust differ from start_time
intensitystring≤ 50 chars, free text

Apps

Luna's mini apps. Every Apps report, issue or positive, says which app it is about: details.app is required. Show it as a dropdown of the labels and send the key. Missing or unknown → 422 on details.app. Categories are the starting set and are edited from the dashboard.

category keylabel
app_not_loadingApp not loading
app_crashedApp crashed or froze
wrong_dataWrong data or numbers
data_missingData missing
cannot_log_entryCould not log or save an entry
plan_or_guidancePlan or guidance looks wrong
remindersReminders or notifications
display_glitchDisplay or UI glitch
something_elseOther
details fieldtypeconstraints
appstringrequired, one of the app keys below
app keyshown as
cycle_trackerCycle tracker
calorie_trackerCalorie tracker
circadianCircadian
luna_ageLuna Age
vo2_maxVO2 max
sleep_plannerSleep planner
supplement_plannerSupplement planner
hydrationHydration
caffeine_windowCaffeine window
jet_lag_plannerJet lag planner
pregnancy_trackerPregnancy tracker
hyrox_trainingHyrox training
running_trainerRunning trainer

Other

Generic issues that do not belong to one screen. Categories are the starting set and are edited from the dashboard.

category keylabel
app_crashApp crashed
app_slow_or_frozeApp slow or froze
login_or_signupLogin or sign-up problem
ring_pairing_syncRing pairing or sync
battery_drainBattery drain
notificationsNotifications
display_glitchDisplay or UI glitch
data_missingData missing
something_elseSomething else
details fieldtypeconstraints
screenstring≤ 100 chars, free text, e.g. "Settings > Profile"
07

Client context

Optional client object on POST, identical for every feature. Send it on every submission: the dashboard filters by platform and slices by app and firmware version, so platform, app_version, and firmware_version are the most valuable. The schema lists these under client_context_fields with their allowed values.

keyexamplesource
environment"stage" · "uat" · "production"which backend the build points at; case-insensitive, defaults to stage when omitted
platform"ios" · "android" · "backend" · "firmware"the phone's platform, which the app fills in on its own; backend or firmware when QA files an issue that lives there rather than in the app. Case-insensitive on input, stored lower-case
app_version"2.4.0"CFBundleShortVersionString
build_number"512"CFBundleVersion
build_channel"stage"build channel / scheme
firmware_version"1.9.2"connected ring firmware
os_version"iOS 19.1"UIDevice.current.systemVersion
device_id"3F2B0C7A-…"identifierForVendor
session_id"7c9e6679-…"app session UUID

environment — what to send. Every report is tagged with the environment it came from, so stage noise never gets mixed into what UAT or production users report. Send the environment the build actually talks to, derived from the build configuration rather than a user setting.

Omit it and the server records stage, so existing builds keep working unchanged. Anything outside the three values is rejected with 422 on client.environment — read the list from client_context_fields rather than hard-coding it.

08

Error reference

error.codeHTTPmeaningclient action
UNAUTHORIZED401key missing or wrongcheck build config; do not retry
NOT_FOUND404route, feature, or id unknownrefetch schema if it was a feature
VALIDATION_FAILED400 / 422malformed JSON, or field errors in issuesmap issues[].path to fields; do not retry unchanged
CONFLICT409admin duplicaten/a for app
INTERNAL500server faultretry with the same Idempotency-Key
09

Swift reference implementation

Minimal URLSession client covering schema fetch with ETag and a submission with idempotency. Adapt to the app's networking layer; the endpoint must be marked as not requiring Luna's bearer token.

import Foundation

struct FeedbackAPI {
    let baseURL = URL(string: "https://luna-feedback.buildsage.tech")!
    let apiKey: String
    let session: URLSession = .shared
    let decoder = JSONDecoder()

    // MARK: Schema

    struct Field: Decodable {
        let key: String
        let label: String
        let type: String
        let required: Bool
        let help: String?
        let maxLength: Int?
        let multiline: Bool?
        let format: String?
        let allowFuture: Bool?
        let min: Double?
        let max: Double?
        let integer: Bool?
        let unit: String?
        let options: [String]?
        let optionLabels: [String: String]?
        let minItems: Int?
        let optionsFrom: String?
    }
    struct Category: Decodable { let key: String; let label: String }
    struct Rule: Decodable { let kind: String; let start: String; let end: String }
    struct Feature: Decodable {
        let key: String
        let label: String
        let issueCategories: [Category]
        let fields: [Field]
        let rules: [Rule]
        enum CodingKeys: String, CodingKey { case key, label, fields, rules, issueCategories = "issue_categories" }
    }
    struct Schema: Decodable {
        let schemaVersion: Int
        let commonFields: [Field]
        let clientContextKeys: [String]
        let clientContextFields: [Field]
        let features: [Feature]
        enum CodingKeys: String, CodingKey {
            case features
            case schemaVersion = "schema_version"
            case commonFields = "common_fields"
            case clientContextKeys = "client_context_keys"
            case clientContextFields = "client_context_fields"
        }
    }

    enum SchemaResult { case updated(Schema, etag: String?), notModified }

    func fetchSchema(etag: String?) async throws -> SchemaResult {
        var req = URLRequest(url: baseURL.appendingPathComponent("v1/feedback/schema"))
        req.setValue(apiKey, forHTTPHeaderField: "x-api-key")
        if let etag { req.setValue(etag, forHTTPHeaderField: "If-None-Match") }
        let (data, resp) = try await session.data(for: req)
        let http = resp as! HTTPURLResponse
        switch http.statusCode {
        case 304: return .notModified
        case 200: return .updated(try decoder.decode(Schema.self, from: data),
                                  etag: http.value(forHTTPHeaderField: "ETag"))
        default: throw try APIError.from(data, status: http.statusCode)
        }
    }

    // MARK: Submit

    struct Submission: Encodable {
        let isTest: Bool                  // true from dev/CI builds, false in the tester build
        let isPositive: Bool
        let occurredOn: String            // "YYYY-MM-DD"
        let userId: Int
        let email: String
        let issueCategories: [String]
        let feedbackText: String?
        let deviceSerial: String?         // ring/band serial from the SDK when connected
        let details: [String: JSONValue]  // feature fields
        let client: [String: String]          // include "platform": "ios"
        enum CodingKeys: String, CodingKey {
            case email, details, client
            case isTest = "is_test"
            case isPositive = "is_positive"
            case occurredOn = "occurred_on"
            case userId = "user_id"
            case issueCategories = "issue_categories"
            case feedbackText = "feedback_text"
            case deviceSerial = "device_serial"
        }
    }

    struct SubmissionResponse: Decodable {
        let id: String
        /// Readable reference to show the tester, e.g. "LN-00042".
        let ref: String
        let createdAt: String
        let createdAtIST: String
        enum CodingKeys: String, CodingKey {
            case id, ref
            case createdAt = "created_at"
            case createdAtIST = "created_at_ist"
        }
    }

    /// `idempotencyKey` must be generated once per user submission and reused on every retry.
    func submit(_ body: Submission, feature: String, idempotencyKey: UUID) async throws -> SubmissionResponse {
        var req = URLRequest(url: baseURL.appendingPathComponent("v1/feedback/\(feature)"))
        req.httpMethod = "POST"
        req.setValue(apiKey, forHTTPHeaderField: "x-api-key")
        req.setValue("application/json", forHTTPHeaderField: "content-type")
        req.setValue(idempotencyKey.uuidString, forHTTPHeaderField: "Idempotency-Key")
        req.httpBody = try JSONEncoder().encode(body)
        let (data, resp) = try await session.data(for: req)
        let http = resp as! HTTPURLResponse
        guard (200...201).contains(http.statusCode) else { throw try APIError.from(data, status: http.statusCode) }
        return try decoder.decode(SubmissionResponse.self, from: data)
    }

    // MARK: Errors

    struct APIError: Error, Decodable {
        struct Issue: Decodable { let path: String; let message: String }
        struct Body: Decodable { let code: String; let message: String; let issues: [Issue]? }
        let error: Body
        var status: Int = 0
        var isRetryable: Bool { status >= 500 }

        static func from(_ data: Data, status: Int) throws -> APIError {
            var e = (try? JSONDecoder().decode(APIError.self, from: data))
                ?? APIError(error: .init(code: "INTERNAL", message: "Unexpected response", issues: nil))
            e.status = status
            return e
        }
    }
}

/// Small JSON value wrapper so `details` can hold numbers and strings.
enum JSONValue: Encodable {
    case string(String), number(Double), bool(Bool), null
    func encode(to encoder: Encoder) throws {
        var c = encoder.singleValueContainer()
        switch self {
        case .string(let s): try c.encode(s)
        case .number(let n): try c.encode(n)
        case .bool(let b): try c.encode(b)
        case .null: try c.encodeNil()
        }
    }
}

Formatting helpers

// occurred_on
let dateFormatter: DateFormatter = {
    let f = DateFormatter(); f.calendar = Calendar(identifier: .gregorian)
    f.timeZone = TimeZone(identifier: "Asia/Kolkata"); f.dateFormat = "yyyy-MM-dd"; return f
}()

// time_12h
let timeFormatter: DateFormatter = {
    let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX")
    f.dateFormat = "hh:mm a"; return f   // "07:05 AM"
}()
10

Versioning and change policy

  • Additive changes (new category, new optional field, new feature) ship without notice. Clients that render from the schema pick them up on the next fetch.
  • Breaking changes (renamed key, new required field, removed type) bump schema_version and are announced before deploy. The v1 path prefix stays until the wire contract itself changes.
  • Backend repo: github.com/karankumar-tech/luna-internal-feedback. Field definitions live in src/schema/registry.ts; categories live in the database and are managed from the dashboard.
Luna Internal Feedback API · v1 · schema 1 · this page is served by the API itself at /docs