API Docs v0.8.1
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

200
Server healthy
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

FieldTypeDescription
identifierrequired string Owner's email or klay_id.
passwordrequired string · password
{
  "identifier": "string",
  "password": "string"
}

Responses

200
Login successful
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

FieldTypeDescription
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
FieldTypeDescription
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

200
Logout successful
401
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
FieldTypeDescription
success boolean
data object
error string
401
PATCH /users/me 🔒 Auth required

Update owner profile

Owner can update name or phone. Email cannot be changed (Skalar admin only).

Request body

FieldTypeDescription
full_name string
phone string
{
  "full_name": "string",
  "phone": "string"
}

Responses

200
Profile updated
FieldTypeDescription
success boolean
data object
error string
401
GET /business 🔒 Auth required

Business details for the authenticated owner

Responses

200
Business data
FieldTypeDescription
success boolean
data object
error string
401
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

FieldTypeDescription
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
FieldTypeDescription
success boolean
data object
error string
401
403
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
FieldTypeDescription
success boolean
data object
error string
401
403
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

FieldTypeDescription
namerequired string The only input field. Server generates operator_code and credentials, returned in the response.
{
  "name": "string"
}

Responses

201
Operator created
FieldTypeDescription
success boolean
data object
error string
400
Invalid input
401
403
409
operator_code already in use or slot limit reached
GET /business/operators/{operator_id} 🔒 Auth required

Get a single operator

Parameters

NameIn · TypeDescription
operator_idrequired path · string

Responses

200
Operator found
FieldTypeDescription
success boolean
data object
error string
401
403
404
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

NameIn · TypeDescription
operator_idrequired path · string

Request body

FieldTypeDescription
name string
is_active boolean
{
  "name": "string",
  "is_active": true
}

Responses

200
Operator updated
FieldTypeDescription
success boolean
data object
error string
401
403
404
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

NameIn · TypeDescription
operator_idrequired path · string

Responses

200
Operator deleted
401
403
404
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

NameIn · TypeDescription
operator_idrequired path · string

Responses

200
New QR token
FieldTypeDescription
success boolean
data object
error string
401
403
404
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

NameIn · TypeDescription
operator_idrequired path · string

Responses

200
operator_code and qr_token
FieldTypeDescription
success boolean
data object
error string
401
403
404
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

NameIn · TypeDescription
operator_idrequired path · string

Responses

200
Operator credentials rotated
FieldTypeDescription
success boolean
data object
error string
401
403
404
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

NameIn · TypeDescription
operator_idrequired path · string

Responses

204
Operator session revoked (no content)
401
403
404
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

FieldTypeDescription
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
FieldTypeDescription
success boolean
data object
error string
400
Invalid input
401
Invalid credentials
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

FieldTypeDescription
qr_tokenrequired string
{
  "qr_token": "string"
}

Responses

200
Login successful
FieldTypeDescription
success boolean
data object
error string
400
401
Invalid qr_token
403
Operator inactive or business is SUSPENDED/CHURNED
GET /operator/me 🔒 Auth required

Profile of the currently authenticated operator (PWA)

Responses

200
Operator profile
FieldTypeDescription
success boolean
data object
error string
401
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: [...] })
FieldTypeDescription
success boolean
data object
error string
401
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
FieldTypeDescription
success boolean
data object
error string
400
Invalid request body or validation failed
401
403
GET /products/{product_id} 🔒 Auth required

Get product details

Owner & operator (RequireOperator).

Parameters

NameIn · TypeDescription
product_idrequired path · string

Responses

200
Product details
FieldTypeDescription
success boolean
data object
error string
401
404
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

NameIn · TypeDescription
product_idrequired path · string

Request body

Responses

200
Product updated
FieldTypeDescription
success boolean
data object
error string
400
Invalid request body or product_id
401
403
404
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

NameIn · TypeDescription
product_idrequired path · string

Responses

200
Product deleted. response.NoContent() returns HTTP 200 with body { success: true }, NOT 204 No Content.
FieldTypeDescription
success boolean
401
403
404
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)
FieldTypeDescription
success boolean
data array<object>
error string
401
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

FieldTypeDescription
itemsrequired array<object>
note string
{
  "items": [
    {
      "product_id": "string",
      "quantity": 0
    }
  ],
  "note": "string"
}

