Overview
The API does three things for the client.
- Describes the form.
GET /v1/feedback/schemareturns 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. - Accepts submissions.
POST /v1/feedback/{feature}validates the payload against the same schema and stores it. - Reads submissions back.
GET /v1/feedbackand/statspower the dashboard; the app does not need them.
Environments
| environment | base URL | notes |
|---|---|---|
| Production | https://luna-feedback.buildsage.tech | same deployment as luna-internal-feedback.vercel.app |
| Local | http://localhost:3000 | npm run dev in the backend repo |
Authentication
Send the shared app key on every request under /v1:
x-api-key: <APP_API_KEY>
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.
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.
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" }
}'
Conventions
Request headers
| header | required | purpose |
|---|---|---|
x-api-key | yes | app key, all /v1 routes |
content-type: application/json | on POST | body encoding |
Idempotency-Key | recommended | any string ≤ 200 chars, unique per submission attempt. Reuse the same value when retrying. |
If-None-Match | optional | send the ETag from a previous schema response to get 304 Not Modified |
Data formats
| kind | wire format | rules |
|---|---|---|
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_id | 10482 | JSON 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
| status | code | when |
|---|---|---|
| 200 | read succeeded, or a POST whose Idempotency-Key was already seen (returns the original row) | |
| 201 | POST created a new submission | |
| 304 | schema unchanged since the If-None-Match ETag | |
| 400 | VALIDATION_FAILED | body is not valid JSON |
| 401 | UNAUTHORIZED | missing or wrong x-api-key |
| 404 | NOT_FOUND | unknown route, unknown or deactivated feature, unknown id |
| 409 | CONFLICT | admin only: duplicate category key |
| 422 | VALIDATION_FAILED | body parsed but failed field validation; see issues |
| 500 | INTERNAL | server fault; safe to retry with the same Idempotency-Key |
Idempotency and retries
Network on internal test devices is unreliable. To make retries safe:
- Generate a UUID when the user taps Submit, and keep it with the queued payload.
- Send it as
Idempotency-Keyon every attempt of that submission. 201means stored now.200means an earlier attempt already stored it; the body is the original row. Treat both as success.- Retry on
5xx, timeouts, and connection failures. Do not retry4xx; surface theissuesto 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
| limit | value |
|---|---|
| request body | 64 KB |
feedback_text | 500 characters |
issue_categories | ≥ 1 item |
| list page size | 1–200, default 50 |
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|falseif you want to check your own test rows:GET /v1/feedback?is_test=true&user_id=<your id>.
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.
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"
}
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:
| field | value |
|---|---|
file | the image bytes (JPEG preferred; PNG, HEIC, WebP accepted), ≤ 8 MB, resized on device first |
fileName | e.g. home_2026-09-17.png |
publicKey, token, expire, signature | copied from step 1 |
folder | copied from step 1 |
useUniqueFileName | true |
tags | luna-feedback |
transformation | the 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 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.
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:
| type | extra properties | render as | send as |
|---|---|---|---|
boolean | toggle / segmented control | true / false | |
text | maxLength, multiline | text area with a counter | string |
date | format, allowFuture (absent = false) | date picker, max = today | "YYYY-MM-DD" |
number | min, max, integer, unit | numeric keypad; stepper if integer | JSON number |
time_12h | format | time picker in 12-hour mode | "HH:MM AM/PM" |
string | maxLength, format (email), options, optionLabels | text field; a dropdown if options is present, showing optionLabels[value] when given | string (the option key) |
multi_select | minItems, optionsFrom | multi-select chips from the feature's issue_categories | array 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.
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.
Endpoints
/healthzno authReturns { "ok": true, "db": "up" }, or 503 with { "ok": false, "db": "down" }.
/v1/feedback/schemax-api-keyEverything 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": []
}
]
}
/v1/feedback/schema/{feature}x-api-keyOne 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.
/v1/feedback/{feature}x-api-keyCreate 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
| field | type | required | notes |
|---|---|---|---|
is_positive | boolean | yes | |
occurred_on | date | issues | not in the future (IST). Required when is_positive is false; for positive feedback it may be omitted and defaults to today (IST) |
user_id | integer ≥ 1 | yes | |
email | email string | yes | |
issue_categories | string[] | issues | category keys for this feature, ≥ 1, unique. Required when is_positive is false; for positive feedback omit it or send [] |
feedback_text | string ≤ 500 | no | free text |
device_serial | string ≤ 64 | no | send 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 |
details | object | no | feature fields, see Feature reference. Unknown keys are rejected. Defaults to {} |
client | object | no | any subset of the client context keys. Unknown keys are rejected |
screenshots | object[] | no | up 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_test | boolean | no | default false. Send true from integration runs and test builds; see Test submissions |
sprint | "current" · "next" | no | issues 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.
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\"" } }
/v1/feedbackx-api-keyList submissions, newest first. Used by the dashboard; the app does not need it.
| param | type | notes |
|---|---|---|
feature | string | home / sleep / activity / workout / apps / other |
environment | string | stage / uat / production |
origin | string | internal (testers, through the app) / cx (customers, through CX) |
ref | string | one report by reference: LN-00042, ln-42 or 42 |
platform | string | ios / android / backend / firmware |
app_version | string | the app version the report came from, exactly as sent (2.4.0) |
is_test | true / false | omit for both |
user_id | integer | |
from, to | date | inclusive bounds on occurred_on |
is_positive | true / false | |
category | string | submissions whose issue_categories contains this key |
limit | 1–200 | default 50 |
cursor | string | value 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.
/v1/feedback/{id}x-api-keyOne submission by UUID or by reference (LN-00042, ln-42 and 42 all work). 404 if not found.
/v1/feedback/statsx-api-keyAggregates 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 }, … ]
}
/v1/feedback/{id}/diagnosisx-api-keyThe 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.
/v1/sprints/optionsx-api-keyThe 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-keyNot for the app. Listed so the front-end team knows how categories change. The dashboard wraps these.
| method | path | body |
|---|---|---|
| GET | /v1/admin/features | |
| PATCH | /v1/admin/features/{feature} | { "label"?, "sort_order"?, "is_active"? } |
| GET | /v1/admin/features/{feature}/issue-categories | includes 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}/similar | look-alikes of a report, best first, each with reasons, strength and the latest AI verdict |
| GET | /v1/feedback/{id}/kinds | the 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-check | asks the model which look-alikes are really the same issue (about $0.002) |
| GET | /v1/kinds/{id}/similar | reports 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}/activity | the 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/tags | tags 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/assignees | everyone 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-device | problem reports from the same ring (or reporter) in the 14 days up to this one; flagged at 3 |
| GET | /v1/attention | adds growing (problems and categories at 3× their usual rate over 3 days, and problems that came back) and repeat_devices |
| GET | /v1/home | the 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/reports | one 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/sprints | every 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/counts | just the badge and My queue numbers, from one query |
| GET | /v1/analytics/overview | adds caught_first: how many problems customers hit that internal testing reported first, and by how long |
| GET | /v1/analytics/model-usage | rough 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=delete | deletes only is_test rows → { "deleted" }; 422 without the confirm parameter |
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.
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.
/v1/cx/feedback/{feature}CX keyThe same feature fields, categories and rules as POST /v1/feedback/{feature}, with these differences:
| field | required | notes |
|---|---|---|
device_serial | yes | the customer's ring or band serial, 3–64 characters. It identifies the customer and finds their logs |
cx | yes | { "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) |
email | never | rejected 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_id | no | the customer's Luna user id if the tool has it; otherwise it is looked up from the serial |
client.environment | no | defaults to production |
feedback_text | no | the 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.
/v1/cx/feedback/{ref}CX keyWhere 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).
/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.
/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.
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 key | label |
|---|---|
wrong_peak_score | Wrong peak score |
peak_score_not_loaded | Peak score not loaded |
guidance_incorrect | Guidance incorrect |
something_else | Other |
| details field | type | constraints |
|---|---|---|
peak_score_value | number | 0–100 |
Sleep
| category key | label |
|---|---|
incorrect_sleep | Incorrect sleep |
sleep_not_recorded | Sleep not recorded |
incorrect_sleep_stage | Incorrect sleep stage |
vitals_not_recorded | Vitals not recorded |
something_else | Other |
| details field | type | constraints |
|---|---|---|
actual_start_time | time_12h | |
actual_end_time | time_12h | must differ from actual_start_time |
recorded_start_time | time_12h | |
recorded_end_time | time_12h | must differ from recorded_start_time |
Activity
| category key | label |
|---|---|
incorrect_steps | Incorrect steps |
incorrect_total_calories | Incorrect total calories |
incorrect_active_calories | Incorrect active calories |
incorrect_training_load | Incorrect training load |
workout_not_showing | Workout not showing |
something_else | Other |
| details field | type | constraints |
|---|---|---|
steps | number | integer ≥ 0 |
active_calories | number | ≥ 0, kcal |
total_calories | number | ≥ 0, kcal |
Workout
| category key | label |
|---|---|
incorrect_duration | Incorrect duration |
incorrect_calories | Incorrect calories |
hr_not_showing | HR not showing |
map_not_loading | Map not loading |
incorrect_zones | Incorrect zones |
incorrect_intensity | Incorrect intensity |
start_workout_fail | Start workout fail |
end_workout_fail | End workout fail |
something_else | Other |
| details field | type | constraints |
|---|---|---|
workout_type | string | ≤ 100 chars, free text |
start_time | time_12h | |
end_time | time_12h | must differ from start_time |
intensity | string | ≤ 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 key | label |
|---|---|
app_not_loading | App not loading |
app_crashed | App crashed or froze |
wrong_data | Wrong data or numbers |
data_missing | Data missing |
cannot_log_entry | Could not log or save an entry |
plan_or_guidance | Plan or guidance looks wrong |
reminders | Reminders or notifications |
display_glitch | Display or UI glitch |
something_else | Other |
| details field | type | constraints |
|---|---|---|
app | string | required, one of the app keys below |
| app key | shown as |
|---|---|
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 |
Other
Generic issues that do not belong to one screen. Categories are the starting set and are edited from the dashboard.
| category key | label |
|---|---|
app_crash | App crashed |
app_slow_or_froze | App slow or froze |
login_or_signup | Login or sign-up problem |
ring_pairing_sync | Ring pairing or sync |
battery_drain | Battery drain |
notifications | Notifications |
display_glitch | Display or UI glitch |
data_missing | Data missing |
something_else | Something else |
| details field | type | constraints |
|---|---|---|
screen | string | ≤ 100 chars, free text, e.g. "Settings > Profile" |
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.
| key | example | source |
|---|---|---|
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.
Error reference
| error.code | HTTP | meaning | client action |
|---|---|---|---|
UNAUTHORIZED | 401 | key missing or wrong | check build config; do not retry |
NOT_FOUND | 404 | route, feature, or id unknown | refetch schema if it was a feature |
VALIDATION_FAILED | 400 / 422 | malformed JSON, or field errors in issues | map issues[].path to fields; do not retry unchanged |
CONFLICT | 409 | admin duplicate | n/a for app |
INTERNAL | 500 | server fault | retry with the same Idempotency-Key |
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"
}()
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_versionand are announced before deploy. Thev1path prefix stays until the wire contract itself changes. - Backend repo:
github.com/karankumar-tech/luna-internal-feedback. Field definitions live insrc/schema/registry.ts; categories live in the database and are managed from the dashboard.