Merchant Mobile API Contract
API contract for the Kesles Merchant Mobile App. Endpoints are served by merchant_core_api (Go service, port :8080). Consumer: Flutter mobile app (Android + iOS).
Status (audited 2026-06-03): Doc historically described a target state including multi-outlet, user/role mgmt, settlement reporting, notifications. 17 of those endpoints are not yet implemented in the backend. This revision separates implemented endpoints from deferred design. See §2 partition.
v1 versioning: Mobile paths currently un-versioned. Backend Phase 3 (per api-versioning-v1-migration-plan.md) will add
/v1/*aliases — not yet executed. Document tracks current production paths. After Phase 3, paths gain/v1/segment.
title: Merchant Mobile API Contract
1. General Conventions
1.1 Base URL
Mobile clients hit the production gateway which strips the /merchant/api prefix before forwarding to merchant_core_api:
| Environment | Client base URL | Backend receives |
|---|---|---|
| Production | https://kesles.com/merchant/api | path appended after strip |
| Local dev | http://localhost:8080 | path as-is |
Per-endpoint paths in §2 are written as the backend handler registers them (e.g. /merchant/transactions). Mobile client constructs the full URL: {base_url}/merchant/transactions.
1.2 Authentication
Mobile auth uses two interchangeable channels via the /auth/* endpoints in merchant_core_api (NOT the dashboard auth endpoint).
Channel A — WhatsApp / Email OTP (default):
POST /auth/resolve-phone→ resolve phone numberPOST /auth/request-otp→ kirim OTP (WhatsApp/Email)POST /auth/verify-otp→ verify code + return access tokenPOST /auth/refresh-token→ renew expired token
Channel B — Firebase Phone Auth (SMS via Google):
Status timeline:
- Backend (Phase 0 firebase_service + Phase 1 Deliverable A+B core_api): ✅ done 2026-05-20
- Mobile Mode A registrasi (Android): ✅ done 2026-05-20
- Mobile iOS: ⏳ deferred Phase 1.5 (butuh
URL Typeentitlement + reCAPTCHA fallback verify) - Mode B auto-fallback signal (Deliverable C): ⏳ deferred (touch existing OTP critical path, butuh feature flag)
- Switch-channel endpoint (Deliverable D): ⏳ deferred (depends on C)
Flow:
- Mobile call
FirebaseAuth.instance.verifyPhoneNumber(phone)→ Google kirim SMS / instant approve untuk Test Phone Number - Best case Android: callback
verificationCompleted(credential)auto-fire (SMS Retriever) →signInWithCredential→user.getIdToken()→ tanpa user input OTP - Fallback Android: callback
codeSent(verificationId, _)→ navigateOtpPage(firebaseVerificationId)→ user input 6 digit →PhoneAuthProvider.credential(verificationId, smsCode)→signInWithCredential→user.getIdToken() POST /auth/firebase-phone-verifydengan body{id_token, ...}→ backend verify viaservices/firebase_service(Google JWKS signature check + claim validation) → return access token (sama shape dengan/auth/verify-otp)POST /auth/refresh-token(sama dengan Channel A)
Channel B dipakai untuk:
- Mode A registrasi — UI Mode A
VerificationMethodPagetampil 2 tombol (WhatsApp + SMS). User tap SMS → trigger Channel B flow di atas. ✅ aktif Android 2026-05-20. - Mode B login auto-fallback — kalau primary channel (WA/Email) gagal kirim, backend signal mobile via
use_firebase_phone_auth: truedi response/auth/request-otp(deferred — Deliverable C plan §7.1) - Mode B login manual switch — user click "Coba metode lain" di OtpPage (deferred — Deliverable D)
Mobile error handling (Channel B):
FirebaseAuthExceptiondi-route ke 4 user-facing message generik (network-request-failed,invalid-phone-number, default-fallback) + tombol snackbar action "Pakai WhatsApp" untuk 1-tap fallback.- Quota / billing / internal Firebase issue TIDAK dibocorkan ke user — full error code di-log ke Crashlytics. Admin lihat via Firebase Console > Authentication > Usage + Crashlytics dashboard.
iOS guard sementara: Platform.isIOS di mobile → snackbar "SMS belum tersedia di iOS. Silakan gunakan WhatsApp." + tombol fallback. Lift saat iOS dev setup selesai.
Plan: sms-otp-service-plan.md (notification site → firebase).
Authenticated endpoints require Bearer token:
Authorization: Bearer <jwt_token>
JWT algorithm: HS256. JWT issuer: kesles-merchant-auth (auth_service, post extraction 2026-05-30).
JWT claims (current):
sub—user_id(uuid —identity.usersdidb_kesles_merchant_auth)merchant_id— resolved frommerchant.merchant_userslinkiss—kesles-merchant-auth
DB source note:
iam.usersdidb_kesles_merchantsudah DROPPED (mig v1/057, 2026-05-30).auth_service(port 8081) adalah sole source of truth untuk semua data user/session. JWT diissue oleh auth_service dan divalidasi oleh core_api via shared HMAC secret.
Deferred claims:
role(owner/manager/staff) +outlet_idare part of the multi-outlet design (proposal atmerchant_docs/docs/database/plans/multi-outlet-enterprise-schema-proposal.md) — not yet in JWT today. Single-user-per-merchant model masih jalan.
HTTP timeout tiers (AppHttpClient — RetryPolicy):
- Default JSON: 30 seconds
- Upload (KTP, foto, payment proof): 60 seconds
- Avatar upload: 90 seconds
- RetryPolicy: GET-only, aktif di 12 service. POST/PATCH/DELETE tidak di-wrap RetryPolicy.
1.3 Role-Based Access Matrix
Deferred — design only. Multi-user per merchant + outlet-scoped access is part of the multi-outlet schema proposal (
merchant_docs/docs/database/plans/multi-outlet-enterprise-schema-proposal.md). Current implementation: 1 user = 1 merchant, no outlet scoping.
Target matrix (post multi-outlet implementation):
| Endpoint | Owner | Manager | Staff |
|---|---|---|---|
| GET /transactions | All outlets | Assigned outlet | Assigned outlet (today only) |
| GET /revenue/* | Yes | Assigned outlet | Assigned outlet (today) |
| POST/PATCH /outlets | Yes | No | No |
| GET /outlets | Yes | Yes | Yes (read-only) |
| POST /users/invite | Yes | No | No |
| DELETE /users/{id} | Yes | No | No |
| GET /settlements | Yes | Yes (without MDR detail) | No |
| GET /reports/* | Yes | Yes | No |
| /notifications/* | Yes | Yes | Yes (own only) |
1.4 Error Response Format
All errors follow a consistent shape:
{
"error": "error_code_snake_case",
"message": "Human-readable message (Bahasa Indonesia)",
"details": { "optional": "additional context" }
}
1.5 Standard HTTP Status Codes
| Code | Condition |
|---|---|
| 200 | OK (GET success, PATCH success) |
| 201 | Created (POST success) |
| 204 | No Content (DELETE success) |
| 400 | Bad Request (invalid JSON, missing required fields) |
| 401 | Unauthorized (token invalid/expired) |
| 403 | Forbidden (role does not have access) |
| 404 | Not Found |
| 409 | Conflict (duplicate name, etc) |
| 422 | Unprocessable Entity (validation failed) |
| 429 | Too Many Requests (rate limit) |
| 500 | Internal Server Error |
1.6 Error Code Reference
| Code | Meaning |
|---|---|
unauthorized | Missing/invalid token |
token_expired | JWT expired, client must refresh |
forbidden | Role does not have access |
forbidden_not_owner | Only owner role allowed |
forbidden_outlet_scope | User outside their outlet scope |
not_found | Resource does not exist |
validation_failed | Input failed validation |
duplicate_resource | Unique constraint violation |
subscription_expired | Trial ended / not yet paid |
rate_limit_exceeded | Too many requests |
internal_error | Server error |
1.7 Pagination
List endpoints use cursor-based pagination for performance over large datasets.
Request:
GET /api/merchant/transactions?limit=50&cursor=eyJpZCI6...
Response:
{
"items": [...],
"pagination": {
"next_cursor": "eyJpZCI6...",
"has_more": true,
"total_count": 1247
}
}
limit: default 20, max 100cursor: opaque string (base64 JSON)has_more: false on the last pagetotal_count: optional, expensive for large data → setinclude_total=truewhen needed
1.8 Date & Time
- All datetimes use ISO 8601 UTC (
2026-05-23T14:23:45Z) - The client is responsible for converting to the user's timezone
- Date-only uses
YYYY-MM-DD(without timezone) - Time-only uses
HH:MM:SS
1.9 Money / Amount
- Pakai pola
amount(integer, smallest unit) +currency(string ISO 4217) — bukan suffix_idrdi nama field. Konsisten dengan migration 076/077 (semua kolom DB drop suffix_idr+ tambah kolomcurrency). - Default
currency = "IDR"saat ini; field tetap di-return supaya partner / mobile client siap multi-currency (QRIS Cross-border SGD/THB/MYR future). - IDR = unit terkecil rupiah utuh, no decimal. Untuk currency dengan minor unit (USD, SGD),
amountadalah cents (×100). - Tipe: int64 only — jangan strings, jangan float.
1.10 Rate Limiting
- Default per user: 100 req/minute for GET endpoints, 20 req/minute for POST/PATCH/DELETE
- Heavy endpoints (reports, charts): 10 req/minute
- Response header:
X-RateLimit-Limit: 100X-RateLimit-Remaining: 45X-RateLimit-Reset: 1716452580
1.11 Request ID Tracing
The client must set the X-Request-ID header on every request for tracing:
X-Request-ID: <uuid-v4-generated-by-client>
The server echoes it back in the response header + logs it in Sentry.
2. Implementation Status (Audited 2026-06-03)
This document is partially aspirational. Backend code in merchant_core_api/internal/httpapi/ is the source of truth.
2.0 Currently implemented endpoints
All paths below are registered in the backend and reachable in production. Production base: https://kesles.com/merchant/api (gateway strips /merchant/api/ before forwarding).
Auth (routes_auth.go)
| Method | Path | Handler | Auth |
|---|---|---|---|
| POST | /auth/resolve-phone | handleResolvePhone | public |
| POST | /auth/request-otp | handleRequestOTP | public |
| POST | /auth/verify-otp | handleVerifyOTP | public |
| POST | /auth/firebase-phone-verify | handleFirebasePhoneVerify | public (Firebase Phone Auth — Phase 0 done 2026-05-20) |
| POST | /auth/refresh-token | handleRefreshToken | public (uses refresh token) |
| GET | /auth/device-login/cancel | handleCancelDeviceLogin | public |
| POST | /auth/push-tokens | handleRegisterPushToken | bearer |
| GET | /auth/profile | handleProfile | bearer |
| POST | /auth/profile/email/request-otp | handleRequestEmailOTP | bearer |
| POST | /auth/profile/email/verify-otp | handleVerifyEmailOTP | bearer |
| POST | /profile/avatar | handleUploadProfileAvatar | bearer (multipart) |
Merchant (routes_merchant.go)
| Method | Path | Handler | Notes |
|---|---|---|---|
| POST | /merchant/registration | handleCreateMerchantRegistration | initial registration |
| GET/POST | /merchant/registration/draft | handleCreateOrGetRegistrationDraft | draft persistence |
| POST | /merchant/registration/photo | handleUploadMerchantRegistrationPhoto | KYC photo upload |
| GET | /merchant/registration/device-offer | handleGetActiveMerchantRegistrationDeviceOffer | device package offer |
| GET | /merchant/shipping-preview | handleGetMerchantShippingPreview | preview ongkir tanpa create order |
| POST | /merchant/sales-order | handleCreateMerchantSalesOrder | create sales order (device purchase) |
| GET | /merchant/sales-orders | handleListMerchantSalesOrders | list with status_group query |
| GET | /merchant/sales-order/current | handleGetCurrentSalesOrder | active order |
| POST | /merchant/sales-order/payment-proof | handleUploadMerchantPaymentProof | upload bukti transfer — multipart, max 200 KB, lihat §3 |
| GET | /merchant/sales-order/{id} | handleGetMerchantSalesOrderByID | catch-all detail by ID |
| GET | /merchant/payment-destination | handleGetMerchantPaymentDestination | rekening tujuan transfer |
| GET | /merchant/payment-destinations | handleListMerchantPaymentDestinations | list rekening kandidat — termasuk QRIS korporat dari payment.qris_config (payment-service, Flow B) |
| GET | /merchant/shipping-address | handleGetMerchantShippingAddress | alamat kirim default |
| GET | /merchant/shipping-rate | handleGetMerchantShippingRate | tarif ongkir current |
| POST | /merchant/feedback | handleCreateMerchantFeedback | user feedback |
| GET | /merchant/home-dashboard | handleGetMerchantHomeDashboard | home screen aggregate |
| GET | /merchant/transactions | handleGetMerchantTransactions | list transaksi Flow A (customer→merchant) — lihat §2.1 for response shape design |
| GET | /merchant/payment-terminal | handleGetMerchantPaymentTerminal | device info |
| POST | /merchant/payment-terminal/confirm-receipt | handleConfirmTerminalReceipt | konfirmasi terima device |
| POST | /merchant/terminals/pair | handleMerchantTerminalPair | pair device ke merchant |
| GET | /merchant/contract-summary | handleGetMerchantContractSummary | summary kontrak / billing |
| GET | /merchant/outlets | handleGetMerchantOutlets | outlet list (read-only; multi-outlet Phase 3.1) |
| GET | /merchant/outlets/{id} | handleGetMerchantOutletByID | outlet detail |
| GET | /merchant/outlets/{id}/devices | handleGetMerchantOutletDevices | devices per outlet |
/merchant/payment-destinations— QRIS context: Endpoint ini return daftar rekening + QRIS tujuan pembayaran untuk Flow B (merchant beli produk Kesles). QRIS korporat Kesles diambil daripayment.qris_configsingleton didb_payment(NMID: ID9999999999999, PT Inti Kesles Nusantara). Berbeda dengan QRIS per-merchant (Flow A) yang NMID-nya ada dimerchant.merchants.nmiddidb_kesles_merchant.
Public (routes_public.go)
| Method | Path | Handler |
|---|---|---|
| GET | /public/legal-documents | handleGetLegalDocument |
UMKM Academy endpoints (
/public/umkm-academy/*) are served bycontent_service(db_kesles_merchant_content), notmerchant_core_api. They are out of scope for this contract.
References (routes_reference.go)
| Method | Path | Handler |
|---|---|---|
| GET | /references/provinces | handleListProvinces |
| GET | /references/cities | handleListCities |
| GET | /references/districts | handleListDistricts |
| GET | /references/subdistricts | handleListSubdistricts |
| GET | /references/postal-codes | handleListPostalCodes |
| GET | /references/business-scales | handleListBusinessScales |
| GET | /references/business-types | handleListBusinessTypes |
2.0.1 Deferred — designed but NOT implemented
The following endpoints are described in §2.1–§2.9 below as design specs, but no backend handler exists as of 2026-06-03. They depend on the multi-outlet schema model (proposal at merchant_docs/docs/database/plans/multi-outlet-enterprise-schema-proposal.md) and the multi-user role model — both pending product approval.
GET /merchant/transactions/{id}— transaction detailGET /merchant/revenue/summary— revenue aggregateGET /merchant/revenue/chart— revenue time seriesPOST /merchant/outlets,PATCH /merchant/outlets/{id},DELETE /merchant/outlets/{id}— outlet write CRUD (read endpointsGET /merchant/outlets,GET /merchant/outlets/{id},GET /merchant/outlets/{id}/devicesare now implemented — see §2.0)GET /merchant/settlements— settlement listingGET /merchant/reports/{type}— report exportsGET /merchant/users,POST /merchant/users/invite,PATCH /merchant/users/{id}/role,DELETE /merchant/users/{id}— multi-user mgmtPOST /merchant/notifications/register-token,DELETE /merchant/notifications/tokens/{token_id},GET /merchant/notifications/settings,PATCH /merchant/notifications/settings— these exact paths are not implemented. The shipped equivalents are:POST /auth/push-tokens(FCM token register),GET /merchant/me/notifications+PATCH /merchant/me/notifications/{event_type}(per-merchant per-event prefs), andGET/PATCH /me/notification-prefs(per-user global prefs, proxied to firebase_service).POST /merchant/terminals/{terminal_code}/keypad-amount— virtual keypad
The detailed design for these endpoints is preserved in §2.1–§2.9 below for future implementation reference.
2.x Designed but deferred — for future implementation
Sections below document design intent for endpoints listed in §2.0.1 as deferred. Treat as design reference, not as currently-callable contract. When implementation begins, validate each spec against final multi-outlet schema (it may need adjustment).
2.1 Transactions
GET /api/merchant/transactions
List transactions with filter + pagination.
Auth: Required (any role)
Query params:
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
from | date | No | today | Start date filter (inclusive) |
to | date | No | today | End date filter (inclusive) |
outlet_id | uuid | No | all | Filter by outlet. Staff is auto-locked to their outlet |
amount_min | int | No | - | Minimum amount filter (IDR) |
amount_max | int | No | - | Maximum amount filter (IDR) |
status | string | No | success | success, refunded, all |
search | string | No | - | Search by reference number / customer name |
limit | int | No | 20 | Max 100 |
cursor | string | No | - | Pagination cursor |
Response 200:
{
"items": [
{
"id": "8b3a2c1f-...",
"transaction_at": "2026-05-23T14:23:45Z",
"amount": 75000,
"currency": "IDR",
"mdr_fee": 525,
"net_amount": 74475,
"reference_number": "BI20260523142345XXXX",
"customer_name": "Budi S.",
"payer_bank": "BCA",
"outlet_id": "a1b2...",
"outlet_name": "Toko Pusat Makassar",
"device_serial": "QRISPLUS-M1-0042",
"settlement_status": "pending",
"settlement_date": null,
"status": "success"
}
],
"pagination": {
"next_cursor": "eyJ...",
"has_more": true,
"total_count": 1247
},
"summary": {
"total_amount": 5240000,
"transaction_count": 34,
"avg_amount": 154117
}
}
Errors:
422 validation_failed—from>to, negative filter values403 forbidden_outlet_scope— staff requesting another outlet
Notes:
- Transactions listed here are Flow A only (customer bayar ke merchant via QRIS) — data source:
payment.transactionsdidb_payment. - Staff is automatically filtered to their outlet + today only
- Manager may use any date within their assigned outlet
GET /api/merchant/transactions/{id}
Transaction detail.
Path params:
id— transaction uuid
Response 200:
{
"item": {
"id": "8b3a2c1f-...",
"transaction_at": "2026-05-23T14:23:45Z",
"amount": 75000,
"mdr_total": 525,
"mdr_fee_kesles": 225,
"mdr_fee_bank": 300,
"net_amount": 74475,
"reference_number": "BI20260523142345XXXX",
"rrn": "301234567890",
"customer_name": "Budi S.",
"customer_phone_masked": "08**-****-1234",
"payer_bank": "BCA",
"payer_bank_code": "014",
"outlet_id": "a1b2...",
"outlet_name": "Toko Pusat Makassar",
"device_serial": "QRISPLUS-M1-0042",
"device_id": "c3d4...",
"settlement_status": "pending",
"settlement_expected_date": "2026-05-24",
"settlement_actual_date": null,
"settlement_batch_id": null,
"status": "success",
"meta": {
"qris_merchant_id": "ID102326...",
"qris_merchant_criteria": "regular"
}
},
"meta": {
"can_view_mdr": true
}
}
Errors:
404 not_found— transaction does not exist or is outside the user's scope403 forbidden_outlet_scope— transaction in another outlet
Notes:
- Manager does not see
mdr_fee_*(meta.can_view_mdr = false) - Staff cannot access transaction detail older than 7 days
2.2 Revenue
GET /api/merchant/revenue/summary
Aggregate revenue summary for the dashboard home screen.
Query params:
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
period | enum | No | today | today, yesterday, week, month, custom |
from | date | Only if custom | - | Custom date range |
to | date | Only if custom | - | Custom date range |
outlet_id | uuid | No | all | Outlet filter |
compare | bool | No | true | Include comparison vs previous period |
Response 200:
{
"period": {
"label": "today",
"from": "2026-05-23T00:00:00Z",
"to": "2026-05-23T23:59:59Z"
},
"summary": {
"currency": "IDR",
"revenue": 3240000,
"transaction_count": 48,
"avg_ticket": 67500,
"peak_hour": 12,
"peak_hour_count": 14,
"unique_customer_estimate": 42
},
"comparison": {
"period_label": "yesterday",
"revenue_delta_pct": 12.5,
"transaction_delta_pct": 8.3,
"direction": "up"
},
"outlets_breakdown": [
{
"outlet_id": "a1b2...",
"outlet_name": "Toko Pusat Makassar",
"revenue": 1850000,
"transaction_count": 26,
"share_pct": 57.1
}
],
"generated_at": "2026-05-23T15:30:00Z"
}
Notes:
- This is the most frequently hit endpoint (dashboard refresh every 30s) — server-side cached for 30 seconds
outlets_breakdownis included only for multi-outlet merchants (≥2 outlets)
GET /api/merchant/revenue/chart
Data points for the revenue chart.
Query params:
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
period | enum | Yes | - | 7d, 30d, 90d, 12m |
granularity | enum | No | auto | hour, day, week, month |
outlet_id | uuid | No | all | Outlet filter |
group_by_outlet | bool | No | false | Return breakdown per outlet (stacked chart) |
Response 200:
{
"period": "7d",
"granularity": "day",
"currency": "IDR",
"points": [
{
"timestamp": "2026-05-17T00:00:00Z",
"label": "Mon",
"revenue": 2340000,
"transaction_count": 34
},
{
"timestamp": "2026-05-18T00:00:00Z",
"label": "Tue",
"revenue": 2890000,
"transaction_count": 42
}
],
"stats": {
"total": 18420000,
"avg": 2631428,
"max": 3240000,
"max_at": "2026-05-23T00:00:00Z"
}
}
Stacked chart (when group_by_outlet=true):
{
"points": [
{
"timestamp": "2026-05-17T00:00:00Z",
"label": "Mon",
"by_outlet": [
{ "outlet_id": "a1b2...", "outlet_name": "Pusat", "revenue": 1500000 },
{ "outlet_id": "c3d4...", "outlet_name": "Cabang", "revenue": 840000 }
]
}
]
}
Notes:
granularity=autopicks the optimal value: 7d→day, 30d→day, 90d→week, 12m→month- Tighter rate limit (10 req/minute) due to heavy queries
2.3 Outlets
GET /api/merchant/outlets
List the merchant's outlets.
Response 200:
{
"items": [
{
"id": "a1b2...",
"outlet_name": "Toko Pusat Makassar",
"address_line": "Jl. Deng Ramang Perumahan The Victoria Residence B15",
"city": "Makassar",
"province": "Sulawesi Selatan",
"is_primary": true,
"device_count": 2,
"active_user_count": 3,
"created_at": "2026-04-10T08:00:00Z"
}
],
"meta": {
"total_count": 3,
"can_create": true,
"max_allowed": 10
}
}
POST /api/merchant/outlets
Create a new outlet.
Auth: Owner only
Request body:
{
"outlet_name": "Cabang Gowa",
"address_line": "Jl. Pallangga No. 45",
"city": "Gowa",
"province": "Sulawesi Selatan",
"is_primary": false
}
Validation:
outlet_name: required, 3-150 chars, unique per merchantaddress_line: optional, max 500 charscity,province: optional, max 100 charsis_primary: when true, other outlets are auto-set to false
Response 201:
{
"item": {
"id": "new-uuid-...",
"outlet_name": "Cabang Gowa",
"...": "..."
}
}
Errors:
403 forbidden_not_owner409 duplicate_resource— name already in use422 validation_failed— empty name, invalid city422 max_outlets_exceeded— already 10 outlets (MVP limit)
PATCH /api/merchant/outlets/{id}
Update an outlet.
Auth: Owner only
Request body (all optional):
{
"outlet_name": "Cabang Gowa (Updated)",
"address_line": "...",
"city": "...",
"province": "...",
"is_primary": true
}
Response 200: Same shape as the POST response
Errors:
404 not_found403 forbidden_not_owner409 duplicate_resource
DELETE /api/merchant/outlets/{id}
Soft-delete an outlet (only when there is no active device/user).
Auth: Owner only
Response 200:
{
"deleted": true,
"id": "a1b2..."
}
Errors:
404 not_found403 forbidden_not_owner422 outlet_has_active_devices— detach the device first422 outlet_is_primary— set another outlet as primary first
2.4 Settlements
GET /api/merchant/settlements
List settlements (pending + completed).
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
status | enum | all | pending, completed, all |
from | date | 30 days ago | |
to | date | today | |
outlet_id | uuid | all | |
limit | int | 20 | |
cursor | string | - |
Response 200:
{
"items": [
{
"id": "sett-uuid-...",
"batch_number": "SETT-20260523-001",
"status": "pending",
"expected_date": "2026-05-24",
"completed_date": null,
"transaction_count": 48,
"gross_amount": 3240000,
"mdr_total": 22680,
"net_amount": 3217320,
"bank_name": "BCA",
"bank_account_masked": "1234-****-5678",
"transaction_date_range": {
"from": "2026-05-23T00:00:00Z",
"to": "2026-05-23T23:59:59Z"
},
"is_overdue": false
}
],
"pagination": { "...": "..." },
"summary": {
"currency": "IDR",
"pending_total": 3240000,
"pending_count": 2,
"overdue_count": 0
}
}
Notes:
is_overdue = truewhenexpected_datehas passed by >1 day- Manager does not see
mdr_total(depending on merchant setting)
2.5 Reports
GET /api/merchant/reports/{type}
Generate a PDF/CSV/Excel report.
Path params:
type— enum:daily,weekly,monthly,custom
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
from | date | Required (for custom) | Date range |
to | date | Required (for custom) | Date range |
format | enum | No, default pdf | pdf, csv, excel |
outlet_id | uuid | No | Filter |
include_detail | bool | No, default true | Include per-transaction detail |
Response 200: The response body is a binary file. Content-Type matches the format:
application/pdftext/csvapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Response headers:
Content-Type: application/pdf
Content-Disposition: attachment; filename="kesles-daily-20260523.pdf"
X-Report-Generated-At: 2026-05-23T15:30:00Z
Alternative: async generation for large ranges
If the range exceeds 90 days or estimated size > 5MB:
Response 202 (Accepted):
{
"job_id": "job-uuid-...",
"status": "queued",
"estimated_ready_at": "2026-05-23T15:32:00Z",
"poll_url": "/api/merchant/reports/jobs/job-uuid-..."
}
The client polls GET /api/merchant/reports/jobs/{job_id}:
{
"job_id": "...",
"status": "completed", // queued | processing | completed | failed
"download_url": "https://cdn.kesles.id/reports/...pdf",
"expires_at": "2026-05-24T15:30:00Z"
}
Errors:
422 date_range_too_large— max 365 days422 invalid_format— format not supported403 forbidden— staff cannot export
Rate limit: 10 req/minute per user (expensive endpoint)
2.6 Users (Multi-User / Role Management)
GET /api/merchant/users
List all merchant users.
Auth: Owner or Manager
Response 200:
{
"items": [
{
"id": "user-uuid-...",
"full_name": "Budi Santoso",
"email": "budi@example.com",
"phone_masked": "08**-****-5678",
"role": "owner",
"outlet_id": null,
"outlet_name": null,
"status": "active",
"invited_by_name": null,
"invited_at": null,
"accepted_at": "2026-04-10T08:00:00Z",
"last_login_at": "2026-05-23T09:15:00Z"
},
{
"id": "user-uuid-2",
"full_name": "Ani Wulandari",
"email": "ani@example.com",
"phone_masked": "08**-****-1234",
"role": "manager",
"outlet_id": "a1b2...",
"outlet_name": "Cabang Gowa",
"status": "invited",
"invited_by_name": "Budi Santoso",
"invited_at": "2026-05-20T10:00:00Z",
"accepted_at": null,
"last_login_at": null
}
],
"meta": {
"can_invite": true,
"can_manage_roles": true
}
}
POST /api/merchant/users/invite
Invite a new staff member.
Auth: Owner only
Request body:
{
"email": "staff@example.com",
"phone": "081234567890",
"full_name": "Ani Wulandari",
"role": "manager",
"outlet_id": "a1b2...",
"send_via": "whatsapp"
}
Validation:
- At least one of:
emailorphone role: enummanager,staff(owner cannot be invited)outlet_id: required forstaff, optional formanager(null = all outlets)send_via: enumemail,whatsapp,sms. Default based on available contact
Response 201:
{
"item": {
"id": "user-uuid-...",
"status": "invited",
"invite_token_masked": "abc****xyz",
"invite_expires_at": "2026-05-27T10:00:00Z",
"invite_link": "https://merchant.kesles.id/accept?token=..."
}
}
Errors:
422 email_and_phone_both_empty409 user_already_member— email/phone already registered to this merchant422 outlet_required_for_staff
PATCH /api/merchant/users/{id}/role
Update a user's role.
Auth: Owner only
Request body:
{
"role": "manager",
"outlet_id": "a1b2..."
}
Errors:
422 cannot_change_owner_role— owner cannot be changed422 cannot_demote_last_manager— when the outlet requires at least 1 manager
DELETE /api/merchant/users/{id}
Remove a user from the merchant.
Auth: Owner only
Response 200:
{
"deleted": true,
"id": "user-uuid-..."
}
Errors:
422 cannot_delete_self422 cannot_delete_owner— transfer ownership first (out of MVP scope)
Notes:
- Soft-delete in
merchant.merchant_app_users(setdeleted_at) - The user account in
identity.users(db_kesles_merchant_auth) stays — bisa jadi member merchant lain
2.7 Notifications
POST /api/merchant/notifications/register-token
Register an FCM/APN token at app login.
Request body:
{
"token": "dFGsd3f4g...fcm-token-here",
"platform": "android",
"app_version": "1.0.0",
"device_model": "Samsung Galaxy A52"
}
Validation:
token: required, max 512 charsplatform: enumandroid,iosapp_version: optional, semver
Response 201:
{
"item": {
"id": "token-uuid-...",
"registered_at": "2026-05-23T15:30:00Z"
}
}
Notes:
- If the token already exists for this user → update
last_used_at, return 200 (not 201) - Stale tokens (not updated for > 30 days) are auto-cleaned up server-side
- Token stored in
notification.fcm_push_tokensdidb_kesles_merchant_notification(sole SOT sejak mig 040)
DELETE /api/merchant/notifications/tokens/{token_id}
Unregister a token (on logout or uninstall detection).
Response 200:
{ "deleted": true }
GET /api/merchant/notifications/settings
Get the user's notification preferences.
Response 200:
{
"item": {
"user_id": "user-uuid-...",
"transaction_notif": true,
"silent_hours_start": "22:00:00",
"silent_hours_end": "06:00:00",
"anomaly_notif": true,
"daily_digest": false,
"min_amount_notif": 0,
"updated_at": "2026-05-20T09:00:00Z"
}
}
Field semantics:
transaction_notif: master switch for transaction notificationssilent_hours_start/end: time range during which notifications are muted (on the device, not server-side)anomaly_notif: P1 feature (smart alerts)daily_digest: P1 feature (morning summary)min_amount_notif: when >0, only notify for transactions above the threshold
PATCH /api/merchant/notifications/settings
Update notification preferences.
Request body (all optional):
{
"transaction_notif": true,
"silent_hours_start": "22:00:00",
"silent_hours_end": "06:00:00",
"anomaly_notif": false,
"daily_digest": true,
"min_amount_notif": 10000
}
Response 200: Same as GET
2.8 Sales Orders (Device Order)
Endpoints for the Order + Payment pages in mobile_user.
An order = a device package order for QRIS Plus / accessories (multi-line).
State machine: pending → accepted → payment_received → processing → completed
(cancelled from any non-terminal status).
DB note: Sales order data tersimpan di
db_kesles_merchant_order(order_service, port 8083). Payment lifecycle per order tersimpan diorders.sales_order_payments(mig 004) — ini adalah Flow B (merchant beli produk Kesles), terpisah daripayment.transactionsyang merupakan Flow A (customer bayar ke merchant).
GET /api/merchant/sales-orders?status_group=<group>&limit=100
List orders belonging to the user's active merchant. status_group (optional, default
semua (all)):
semua(all) /belum_bayar(unpaid) /dikemas(packed) /dikirim(shipped) /selesai(completed) /dibatalkan(cancelled) /pengembalian_dana(refunded — currently returns empty; the schema does not yet have a refunded status)
Response:
{
"items": [
{
"id": "uuid",
"order_number": "SO-0000000000000067",
"status": "pending",
"subtotal_amount": 420000,
"shipping_fee_amount": 30000,
"promo_amount": 850000,
"total_amount": 466200,
"payment_proof_url": "https://...",
"created_at": "2026-05-02T16:58:00Z",
"updated_at": "...",
"total_quantity": 3,
"device_summary": "QRIS Plus, QRIS Statis ACR-1, QRIS Statis Sticker S01",
"items_summary": [
{
"line_no": 1,
"device_name": "QRIS Plus",
"quantity": 1,
"unit_price_amount": 1200000,
"promo_amount": 850000,
"line_total_amount": 388500
}
]
}
]
}
items_summary[].promo_amount = sales_quotations.promo_amount × quantity
(per-line total, not per-unit). Mobile uses this to render the strikethrough
of the normal price + the after-promo price. device_name is resolved from
payment_device_models.product_model_name (live SOT) with a fallback to
sales_quotations.device_name (snapshot).
GET /api/merchant/sales-order/{id}/items
Per-line detail for the Payment page (image, description, price, qty).
Response:
{
"items": [
{
"line_no": 1,
"device_name": "QRIS Plus",
"description": "Dual QR Display ...",
"image_url": "https://.../qris-plus.png",
"quantity": 1,
"unit_price_amount": 1200000,
"promo_amount": 850000,
"line_total_amount": 388500
}
]
}
PATCH /api/merchant/sales-order/{id}/items/{line_no}
Update qty for a single line. Only allowed when the order status = pending.
Body: {"quantity": 2} (1–99)
Response 200: items array (re-fetched) + header (subtotal/promo/
shipping/total recomputed).
DELETE /api/merchant/sales-order/{id}/items/{line_no}
Delete a single line item. Mobile uses this when the user taps minus on a
line with qty=1 + confirmation popup. Only allowed when status = pending.
The header is recomputed.
Response 200: Same shape as PATCH.
DELETE /api/merchant/sales-order/{id}
Delete the entire order (soft-cancel). Mobile uses this when the user removes the LAST item of the order — instead of per-line delete, the order is fully cancelled, so the audit trail stays intact.
Body:
{
"reasons": ["change_quantity", "wrong_product", "change_address",
"price_too_high", "not_buying", "other"],
"reason_note": "Optional free-text. Required if reasons contains 'other'."
}
Stored in the sales_orders.metadata.cancellation JSONB. Status is changed
to cancelled (not hard-deleted).
2.9 Virtual Keypad → Device
POST /api/merchant/terminals/{terminal_code}/keypad-amount
Send the transaction amount from the mobile virtual keypad to a QRIS Plus device (e.g. Aisino Q161). The backend forwards to the device via push channel — the device displays the amount & QR as if the operator pressed the physical keypad. Purpose: reduce hardware keypad wear.
Body: {"amount": 15000} — Rupiah integer, min 1000.
Response 200: {"sent_at": "2026-05-03T10:00:00Z"} (TBD — backend
endpoint is still TODO; mobile already calls the sendKeypadAmount method).
2.x OpenAPI Spec (Draft)
Section 2.8 (Sales Orders) + 2.9 (Keypad) have a formal OpenAPI 3.0.3 schema
in openapi-draft.yaml
— can be imported into Postman or used for client code-gen.
3. Subscription & Billing Aware
Every endpoint must return 402 Payment Required when:
- The 30-day trial has ended and the user has not subscribed
- The subscription has expired (daily fee unpaid > 7 days)
Response 402:
{
"error": "subscription_expired",
"message": "Your subscription has ended. Please renew your payment.",
"details": {
"trial_ended_at": "2026-06-23T00:00:00Z",
"last_payment_date": null,
"grace_period_days_remaining": 0,
"renew_url": "https://merchant.kesles.id/billing"
}
}
Exempt endpoints (still accessible while expired):
GET /api/merchant/notifications/settings(the user can still change settings)POST /api/merchant/notifications/register-token(so billing notifications still come through)- Billing endpoints (out of scope for this spec)
4. Webhooks (Backend Internal)
Not part of the API consumed by the mobile app, but worth mentioning because it is on the critical path for F1 (real-time notifications).
4.1 Incoming Webhook: Bank Acquirer → Kesles
The existing endpoint /api/dashboard/partner-webhooks receives the bank callback when QRIS is settled.
Flow after the webhook is received:
- Bank callback → dashboard-api
/api/dashboard/partner-webhooks - dashboard-api inserts into
payment.transactionsdidb_payment(Flow A — customer bayar ke merchant) - dashboard-api triggers internal event
transaction.created - The event handler resolves
notification.fcm_push_tokens(db_kesles_merchant_notification) for the owner + active users in the outlet - The event handler calls FCM API via
firebase_servicefor the push notification - Response to the bank: 200 OK (when DB insert succeeds)
SLA: webhook-to-push < 3 seconds
5. Per-Endpoint cURL Examples
# 1. List today's transactions
curl -H "Authorization: Bearer $TOKEN" \
"https://kesles.com/merchant/api/transactions?limit=20"
# 2. Revenue dashboard summary
curl -H "Authorization: Bearer $TOKEN" \
"https://kesles.com/merchant/api/revenue/summary?period=today&compare=true"
# 3. Revenue chart, 7 days
curl -H "Authorization: Bearer $TOKEN" \
"https://kesles.com/merchant/api/revenue/chart?period=7d"
# 4. Create outlet
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"outlet_name":"Cabang Gowa","city":"Gowa","province":"Sulawesi Selatan"}' \
"https://kesles.com/merchant/api/outlets"
# 5. Download daily report
curl -H "Authorization: Bearer $TOKEN" \
-o report.pdf \
"https://kesles.com/merchant/api/reports/daily?format=pdf&from=2026-05-23&to=2026-05-23"
# 6. Invite staff
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"email":"ani@example.com","phone":"081234567890","full_name":"Ani W","role":"manager","outlet_id":"a1b2...","send_via":"whatsapp"}' \
"https://kesles.com/merchant/api/users/invite"
# 7. Register FCM token
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"token":"dFGsd...","platform":"android","app_version":"1.0.0"}' \
"https://kesles.com/merchant/api/notifications/register-token"
6. Versioning & Compatibility
6.1 API Versioning
Current state (2026-06-03): Mobile paths are un-versioned (/auth/*, /merchant/*, /public/*, /references/*).
Migration plan: Add /v1/* aliases as part of api-versioning-v1-migration-plan.md Phase 3. Once aliases are live, mobile clients gradually migrate per module. After all modules migrated + ≥2 weeks stable, the un-versioned paths are removed.
Target post-Phase 3:
| Caller | Path |
|---|---|
| Mobile (current) | /merchant/transactions |
| Mobile (post-Phase 3) | /v1/merchant/transactions |
| Backend handler register | both (alias paralel) |
PSP and Partner already on /api/psp/v1/* and /api/partner/v1/* (Phase 2 done). Mobile + dashboard SPA pending Phase 3.
Versioning policy (after Phase 3): Additive changes (new fields, new endpoints) do not require a new version. Breaking changes (remove field, change type, change auth) require a new major version (/v2/*) with ≥6-month deprecation notice for mobile clients.
6.2 Client Version Compatibility
The server checks the X-App-Version header from the client:
X-App-Version: 1.0.0
X-Platform: android
Response headers when there is an important update:
X-Client-Min-Version: 1.0.0
X-Client-Update-Available: 1.2.0
X-Client-Update-Required: false
When update_required=true, the client must force the user to update before using the app.
7. Testing & Staging
7.1 Staging Data
Staging uses synthetic datasets:
- 3 test merchants (
merchant_test_small,merchant_test_medium,merchant_test_large) - Each merchant has 1-5 outlets
- Random transactions over the last 30 days
- Universal password:
Test1234!(internal use only)
7.2 Health Check
GET /api/merchant/health
Auth not required:
{
"status": "ok",
"version": "1.0.0",
"timestamp": "2026-05-23T15:30:00Z"
}
7.3 Postman Collection
Download dari /postman/kesles-merchant-mobile-api.postman_collection.json (di-serve oleh internal Docusaurus dari merchant_docs/api_docs/internal/static/postman/).