Responses

201
DRAFT order created (response.Created -> 201)
FieldTypeDescription
success boolean
data object
error string
400
Invalid body, empty items, or product not found/unavailable (ErrEmptyItems / ErrProductNotFound mapped to 400 BadRequest)
401
PATCH /orders/{order_id}/items 🔒 Auth required

Replace items in a DRAFT order

order.UpdateItems (RequireOperator). Only valid when status is DRAFT.

Parameters

NameIn · TypeDescription
order_idrequired path · string

Request body

FieldTypeDescription
itemsrequired array<object>
note string
{
  "items": [
    {
      "product_id": "string",
      "quantity": 0
    }
  ],
  "note": "string"
}

Responses

200
Order updated
FieldTypeDescription
success boolean
data object
error string
400
Invalid body or product not found
401
404
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

NameIn · TypeDescription
order_idrequired path · string

Responses

200
Checkout started, status PENDING
FieldTypeDescription
success boolean
data object
error string
401
404
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

NameIn · TypeDescription
order_idrequired path · string

Request body

FieldTypeDescription
payment_methodrequired object
{
  "payment_method": null
}

Responses

200
Order CONFIRMED. data = Order + qris_string (nullable, only for QRIS_STATIC).
FieldTypeDescription
success boolean
data object
error string
400
Invalid body or checkout not started (ErrCheckoutNotStarted)
401
404
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

NameIn · TypeDescription
order_idrequired path · string

Responses

200
Order voided. Body {"success":true} with no data.
FieldTypeDescription
success boolean
401
404
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

NameIn · TypeDescription
idrequired path · string

Request body

FieldTypeDescription
void_reasonrequired string Required for audit purposes
{
  "void_reason": "string"
}

Responses

200
Order voided. Body {"success":true} with no data.
FieldTypeDescription
success boolean
400
Empty void_reason or invalid body
401
403
404
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

NameIn · TypeDescription
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
FieldTypeDescription
success boolean
data object
error string
400
Invalid filter (e.g. status=PENDING, invalid payment_method/date/page/limit)
401
403
GET /transactions/{transaction_id} 🔒 Auth required

Transaction details

transaction.GetByID (RequireOperator). Returns OrderWithItems (Transaction + items[]).

Parameters

NameIn · TypeDescription
transaction_idrequired path · string

Responses

200
Transaction details
FieldTypeDescription
success boolean
data object
error string
400
Invalid transaction_id
401
403
404
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

NameIn · TypeDescription
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
FieldTypeDescription
success boolean
data object
error string
400
Invalid filter
401
GET /metrics/summary 🔒 Auth required

Business performance summary

Dashboard aggregate. Counts CONFIRMED status only.

Parameters

NameIn · TypeDescription
period query · string

Responses

200
Summary
FieldTypeDescription
success boolean
data object
error string
401
403
GET /metrics/trend 🔒 Auth required

Daily transaction trend

Parameters

NameIn · TypeDescription
start_date query · string
end_date query · string

Responses

200
Daily trend
FieldTypeDescription
success boolean
data array<object>
error string
401
403
GET /metrics/top-products 🔒 Auth required

Top-selling products

Parameters

NameIn · TypeDescription
period query · string
limit query · integer

Responses

200
Top products
FieldTypeDescription
success boolean
data array<object>
error string
401
403
GET /metrics/peak-hours 🔒 Auth required

Peak hour distribution

Parameters

NameIn · TypeDescription
period query · string

Responses

200
Peak hours
FieldTypeDescription
success boolean
data array<object>
error string
401
403
GET /metrics/overview 🔒 Auth required

Analytics overview for custom date range

Used on the Analytics page, supports custom timeframes and period comparison.

Parameters

NameIn · TypeDescription
start_daterequired query · string
end_daterequired query · string
compare_with query · string

Responses

200
Analytics overview
FieldTypeDescription
success boolean
data object
error string
401
403
GET /metrics/reports/daily-sales 🔒 Auth required

Daily sales report

Aggregates CONFIRMED transactions per day for a given date.

Parameters

NameIn · TypeDescription
daterequired query · string

Responses

200
Daily report
FieldTypeDescription
success boolean
data object
error string
401
403
GET /metrics/reports/monthly-sales 🔒 Auth required

