GET
/health
Public
Server health status (for the monitoring card in the admin panel)
cmd/main.go. Public, no auth — deliberately lightweight (2s DB ping timeout) so it's safe to poll every few seconds. Returns HTTP 200 when status=ok, HTTP 503 when status=degraded (mirrors the existing /readyz convention). Redis isn't wired into the app yet (see docs/redis-caching-plan.md) — checks.redis always reports not_configured rather than opening a new connection just for this endpoint.
Responses
503
One or more dependencies down (e.g. database unreachable)
POST
/auth/login
Public
Owner login with identifier and password
Authenticate owner via identifier (email OR klay_id) + password. Account must have been created by Skalar admin during onboarding. Success = access token in body, refresh token in httpOnly cookie. Response data contains only access_token (no nested user object).
Request body
| Field | Type | Description |
| identifierrequired |
string |
Owner's email or klay_id. |
| passwordrequired |
string · password |
|
{
"identifier": "string",
"password": "string"
}
Responses
401
Invalid email or password
403
Account found but business status is SUSPENDED or CHURNED
POST
/auth/google/login
Public
Owner login with Google OAuth (NOT IMPLEMENTED)
Handler currently returns 501 Not Implemented (auth/handler.go GoogleLogin). Google OAuth login is not yet wired. The 200/403 entries below are the intended contract but not yet built: called by NextAuth after Google OAuth succeeds, server verifies the email is registered as an owner, then issues its own Klay JWT.
Request body
| Field | Type | Description |
| emailrequired |
string · email |
|
| namerequired |
string |
|
{
"email": "owner@klay.id",
"name": "string"
}
Responses
200
[INTENDED, NOT YET BUILT] Login successful
403
[INTENDED, NOT YET BUILT] Email not registered as an owner
501
Google login not implemented (actual server behavior)
POST
/auth/refresh
Public
Refresh access token
Reads refresh token from httpOnly cookie, issues a new access token.
Responses
200
New access token
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
401
Refresh token invalid or expired
POST
/auth/logout
🔒 Auth required
Logout
Clears the refresh token httpOnly cookie.
Responses
GET
/users/me
🔒 Auth required
Profile of the currently authenticated owner
Returns the owner's profile along with associated business data.
Responses
200
Profile retrieved successfully
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
PATCH
/users/me
🔒 Auth required
Update owner profile
Owner can update name or phone. Email cannot be changed (Skalar admin only).
Request body
| Field | Type | Description |
| full_name |
string |
|
| phone |
string |
|
{
"full_name": "string",
"phone": "string"
}
Responses
200
Profile updated
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
GET
/business
🔒 Auth required
Business details for the authenticated owner
Responses
200
Business data
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
PATCH
/business
🔒 Auth required
Update business info (restricted)
Owner can only update display fields. Plan, features, max_operators, status, and xendit_status are Skalar admin-only fields.
Request body
| Field | Type | Description |
| business_name |
string |
|
| phone |
string |
|
| address |
string |
|
| city |
string |
|
| country |
string |
|
| qris_string |
string |
Merchant QRIS static payload. |
| category |
string |
|
{
"business_name": "string",
"phone": "string",
"address": "string",
"city": "string",
"country": "string",
"qris_string": "string",
"category": "string"
}
Responses
200
Business updated
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
GET
/business/operators
🔒 Auth required
List business operators
Lists operators with quota info. qr_token is not returned in the list — only on create and regenerate.
Responses
200
Operator list
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
POST
/business/operators
🔒 Auth required
Add a new operator
Owner-only. Cap is validated against business.max_operators (default 3, admin updates if add-on purchased, -1 = unlimited). Body contains only name; server generates operator_code (KL-XXXXX), PIN, and qr_token. operator_code and credentials (qr_token/pin) are returned only in this create/regenerate response (one-time).
Request body
| Field | Type | Description |
| namerequired |
string |
The only input field. Server generates operator_code and credentials, returned in the response.
|
{
"name": "string"
}
Responses
201
Operator created
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
409
operator_code already in use or slot limit reached
GET
/business/operators/{operator_id}
🔒 Auth required
Get a single operator
Parameters
| Name | In · Type | Description |
| operator_idrequired |
path · string |
|
Responses
200
Operator found
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
PUT
/business/operators/{operator_id}
🔒 Auth required
Update operator (partial)
Owner updates name or is_active. Password and QR reset are handled by separate endpoints.
Parameters
| Name | In · Type | Description |
| operator_idrequired |
path · string |
|
Request body
| Field | Type | Description |
| name |
string |
|
| is_active |
boolean |
|
{
"name": "string",
"is_active": true
}
Responses
200
Operator updated
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
DELETE
/business/operators/{operator_id}
🔒 Auth required
Delete operator (soft delete)
Soft delete — deleted_at is set, row remains. operator_code is NOT released (stored in row, unique index slot consumed). Operator can no longer log in. Active operator slot count decreases (COUNT is_active drops). Historical data remains valid.
Parameters
| Name | In · Type | Description |
| operator_idrequired |
path · string |
|
Responses
POST
/business/operators/{operator_id}/regenerate-qr
🔒 Auth required
Regenerate operator qr_token
Issues a new qr_token. The old QR is invalid for new logins, but any currently active JWT sessions remain valid until the access token expires.
Parameters
| Name | In · Type | Description |
| operator_idrequired |
path · string |
|
Responses
200
New QR token
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
GET
/business/operators/{operator_id}/qr
🔒 Auth required
Get operator_code and qr_token
UndocumentedPresent in user/routes.go, absent from prior contract.
Owner-only. Returns the current operator_code and qr_token to re-render the QR in the dashboard without regenerating.
Parameters
| Name | In · Type | Description |
| operator_idrequired |
path · string |
|
Responses
200
operator_code and qr_token
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
POST
/business/operators/{operator_id}/rotate
🔒 Auth required
Rotate operator credentials
UndocumentedPresent in user/routes.go, absent from prior contract.
Owner-only. Re-issues the operator's qr_token and PIN. New credentials are returned only once (one-time) in this response.
Parameters
| Name | In · Type | Description |
| operator_idrequired |
path · string |
|
Responses
200
Operator credentials rotated
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
POST
/business/operators/{operator_id}/force-logout
🔒 Auth required
Force logout operator
UndocumentedPresent in user/routes.go, absent from prior contract.
Owner-only. Revokes the operator's active session (revoke refresh token). Returns 204 No Content on success.
Parameters
| Name | In · Type | Description |
| operator_idrequired |
path · string |
|
Responses
204
Operator session revoked (no content)
POST
/operator/auth/login
Public
Operator login with credentials
Public endpoint. identifier (business email or klay_id) resolves the business; operator is looked up via operator_code + PIN.
Request body
| Field | Type | Description |
| identifierrequired |
string |
Business email or klay_id (resolves business). |
| operator_coderequired |
string |
|
| pinrequired |
string · password |
Exactly 6 numeric digits (server: validate len=6,numeric). |
{
"identifier": "string",
"operator_code": "string",
"pin": "string"
}
Responses
200
Login successful
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
403
Operator inactive or business is SUSPENDED/CHURNED
POST
/operator/auth/login/qr
Public
Operator login with QR scan
Public endpoint. qr_token is static, regenerated via owner.
Request body
| Field | Type | Description |
| qr_tokenrequired |
string |
|
{
"qr_token": "string"
}
Responses
200
Login successful
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
403
Operator inactive or business is SUSPENDED/CHURNED
GET
/operator/me
🔒 Auth required
Profile of the currently authenticated operator (PWA)
Responses
200
Operator profile
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
GET
/products
🔒 Auth required
List business products
Owner & operator (RequireOperator) can GET. Handler only uses business_id from JWT; no query filter/sort/search is processed by the server.
Responses
200
Product list (data wrapped as object { products: [...] })
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
POST
/products
🔒 Auth required
Add product (owner only)
UndocumentedOwner-facing product CRUD exists at /products in routes.go; prior contract said admin-only.
RequireOwner. business_id is taken from JWT, not the body.
Request body
Responses
201
Product created
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
400
Invalid request body or validation failed
GET
/products/{product_id}
🔒 Auth required
Get product details
Owner & operator (RequireOperator).
Parameters
| Name | In · Type | Description |
| product_idrequired |
path · string |
|
Responses
200
Product details
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
PATCH
/products/{product_id}
🔒 Auth required
Update product (owner only, partial)
UndocumentedOwner-facing product CRUD exists at /products in routes.go; prior contract said admin-only.
RequireOwner. Partial update — fields not sent remain unchanged.
Parameters
| Name | In · Type | Description |
| product_idrequired |
path · string |
|
Request body
Responses
200
Product updated
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
400
Invalid request body or product_id
DELETE
/products/{product_id}
🔒 Auth required
Delete product (owner only)
UndocumentedOwner-facing product CRUD exists at /products in routes.go; prior contract said admin-only.
RequireOwner.
Parameters
| Name | In · Type | Description |
| product_idrequired |
path · string |
|
Responses
200
Product deleted. response.NoContent() returns HTTP 200 with body { success: true }, NOT 204 No Content.
| Field | Type | Description |
| success |
boolean |
|
GET
/orders
🔒 Auth required
List orders for the authenticated operator
order.ListMyOrders (RequireOperator). Only orders belonging to the operator from JWT. If called as owner (operator_id empty) returns an empty array. Response is a Transaction array directly in `data`, NOT a paginated object.
Responses
200
Operator's order array (or [] for owner)
| Field | Type | Description |
| success |
boolean |
|
| data |
array<object> |
|
| error |
string |
|
POST
/orders
🔒 Auth required
Create a new order (status DRAFT)
order.CreateOrder (RequireOperator). Initial status is DRAFT. Backend calculates total_amount from items and snapshots product_name + unit_price into order_items. operator_id is taken from JWT (nullable if owner).
Request body
| Field | Type | Description |
| itemsrequired |
array<object> |
|
| note |
string |
|
{
"items": [
{
"product_id": "string",
"quantity": 0
}
],
"note": "string"
}
Responses
201
DRAFT order created (response.Created -> 201)
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
400
Invalid body, empty items, or product not found/unavailable (ErrEmptyItems / ErrProductNotFound mapped to 400 BadRequest)
PATCH
/orders/{order_id}/items
🔒 Auth required
Replace items in a DRAFT order
order.UpdateItems (RequireOperator). Only valid when status is DRAFT.
Parameters
| Name | In · Type | Description |
| order_idrequired |
path · string |
|
Request body
| Field | Type | Description |
| itemsrequired |
array<object> |
|
| note |
string |
|
{
"items": [
{
"product_id": "string",
"quantity": 0
}
],
"note": "string"
}
Responses
200
Order updated
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
400
Invalid body or product not found
409
Order is not in DRAFT status (ErrNotDraft)
POST
/orders/{order_id}/checkout/begin
🔒 Auth required
Begin checkout (DRAFT -> PENDING)
order.BeginCheckout (RequireOperator). Stamps checkout_started_at and moves status to PENDING. No request body. Must be called before confirm — confirming without begin yields ErrCheckoutNotStarted (400).
Parameters
| Name | In · Type | Description |
| order_idrequired |
path · string |
|
Responses
200
Checkout started, status PENDING
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
409
Order is not in DRAFT status (ErrNotDraft)
POST
/orders/{order_id}/checkout/confirm
🔒 Auth required
Confirm checkout (PENDING -> CONFIRMED)
order.ConfirmCheckout (RequireOperator). Body requires `payment_method`. Server rejects if begin-checkout was not called (ErrCheckoutNotStarted) or gesture was too fast <800ms since checkout_started_at (ErrGestureTooFast). Stamps confirmed_at + confirmed_by (string), then triggers consumption_log from product.recipe. Response includes qris_string when payment is QRIS_STATIC.
Parameters
| Name | In · Type | Description |
| order_idrequired |
path · string |
|
Request body
| Field | Type | Description |
| payment_methodrequired |
object |
|
{
"payment_method": null
}
Responses
200
Order CONFIRMED. data = Order + qris_string (nullable, only for QRIS_STATIC).
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
400
Invalid body or checkout not started (ErrCheckoutNotStarted)
409
Order is not PENDING (ErrNotPending) or confirmation too fast <800ms (ErrGestureTooFast)
POST
/orders/{order_id}/void
🔒 Auth required
Void order (operator)
order.VoidOrder (RequireOperator). No request body. Returns response.NoContent = HTTP 200 with body {"success":true} (NOT 204).
Parameters
| Name | In · Type | Description |
| order_idrequired |
path · string |
|
Responses
200
Order voided. Body {"success":true} with no data.
| Field | Type | Description |
| success |
boolean |
|
POST
/transactions/{id}/void
🔒 Auth required
Void order (owner)
order.VoidOrderByOwner (RequireOwner). Write mutation in the order domain but mounted at /transactions/{id}/void. Body requires `void_reason`. Owner identity is taken from user_id JWT. Returns response.NoContent = HTTP 200 with {"success":true} (NOT 204).
Parameters
| Name | In · Type | Description |
| idrequired |
path · string |
|
Request body
| Field | Type | Description |
| void_reasonrequired |
string |
Required for audit purposes |
{
"void_reason": "string"
}
Responses
200
Order voided. Body {"success":true} with no data.
| Field | Type | Description |
| success |
boolean |
|
400
Empty void_reason or invalid body
GET
/transactions
🔒 Auth required
Business transaction list (read-only log)
transaction.List (RequireOwner). Read-only history. status query accepts only DRAFT/CONFIRMED/VOIDED — PENDING is rejected 400 by list-filter validation.
Parameters
| Name | In · Type | Description |
| start_date |
query · string |
Format YYYY-MM-DD |
| end_date |
query · string |
Format YYYY-MM-DD |
| status |
query · string |
Only DRAFT, CONFIRMED, VOIDED accepted. PENDING rejected 400. |
| payment_method |
query · string |
|
| operator_id |
query · string |
|
| page |
query · integer |
|
| limit |
query · integer |
|
Responses
200
Paginated transaction list
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
400
Invalid filter (e.g. status=PENDING, invalid payment_method/date/page/limit)
GET
/transactions/{transaction_id}
🔒 Auth required
Transaction details
transaction.GetByID (RequireOperator). Returns OrderWithItems (Transaction + items[]).
Parameters
| Name | In · Type | Description |
| transaction_idrequired |
path · string |
|
Responses
200
Transaction details
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
400
Invalid transaction_id
GET
/operator/transactions
🔒 Auth required
Transaction list for the authenticated operator
transaction.OperatorList (RequireOperatorOnly). Operator-scoped — only transactions from the operator in JWT. Filters: start_date, end_date, page, limit (no status/payment_method/operator_id filter on this endpoint).
Parameters
| Name | In · Type | Description |
| start_date |
query · string |
Format YYYY-MM-DD |
| end_date |
query · string |
Format YYYY-MM-DD |
| page |
query · integer |
|
| limit |
query · integer |
|
Responses
200
Paginated operator transaction list
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
GET
/metrics/summary
🔒 Auth required
Business performance summary
Dashboard aggregate. Counts CONFIRMED status only.
Parameters
| Name | In · Type | Description |
| period |
query · string |
|
Responses
200
Summary
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
GET
/metrics/trend
🔒 Auth required
Daily transaction trend
Parameters
| Name | In · Type | Description |
| start_date |
query · string |
|
| end_date |
query · string |
|
Responses
200
Daily trend
| Field | Type | Description |
| success |
boolean |
|
| data |
array<object> |
|
| error |
string |
|
GET
/metrics/top-products
🔒 Auth required
Top-selling products
Parameters
| Name | In · Type | Description |
| period |
query · string |
|
| limit |
query · integer |
|
Responses
200
Top products
| Field | Type | Description |
| success |
boolean |
|
| data |
array<object> |
|
| error |
string |
|
GET
/metrics/peak-hours
🔒 Auth required
Peak hour distribution
Parameters
| Name | In · Type | Description |
| period |
query · string |
|
Responses
200
Peak hours
| Field | Type | Description |
| success |
boolean |
|
| data |
array<object> |
|
| error |
string |
|
GET
/metrics/overview
🔒 Auth required
Analytics overview for custom date range
Used on the Analytics page, supports custom timeframes and period comparison.
Parameters
| Name | In · Type | Description |
| start_daterequired |
query · string |
|
| end_daterequired |
query · string |
|
| compare_with |
query · string |
|
Responses
200
Analytics overview
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
GET
/metrics/reports/daily-sales
🔒 Auth required
Daily sales report
Aggregates CONFIRMED transactions per day for a given date.
Parameters
| Name | In · Type | Description |
| daterequired |
query · string |
|
Responses
200
Daily report
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
GET
/metrics/reports/monthly-sales
🔒 Auth required
Monthly sales report
Parameters
| Name | In · Type | Description |
| monthrequired |
query · string |
Format YYYY-MM |
Responses
200
Monthly report
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
GET
/metrics/reports/consumption
🔒 Auth required
Raw material consumption report
Aggregates consumption_log per ingredient for a period. Foundation for owner restock decisions and downstream AI insights.
Parameters
| Name | In · Type | Description |
| start_daterequired |
query · string |
|
| end_daterequired |
query · string |
|
| ingredient |
query · string |
Filter by ingredient name (ignored by server) |
Responses
200
Consumption report
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
POST
/metrics/reports/export
🔒 Auth required
Export report to PDF or CSV
Generates a file download. Server returns a temporary URL or streams directly. Report type and period are passed via body.
Request body
| Field | Type | Description |
| typerequired |
string · enum |
|
| formatrequired |
string · enum |
|
| periodrequired |
object |
Shape depends on type. Daily=date, monthly=month, consumption=start_date+end_date. |
{
"type": "daily_sales",
"format": "pdf",
"period": {}
}
Responses
200
File generated
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
422
Invalid type, format, or period combination
GET
/metrics/insight
🔒 Auth required
List insight cards
Rule-based MVP. Schema is ready for model_version + confidence if later switching to an AI engine.
Responses
200
Insight cards
| Field | Type | Description |
| success |
boolean |
|
| data |
array<object> |
|
| error |
string |
|
POST
/admin/auth/google
Public
Admin Skalar login via Google OAuth
core/adminauth. Frontend gets a Google ID token (credential) from Google Sign-In and posts it here. Server verifies the token against Google (signature + audience == GOOGLE_CLIENT_ID), extracts the verified email, and checks it against ADMIN_WHITELIST_EMAILS (env var, comma-separated — no DB table, parsed once into an in-memory set). No match -> 403, no token issued. Match -> admin JWT issued (access_token in body, refresh token via httpOnly "admin_refresh_token" cookie — separate cookie name from owner/operator's "refresh_token" so sessions can't collide if both panels share a browser/cookie domain). Logs admin.login to audit_logs.
Request body
| Field | Type | Description |
| credentialrequired |
string |
Google ID token from Google Sign-In. |
{
"credential": "string"
}
Responses
200
Login successful
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
400
Empty credential or invalid request body
401
Invalid Google token (signature/audience/expiry verification failed)
403
Email not in ADMIN_WHITELIST_EMAILS. No token issued.
POST
/admin/auth/refresh
Public
Refresh admin access token
Responses
200
New token
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
POST
/admin/auth/logout
Public
Admin logout
core/adminauth. Public — no Bearer required (same pattern as owner /auth/logout): reads the admin_refresh_token cookie, best-effort deletes it server-side, always clears the cookie. Idempotent — calling it with no cookie still returns 200.
Responses
200
Logout successful (idempotent, also when there is no active session)
GET
/admin/auth/me
🔒 Auth required
Identity of the admin from the active session
core/adminauth. Used by the FE to check the session. Pure JWT-claims read, no DB call — email and name both come from the Google-verified token at login time and are embedded in the JWT itself (there's no admin identity row to look up). Reflects whatever Google returned at login, not live data — if an admin's Google profile name changes, /me won't show it until they log in again.
Responses
200
Admin profile
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
GET
/admin/owners
🔒 Auth required
List all owners (cross-merchant)
Admin view. handler.ListOwners — accepts only page and limit. Status/plan/q filters are not processed by the server (admin/handler.go).
Parameters
| Name | In · Type | Description |
| page |
query · integer |
|
| limit |
query · integer |
|
Responses
200
Paginated owner list
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
POST
/admin/owners
🔒 Auth required
Onboard a new merchant
Creates Business + User owner in one operation. Body matches admin/model.go CreateBusinessRequest. business_id is server-generated.
Request body
Responses
201
Owner onboarded successfully
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
409
Owner email already in use
GET
/admin/owners/{owner_id}
🔒 Auth required
Owner details
Parameters
| Name | In · Type | Description |
| owner_idrequired |
path · string |
|
Responses
200
Owner details
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
PATCH
/admin/owners/{owner_id}
🔒 Auth required
Update business info owner (admin-controlled fields)
Matches admin/model.go UpdateBusinessRequest. Fields not sent remain unchanged. merchant_status enum: PENDING | REGISTERED | ACTIVE | SUSPENDED.
Parameters
| Name | In · Type | Description |
| owner_idrequired |
path · string |
|
Request body
Responses
200
Owner updated
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
PATCH
/admin/owners/{owner_id}/status
🔒 Auth required
Toggle aktif/suspend owner
handler.SetOwnerStatus — boolean toggle. enabled=true activates, enabled=false suspends. Returns HTTP 200 { success: true } via response.NoContent().
Parameters
| Name | In · Type | Description |
| owner_idrequired |
path · string |
|
Request body
| Field | Type | Description |
| enabledrequired |
boolean |
true = activate, false = suspend |
{
"enabled": true
}
Responses
200
Status toggled. response.NoContent() returns HTTP 200 { success:true }.
POST
/admin/owners/{owner_id}/credential
🔒 Auth required
Set owner login credentials (email and/or password)
UndocumentedRoute present in admin/routes.go but absent from prior contract.
handler.SetOwnerCredential. email is optional (must be valid format if provided). password is required, min 6 characters. Returns HTTP 200 { success:true } via response.NoContent().
Parameters
| Name | In · Type | Description |
| owner_idrequired |
path · string |
|
Request body
| Field | Type | Description |
| email |
string · email |
Optional. Must be a valid email format if provided. |
| passwordrequired |
string · password |
Required, minimum 6 characters. |
{
"email": "owner@klay.id",
"password": "string"
}
Responses
200
Credentials updated. response.NoContent() returns HTTP 200 { success:true }.
GET
/admin/owners/{owner_id}/products
🔒 Auth required
List products for the business
handler.ListOwnerProducts. Response wraps products inside an object { products: [...] } (not a direct array).
Parameters
| Name | In · Type | Description |
| owner_idrequired |
path · string |
|
Responses
200
Product list
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
POST
/admin/owners/{owner_id}/products
🔒 Auth required
Add a product for the business
handler.CreateProduct. Body matches admin/model.go AdminCreateProductRequest. business_id is taken from the path param, not the body.
Parameters
| Name | In · Type | Description |
| owner_idrequired |
path · string |
|
Request body
Responses
201
Product created
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
GET
/admin/products/{product_id}
🔒 Auth required
Product details (including recipe)
handler.GetProduct. Returns AdminProductDetail which includes the recipe array.
Parameters
| Name | In · Type | Description |
| product_idrequired |
path · string |
|
Responses
200
Product details with recipe
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
PATCH
/admin/products/{product_id}
🔒 Auth required
Update produk (partial)
handler.UpdateProduct. Body matches admin/model.go AdminUpdateProductRequest. All fields are optional (pointer) — fields not sent remain unchanged.
Parameters
| Name | In · Type | Description |
| product_idrequired |
path · string |
|
Request body
Responses
200
Product updated
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
DELETE
/admin/products/{product_id}
🔒 Auth required
Delete product
handler.DeleteProduct. Soft delete if product already exists in historical transactions. Returns HTTP 200 { success:true } via response.NoContent() (NOT 204).
Parameters
| Name | In · Type | Description |
| product_idrequired |
path · string |
|
Responses
PUT
/admin/products/{product_id}/recipe
🔒 Auth required
Replace recipe produk
handler.UpdateProductRecipe. Replaces the entire recipe array (not a merge). Returns HTTP 200 { success:true } via response.NoContent() (NOT 204).
Parameters
| Name | In · Type | Description |
| product_idrequired |
path · string |
|
Request body
| Field | Type | Description |
| reciperequired |
array<object> |
New ingredient array. Replaces the entire existing recipe. |
{
"recipe": [
null
]
}
Responses
200
Recipe updated. response.NoContent() returns HTTP 200 { success:true }.
DELETE
/admin/owners/{owner_id}/operators/{operator_id}
🔒 Auth required
Remove operator on merchant request
handler.DeleteOperator. Admin can only REMOVE operator on request. No request body — handler does not read body (admin/handler.go). Returns HTTP 200 { success:true } via response.NoContent() (NOT 204).
Parameters
| Name | In · Type | Description |
| owner_idrequired |
path · string |
|
| operator_idrequired |
path · string |
|
Responses
200
Operator removed. response.NoContent() returns HTTP 200 { success:true }.
GET
/admin/transactions
🔒 Auth required
List transaksi cross-merchant
handler.ListTransactions. Filter yang diproses: status (string), business_id (uuid), start_date (YYYY-MM-DD, filter created_at >= start), end_date (YYYY-MM-DD, filter created_at < end+1day), page (default 1), limit (default 20, max 100).
Parameters
| Name | In · Type | Description |
| status |
query · string |
Filter status transaksi (string bebas, tidak divalidasi enum di handler). |
| business_id |
query · string |
Filter per business. |
| start_date |
query · string |
Format YYYY-MM-DD. created_at >= start_date. |
| end_date |
query · string |
Format YYYY-MM-DD. created_at < end_date + 1 hari. |
| page |
query · integer |
|
| limit |
query · integer |
|
Responses
200
List transaksi cross-merchant berpaginasi
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
POST
/admin/transactions/{transaction_id}/void
🔒 Auth required
Void transaksi (admin)
handler.VoidTransaction. Tidak ada request body — handler tidak membaca body. Returns HTTP 200 { success:true } via response.NoContent() (BUKAN 204). 409 dikembalikan jika transaksi bukan status PENDING/CONFIRMED (tidak bisa di-void).
Parameters
| Name | In · Type | Description |
| transaction_idrequired |
path · string |
|
Responses
200
Transaksi di-void. response.NoContent() returns HTTP 200 { success:true }.
409
Transaction cannot be voided (already VOIDED or invalid status)
POST
/admin/query
🔒 Auth required
Run a single read-only SELECT query (SQL console)
core/adminquery. Guarded, not a general SQL endpoint: rejects anything that isn't a single SELECT/WITH statement (write CTEs, SELECT ... INTO, FOR UPDATE/SHARE, dangerous function calls like pg_terminate_backend, and multi-statement input via ';' are all rejected before reaching the database). Executes inside a Postgres READ ONLY transaction as a second, DB-enforced layer beyond the text guard, with SET LOCAL statement_timeout=5s and the query outer-wrapped in "SELECT * FROM (...) LIMIT 1000" so returned rows are bounded regardless of what the inner query requested. Every submitted query is audit-logged (action: admin.query, query text + outcome in metadata) — including ones the guard blocked, since a blocked destructive attempt is itself worth having on record. Errors (guard rejection or a real Postgres error — syntax, unknown column, timeout) are returned verbatim in `error`: this is a debugging tool for a trusted, whitelisted admin, not a public surface, so hiding SQL errors would make it useless.
Request body
| Field | Type | Description |
| queryrequired |
string |
Single SELECT or WITH...SELECT statement. |
{
"query": "string"
}
Responses
200
Query executed successfully
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
400
Query rejected by guard (not a single SELECT, multi-statement, forbidden keyword) or a real Postgres error (syntax, unknown column, timeout).
POST
/telemetry/heartbeat
🔒 Auth required
Report one PWA install/version heartbeat
core/telemetry. Called by klay-app (owner + operator apps; entry has no authenticated session so it never calls this) once per session boot plus occasionally while active. Upserts app_installs keyed on (business_id, app, device_id) — business_id comes from the authenticated JWT claim, NOT from the request body, so a client can't report installs against a merchant it doesn't belong to. device_id is a client-generated pseudonymous fingerprint (crypto.randomUUID(), persisted in localStorage) — never a real device or personal identifier. No transaction/order data travels this path. Best-effort on the client: failures are swallowed there, never surfaced to the user.
Request body
Responses
200
Heartbeat recorded (insert or upsert)
400
Invalid body (app is not one of entry/owner/operator, empty app_version/device_id)
GET
/admin/distribution
🔒 Auth required
Aggregated PWA distribution stats for the admin panel
core/telemetry. Powers klay-admin's Distribution page. JWT RequireAdmin only — same pattern as /admin/query, no X-Admin-Key required. Returns total installs, breakdown by app, breakdown by app version, breakdown by merchant, and a list of devices that haven't reported in `stale_after_days` days (churn/uninstall signal, capped at 100 rows).
Parameters
| Name | In · Type | Description |
| app |
query · string |
Filter by app. |
| business_id |
query · string |
Filter by merchant. |
Responses
200
Aggregated distribution stats
| Field | Type | Description |
| success |
boolean |
|
| data |
object |
|
| error |
string |
|
400
business_id query param is not a valid uuid