Monthly sales report

Parameters

NameIn · TypeDescription
monthrequired query · string Format YYYY-MM

Responses

200
Monthly report
FieldTypeDescription
success boolean
data object
error string
401
403
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

NameIn · TypeDescription
start_daterequired query · string
end_daterequired query · string
ingredient query · string Filter by ingredient name (ignored by server)

Responses

200
Consumption report
FieldTypeDescription
success boolean
data object
error string
401
403
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

FieldTypeDescription
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
FieldTypeDescription
success boolean
data object
error string
401
403
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
FieldTypeDescription
success boolean
data array<object>
error string
401
403
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

FieldTypeDescription
credentialrequired string Google ID token from Google Sign-In.
{
  "credential": "string"
}

Responses

200
Login successful
FieldTypeDescription
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
FieldTypeDescription
success boolean
data object
error string
401
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
FieldTypeDescription
success boolean
data object
error string
401
403
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

NameIn · TypeDescription
page query · integer
limit query · integer

Responses

200
Paginated owner list
FieldTypeDescription
success boolean
data object
error string
401
403
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
FieldTypeDescription
success boolean
data object
error string
400
Invalid input
401
403
409
Owner email already in use
GET /admin/owners/{owner_id} 🔒 Auth required

Owner details

Parameters

NameIn · TypeDescription
owner_idrequired path · string

Responses

200
Owner details
FieldTypeDescription
success boolean
data object
error string
401
403
404
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

NameIn · TypeDescription
owner_idrequired path · string

Request body

Responses

200
Owner updated
FieldTypeDescription
success boolean
data object
error string
401
403
404
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

NameIn · TypeDescription
owner_idrequired path · string

Request body

FieldTypeDescription
enabledrequired boolean true = activate, false = suspend
{
  "enabled": true
}

Responses

200
Status toggled. response.NoContent() returns HTTP 200 { success:true }.
400
401
404
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

NameIn · TypeDescription
owner_idrequired path · string

Request body

FieldTypeDescription
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 }.
400
401
404
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

NameIn · TypeDescription
owner_idrequired path · string

Responses

200
Product list
FieldTypeDescription
success boolean
data object
error string
401
403
404
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

NameIn · TypeDescription
owner_idrequired path · string

Request body

Responses

201
Product created
FieldTypeDescription
success boolean
data object
error string
400
Invalid input
401
403
GET /admin/products/{product_id} 🔒 Auth required

Product details (including recipe)

handler.GetProduct. Returns AdminProductDetail which includes the recipe array.

Parameters

NameIn · TypeDescription
product_idrequired path · string

Responses

200
Product details with recipe
FieldTypeDescription
success boolean
data object
error string
401
403
404
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

NameIn · TypeDescription
product_idrequired path · string

Request body

Responses

200
Product updated
FieldTypeDescription
success boolean
data object
error string
401
403
404
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

NameIn · TypeDescription
product_idrequired path · string

Responses

200
Product deleted
401
403
404
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

NameIn · TypeDescription
product_idrequired path · string

Request body

FieldTypeDescription
reciperequired array<object> New ingredient array. Replaces the entire existing recipe.
{
  "recipe": [
    null
  ]
}

Responses

200
Recipe updated. response.NoContent() returns HTTP 200 { success:true }.
400
401
404
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

NameIn · TypeDescription
owner_idrequired path · string
operator_idrequired path · string

Responses

200
Operator removed. response.NoContent() returns HTTP 200 { success:true }.
401
403
404
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

NameIn · TypeDescription
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
FieldTypeDescription
success boolean
data object
error string
401
403
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

NameIn · TypeDescription
transaction_idrequired path · string

Responses

200
Transaksi di-void. response.NoContent() returns HTTP 200 { success:true }.
401
403
404
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

FieldTypeDescription
queryrequired string Single SELECT or WITH...SELECT statement.
{
  "query": "string"
}

Responses

200
Query executed successfully
FieldTypeDescription
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).
401
403
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)
401
403
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

NameIn · TypeDescription
app query · string Filter by app.
business_id query · string Filter by merchant.

Responses

200
Aggregated distribution stats
FieldTypeDescription
success boolean
data object
error string
400
business_id query param is not a valid uuid
401
403