Version 1

Cucimoo API reference

A JSON REST API for the Cucimoo car and motorcycle wash management platform. It powers the Cucimoo mobile app and is available to Enterprise tenants for their own integrations.

Base URLhttp://localhost:3000/api/v1
Content typeapplication/json in and out. No form posts.
AmountsIntegers in the smallest currency unit — IDR rupiah, so 99000.
TimestampsISO 8601 in UTC. Branch-local rendering is the client's job.
OpenAPI /api/v1/openapi.json — generated from this same catalogue, so it never drifts.
A note on language
Field names, enum values and error codes are English. The `message` on an error is written in Bahasa Indonesia and is safe to display to end users verbatim — that is deliberate, not an oversight.

Authentication

Call POST /auth/login to exchange credentials for a session token, then send it on every subsequent request:

Authorization: Bearer <token>
  • Tokens are valid for 7 days by default. Call POST /auth/refresh before expiry to extend the session without re-prompting for a password.
  • There are no cookies. Clients store the token themselves — use the platform keychain, not plain preferences.
  • Enterprise tenants may instead send a long-lived API key as X-Api-Key: ck_live_…. Keys are created in the tenant console and shown exactly once.
  • A 401 UNAUTHENTICATED means the token is gone or expired — send the user back to the login screen.
Sign in with curl
curl -X POST http://localhost:3000/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"identifier":"budi@example.com","password":"rahasia123"}'

Response envelope

Every response has the same shape, so a client can unwrap it once and forget about it.

Single resource

{
  "data": {
    "id": "clx…",
    "name": "Cabang Margonda"
  }
}

List

{
  "data": [
    "…"
  ],
  "meta": {
    "page": 1,
    "limit": 20,
    "total": 137,
    "pageCount": 7
  }
}

Error

{
  "error": {
    "code": "LIMIT_REACHED",
    "message": "Paket Free hanya mengizinkan 1 cabang. Tingkatkan paket untuk menambah lagi.",
    "status": 402
  }
}

Error codes

Switch on error.code; show error.message to the user.

CodeHTTPMeaning
UNAUTHENTICATED401Missing, malformed, or expired token. Send the user back to login.
FORBIDDEN403Authenticated, but this role may not perform the action.
TENANT_SUSPENDED403The business account is suspended. Contact support.
NOT_FOUND404The resource does not exist, or belongs to another tenant.
VALIDATION_FAILED400Request body failed validation. `details.field` names the offending field.
PLAN_REQUIRED402The tenant's plan does not include this feature. Prompt an upgrade.
LIMIT_REACHED402A numeric plan cap is exhausted. `details.max` is the cap. Prompt an upgrade.
CONFLICT409The request contradicts current state (duplicate, illegal transition).
RATE_LIMITED429Too many attempts. `details.retryAfterSeconds` says how long to wait.
SERVER_ERROR500Unexpected failure on our side. Safe to retry with backoff.
402 is not 403
402 means the tenant's plan does not allow this — prompt an upgrade. 403 means this role may not do it at all — an upgrade will not help.

Pagination & caching

List endpoints accept ?page, ?limit, ?search, ?branchId, ?dateFrom and ?dateTo. limit defaults to 20 and is capped at 100.

Catalogue-style reads (branches, staff, plans, public settings) send an ETag. Send it back as If-None-Match and you get a 304 with no body — worth doing on a mobile connection.

Rate limits

EndpointLimit
POST /auth/login5 failed attempts per identifier per 15 minutes
POST /auth/register5 per IP address per hour
POST /auth/forgot-password5 per IP address per 15 minutes

Exceeding a limit returns 429 RATE_LIMITED with details.retryAfterSeconds.

Authentication

Obtain and manage the tenant session token used by every other endpoint.

POST/api/v1/auth/login Public

Sign in a business owner or cashier

Accepts an email address or an Indonesian phone number as `identifier`. Rate limited to 5 failed attempts per identifier per 15 minutes.

Request body

  • identifierstringrequiredEmail address or phone number.
  • passwordstringrequiredAccount password.

Example response

{
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
    "expiresAt": "2026-08-18T04:41:38.000Z",
    "user": {
      "id": "clx…",
      "name": "Budi Santoso",
      "email": "budi@example.com",
      "phone": "628121111111",
      "tenantRole": "owner"
    },
    "tenant": {
      "id": "clx…",
      "name": "Cuci Kilat Depok",
      "slug": "cuci-kilat-depok"
    }
  }
}
Possible errors:UNAUTHENTICATEDRATE_LIMITED
POST/api/v1/auth/register Public

Register an owner and create their business

Creates the user, the tenant, the first branch and a trial subscription in one call. One trial is allowed per verified phone number. The business fields are optional: leave them out and the tenant is created provisionally under the owner name, to be completed by the onboarding flow.

Request body

  • namestringrequiredOwner full name.
  • emailstringrequiredEmail address, must be unique.
  • phonestringrequiredIndonesian phone number (08xx, +62xx or 62xx).
  • passwordstringrequiredMinimum 8 characters.
  • businessNamestringName of the wash business. Defaults to the owner name until onboarding sets it.
  • categorystringOne of `car`, `motorcycle`, `both`. Defaults to `both`.
  • branchNamestringFirst branch name. Defaults to `Cabang Utama`.
  • timezonestringOne of `Asia/Jakarta`, `Asia/Makassar`, `Asia/Jayapura`.
  • acceptTermsbooleanrequiredMust be `true` — records UU PDP consent.

Example response

{
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
    "trialEndsAt": "2026-08-25T04:41:38.000Z"
  }
}
Possible errors:VALIDATION_FAILEDCONFLICTRATE_LIMITED
POST/api/v1/auth/forgot-password Public

Request a password reset link

Always succeeds, whether or not the address exists — this prevents account enumeration.

Request body

  • emailstringrequiredEmail address on the account.

Example response

{
  "data": {
    "success": true
  }
}
Possible errors:VALIDATION_FAILEDRATE_LIMITED
POST/api/v1/auth/reset-password Public

Set a new password using a reset token

Request body

  • tokenstringrequiredRaw token from the emailed link.
  • passwordstringrequiredNew password, minimum 8 characters.

Example response

{
  "data": {
    "success": true
  }
}
Possible errors:VALIDATION_FAILED
POST/api/v1/auth/refresh Bearer token

Exchange a valid token for a fresh one

Call this before the current token expires to keep the session alive without asking for the password again.

Example response

{
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
    "expiresAt": "2026-08-25T04:41:38.000Z"
  }
}
Possible errors:UNAUTHENTICATED
POST/api/v1/auth/logout Bearer token

End the current session

Clients should discard the token regardless of the response.

Example response

{
  "data": {
    "success": true
  }
}

Current user

Who is signed in, what their plan allows, and their profile.

GET/api/v1/me Bearer token or API key

User, tenant, role, entitlements and branches

The single call a mobile app makes on launch. Everything the UI needs to decide what to show. `tenant.timezone` is the display clock for screens with no branch in scope; where a branch is in scope, use that branch's own `timezone` — all timestamps are returned in UTC either way.

Example response

{
  "data": {
    "user": {
      "id": "clx…",
      "name": "Budi Santoso",
      "email": "budi@example.com",
      "phone": "628121111111",
      "avatarUrl": null
    },
    "tenant": {
      "id": "clx…",
      "name": "Cuci Kilat Depok",
      "slug": "cuci-kilat-depok",
      "category": "both",
      "status": "active",
      "timezone": "Asia/Jakarta"
    },
    "membership": {
      "role": "owner",
      "abilities": [
        "billing.manage",
        "branches.manage",
        "staff.manage"
      ]
    },
    "entitlements": {
      "planSlug": "premium",
      "planName": "Premium",
      "status": "trialing",
      "trialDaysRemaining": 11,
      "limits": {
        "branches": 5,
        "staffTagsPerTransaction": -1,
        "customers": -1,
        "services": -1,
        "reportRetentionDays": 365
      },
      "features": {
        "export": true,
        "multiBranchRollup": true,
        "apiAccess": false,
        "prioritySupport": false
      }
    },
    "branches": [
      {
        "id": "clx…",
        "name": "Cabang Margonda",
        "timezone": "Asia/Jakarta",
        "isActive": true,
        "isPrimary": true
      }
    ]
  }
}
Possible errors:UNAUTHENTICATED
PATCH/api/v1/me Bearer token

Update the signed-in user profile

Request body

  • namestringFull name, minimum 2 characters.
  • phonestringIndonesian phone number.
  • avatarUrlstringURL returned by an upload, or null to clear.

Example response

{
  "data": {
    "user": {
      "id": "clx…",
      "name": "Budi Santoso",
      "email": "budi@example.com",
      "phone": "628121111111",
      "avatarUrl": null
    }
  }
}
Possible errors:VALIDATION_FAILEDCONFLICT
POST/api/v1/me/change-password Bearer token

Change the password while signed in

Request body

  • currentPasswordstringrequiredThe password in use right now.
  • newPasswordstringrequiredNew password, minimum 8 characters.

Example response

{
  "data": {
    "success": true
  }
}
Possible errors:VALIDATION_FAILED

Branches

Physical outlets under the business. Creating one beyond the plan cap returns 402 `LIMIT_REACHED`.

GET/api/v1/branches Bearer token or API key

List branches

Parameters

  • pagequery · integerPage number, from 1.
  • limitquery · integerItems per page, capped at 100.
  • searchquery · stringMatches branch name.

Example response

{
  "data": [
    {
      "id": "clx…",
      "name": "Cabang Margonda",
      "address": "Jl. Margonda Raya No. 12, Depok",
      "phone": "628121111111",
      "timezone": "Asia/Jakarta",
      "taxRate": 11,
      "isActive": true,
      "isPrimary": true,
      "staffCount": 3
    }
  ],
  "meta": {
    "page": 1,
    "limit": 20,
    "total": 1,
    "pageCount": 1
  }
}
POST/api/v1/branches Bearer token or API key

Create a branch

Returns 402 `LIMIT_REACHED` when the plan's active-branch cap is already used up.

Request body

  • namestringrequiredBranch name, minimum 2 characters.
  • addressstringStreet address.
  • phonestringBranch phone number.
  • timezonestring`Asia/Jakarta` | `Asia/Makassar` | `Asia/Jayapura`.
  • receiptFooterstringClosing line printed at the foot of this branch's receipts. The header uses the branch name, address and phone.
  • taxRatenumberTax percent charged on every sale at this branch, 0–100. Applied to the subtotal after the discount; 0 (the default) means no tax.

Example response

{
  "data": {
    "id": "clx…",
    "name": "Cabang Kelapa Dua",
    "timezone": "Asia/Jakarta",
    "taxRate": 0,
    "isActive": true,
    "isPrimary": false
  }
}
Possible errors:VALIDATION_FAILEDLIMIT_REACHEDFORBIDDEN
GET/api/v1/branches/{id} Bearer token or API key

Get one branch

Parameters

  • idpath · stringrequiredBranch id.

Example response

{
  "data": {
    "id": "clx…",
    "name": "Cabang Margonda",
    "timezone": "Asia/Jakarta",
    "taxRate": 11,
    "isActive": true,
    "isPrimary": true,
    "staffCount": 3
  }
}
Possible errors:NOT_FOUND
PATCH/api/v1/branches/{id} Bearer token or API key

Update a branch

Parameters

  • idpath · stringrequiredBranch id.

Request body

  • namestringBranch name.
  • addressstringStreet address.
  • phonestringBranch phone number.
  • timezonestringBranch timezone.
  • receiptFooterstringClosing line printed at the foot of this branch's receipts.
  • taxRatenumberTax percent charged on every sale at this branch, 0–100. Applied to the subtotal after the discount; 0 (the default) means no tax. Changing it never rewrites sales already recorded.
  • isActivebooleanRe-activating is subject to the plan cap.
  • isPrimarybooleanMakes this the default branch.

Example response

{
  "data": {
    "id": "clx…",
    "name": "Cabang Margonda Baru",
    "isActive": true
  }
}
Possible errors:NOT_FOUNDVALIDATION_FAILEDLIMIT_REACHED
DELETE/api/v1/branches/{id} Bearer token or API key

Delete a branch

The last remaining branch cannot be deleted.

Parameters

  • idpath · stringrequiredBranch id.

Example response

{
  "data": {
    "success": true
  }
}
Possible errors:NOT_FOUNDCONFLICT

Staff tags

Names of the people who wash vehicles. They are labels, not accounts — staff never sign in.

GET/api/v1/branches/{id}/staff Bearer token or API key

List staff tags in a branch

Parameters

  • idpath · stringrequiredBranch id.

Example response

{
  "data": [
    {
      "id": "clx…",
      "name": "Agus",
      "phone": null,
      "isActive": true,
      "branchId": "clx…"
    }
  ]
}
Possible errors:NOT_FOUND
POST/api/v1/branches/{id}/staff Bearer token or API key

Add a staff tag

Parameters

  • idpath · stringrequiredBranch id.

Request body

  • namestringrequiredStaff name, minimum 2 characters.
  • phonestringOptional phone number.

Example response

{
  "data": {
    "id": "clx…",
    "name": "Rian",
    "isActive": true,
    "branchId": "clx…"
  }
}
Possible errors:NOT_FOUNDVALIDATION_FAILEDFORBIDDEN
PATCH/api/v1/staff/{id} Bearer token or API key

Update a staff tag

Parameters

  • idpath · stringrequiredStaff tag id.

Request body

  • namestringStaff name.
  • phonestringPhone number.
  • isActivebooleanInactive staff no longer appear when tagging.

Example response

{
  "data": {
    "id": "clx…",
    "name": "Rian Saputra",
    "isActive": true
  }
}
Possible errors:NOT_FOUNDVALIDATION_FAILED
DELETE/api/v1/staff/{id} Bearer token or API key

Delete a staff tag

Parameters

  • idpath · stringrequiredStaff tag id.

Example response

{
  "data": {
    "success": true
  }
}
Possible errors:NOT_FOUND

Service catalogue

What the business sells and what it charges. Defined once per business; a branch may override the price of any item instead of the catalogue being duplicated per outlet. All money fields are integers in rupiah.

GET/api/v1/services Bearer token or API key

List catalogue items

Pass `branchId` to get the price that branch actually charges in `price`; `basePrice` is always the business-wide price.

Parameters

  • branchIdquery · stringResolve prices for this branch.
  • searchquery · stringMatches the service name.
  • vehicleTypequery · string`car` | `motorcycle` | `truck` | `bus` | `both` | `addon`.
  • isActivequery · booleanOnly active or only inactive items.

Example response

{
  "data": [
    {
      "id": "clx…",
      "name": "Cuci mobil kecil (sedan/hatchback)",
      "description": null,
      "vehicleType": "car",
      "basePrice": 35000,
      "price": 40000,
      "hasBranchPrice": true,
      "durationMinutes": 30,
      "color": "sky",
      "isActive": true,
      "order": 0,
      "branchPrices": [
        {
          "branchId": "clx…",
          "price": 40000
        }
      ]
    }
  ]
}
POST/api/v1/services Bearer token or API key

Add a catalogue item

Returns 402 `LIMIT_REACHED` when the plan's catalogue cap is used up.

Request body

  • namestringrequiredService name, minimum 2 characters.
  • basePriceintegerrequiredBusiness-wide price in rupiah.
  • vehicleTypestring`car` | `motorcycle` | `truck` | `bus` | `both` (default) | `addon`.
  • descriptionstringShort note shown under the name.
  • durationMinutesintegerTypical time the job takes.
  • colorstringTile colour in the cashier grid: `sky` | `teal` | `emerald` | `lime` | `amber` | `orange` | `rose` | `violet` | `slate`, or null for no colour.
  • isActivebooleanInactive items do not appear in the cashier screen.

Example response

{
  "data": {
    "id": "clx…",
    "name": "Poles bodi",
    "vehicleType": "addon",
    "basePrice": 75000,
    "price": 75000,
    "isActive": true
  }
}
Possible errors:VALIDATION_FAILEDLIMIT_REACHEDFORBIDDEN
PATCH/api/v1/services/{id} Bearer token or API key

Update a catalogue item

Parameters

  • idpath · stringrequiredService id.

Request body

  • namestringService name.
  • basePriceintegerBusiness-wide price in rupiah.
  • vehicleTypestringVehicle the service applies to.
  • durationMinutesintegerTypical duration, or null to clear.
  • colorstringTile colour in the cashier grid, or null to clear it.
  • isActivebooleanHide the item from the cashier screen.

Example response

{
  "data": {
    "id": "clx…",
    "name": "Poles bodi premium",
    "basePrice": 95000
  }
}
Possible errors:NOT_FOUNDVALIDATION_FAILED
POST/api/v1/services/{id}/price Bearer token or API key

Set or clear a branch price override

Send `price: null` to remove the override and put the branch back on `basePrice`.

Parameters

  • idpath · stringrequiredService id.

Request body

  • branchIdstringrequiredBranch the price applies to.
  • priceintegerrequiredPrice in rupiah, or null to clear the override.

Example response

{
  "data": {
    "id": "clx…",
    "name": "Cuci mobil kecil (sedan/hatchback)",
    "basePrice": 35000,
    "price": 40000,
    "hasBranchPrice": true
  }
}
Possible errors:NOT_FOUNDVALIDATION_FAILED
DELETE/api/v1/services/{id} Bearer token or API key

Delete a catalogue item

Returns 409 `CONFLICT` once the item has been sold — deactivate it instead, so reports keep resolving.

Parameters

  • idpath · stringrequiredService id.

Example response

{
  "data": {
    "success": true
  }
}
Possible errors:NOT_FOUNDCONFLICT

Customers & vehicles

Lightweight records for recognising repeat customers. Always optional — recording a wash never requires one. Names, phone numbers and plate numbers are personal data under Indonesia's UU PDP; handle them accordingly.

GET/api/v1/customers Bearer token or API key

List customers

`search` matches the name, the phone number, or a saved plate number.

Parameters

  • pagequery · integerPage number, from 1.
  • limitquery · integerItems per page, capped at 100.
  • searchquery · stringName, phone number or plate.
  • sortquery · string`recent` (default), `name` or `spend`.

Example response

{
  "data": [
    {
      "id": "clx…",
      "name": "Ibu Sari",
      "phone": "628121234567",
      "notes": null,
      "totalVisits": 6,
      "totalSpend": 240000,
      "lastVisitAt": "2026-08-11T02:14:00.000Z",
      "vehicles": [
        {
          "id": "clx…",
          "type": "car",
          "subType": "MPV",
          "plate": "B 1234 XYZ",
          "color": "Putih",
          "note": null
        }
      ]
    }
  ],
  "meta": {
    "page": 1,
    "limit": 20,
    "total": 1,
    "pageCount": 1
  }
}
POST/api/v1/customers Bearer token or API key

Add a customer

A vehicle can be created in the same call, which is how the cashier screen saves a walk-in. Returns 402 `LIMIT_REACHED` at the plan cap.

Request body

  • namestringrequiredCustomer name, minimum 2 characters.
  • phonestringIndonesian phone number; stored in `62…` form.
  • notesstringFree-text note.
  • vehicleobject`{ type, subType?, plate?, color?, note? }` — creates one vehicle at the same time.

Example response

{
  "data": {
    "id": "clx…",
    "name": "Ibu Sari",
    "phone": "628121234567",
    "totalVisits": 0,
    "totalSpend": 0,
    "vehicles": []
  }
}
Possible errors:VALIDATION_FAILEDLIMIT_REACHEDFORBIDDEN
GET/api/v1/customers/{id} Bearer token or API key

Get one customer

Parameters

  • idpath · stringrequiredCustomer id.

Example response

{
  "data": {
    "id": "clx…",
    "name": "Ibu Sari",
    "totalVisits": 6,
    "totalSpend": 240000,
    "vehicles": []
  }
}
Possible errors:NOT_FOUND
PATCH/api/v1/customers/{id} Bearer token or API key

Update a customer

Parameters

  • idpath · stringrequiredCustomer id.

Request body

  • namestringCustomer name.
  • phonestringPhone number, or null to clear.
  • notesstringFree-text note.

Example response

{
  "data": {
    "id": "clx…",
    "name": "Ibu Sari Wulandari"
  }
}
Possible errors:NOT_FOUNDVALIDATION_FAILED
DELETE/api/v1/customers/{id} Bearer token or API key

Delete a customer

UU PDP erasure at record level. Their transactions survive with the customer link cleared — the takings still happened.

Parameters

  • idpath · stringrequiredCustomer id.

Example response

{
  "data": {
    "success": true
  }
}
Possible errors:NOT_FOUND
POST/api/v1/customers/{id}/vehicles Bearer token or API key

Add a vehicle to a customer

Parameters

  • idpath · stringrequiredCustomer id.

Request body

  • typestringrequired`car` | `motorcycle` | `truck` | `bus`.
  • subTypestringSedan, MPV, SUV, Matic, …
  • platestringPlate number; stored upper-case.
  • colorstringVehicle colour.
  • notestringFree-text note.

Example response

{
  "data": {
    "id": "clx…",
    "type": "car",
    "subType": "MPV",
    "plate": "B 1234 XYZ"
  }
}
Possible errors:NOT_FOUNDVALIDATION_FAILED
PATCH/api/v1/vehicles/{id} Bearer token or API key

Update a vehicle

Parameters

  • idpath · stringrequiredVehicle id.

Request body

  • typestring`car` | `motorcycle` | `truck` | `bus`.
  • subTypestringVehicle sub-type.
  • platestringPlate number.
  • colorstringVehicle colour.

Example response

{
  "data": {
    "id": "clx…",
    "plate": "B 4321 ZYX"
  }
}
Possible errors:NOT_FOUNDVALIDATION_FAILED
DELETE/api/v1/vehicles/{id} Bearer token or API key

Delete a vehicle

Parameters

  • idpath · stringrequiredVehicle id.

Example response

{
  "data": {
    "success": true
  }
}
Possible errors:NOT_FOUND

Transactions

Recording a wash. Cucimoo records that a payment happened; it never processes one — `paymentMethod` is bookkeeping. Amounts are integers in rupiah.

GET/api/v1/cashier/context Bearer token or API key

Everything the cashier screen needs, in one call

Catalogue at this branch's prices, its active staff, the open shift, today's running total and the plan's staff-tag allowance. Built for a single round trip on a slow connection — call this on entering the sale screen, then POST /transactions.

Parameters

  • branchIdquery · stringrequiredBranch the cashier is working in.

Example response

{
  "data": {
    "businessName": "Cucimoo Wash",
    "branch": {
      "id": "clx…",
      "name": "Cabang Margonda",
      "timezone": "Asia/Jakarta",
      "address": "Jl. Margonda Raya No. 12, Depok",
      "phone": "0812-3456-7890",
      "receiptFooter": "Terima kasih atas kunjungan Anda."
    },
    "services": [
      {
        "id": "clx…",
        "name": "Cuci motor kecil",
        "vehicleType": "motorcycle",
        "price": 12000,
        "durationMinutes": 15,
        "color": "sky"
      }
    ],
    "staff": [
      {
        "id": "clx…",
        "name": "Agus"
      }
    ],
    "shift": {
      "id": "clx…",
      "openedAt": "2026-08-12T01:00:00.000Z",
      "openedByName": "Budi Santoso"
    },
    "summary": {
      "dateKey": "2026-08-12",
      "transactionCount": 14,
      "revenue": 320000,
      "byMethod": {
        "cash": 250000,
        "transfer": 0,
        "qris": 70000,
        "other": 0
      },
      "averageTicket": 22857
    },
    "maxStaffTags": -1,
    "recent": [
      {
        "id": "clx…",
        "code": "TRX-260812-0014",
        "total": 25000,
        "paymentMethod": "cash",
        "occurredAt": "2026-08-12T04:20:00.000Z"
      }
    ]
  }
}
Possible errors:VALIDATION_FAILEDNOT_FOUND
GET/api/v1/transactions Bearer token or API key

List transactions

Parameters

  • pagequery · integerPage number, from 1.
  • limitquery · integerItems per page, capped at 100.
  • branchIdquery · stringRestrict to one branch.
  • searchquery · stringMatches the receipt code, plate number or customer name.
  • statusquery · string`recorded` or `voided`.
  • paymentMethodquery · string`cash` | `transfer` | `qris` | `other`.
  • staffTagIdquery · stringOnly jobs this staff member was tagged on.
  • customerIdquery · stringOnly this customer's washes.
  • shiftIdquery · stringOnly transactions inside one shift.
  • dateFromquery · stringISO timestamp, inclusive.
  • dateToquery · stringISO timestamp, exclusive.

Example response

{
  "data": [
    {
      "id": "clx…",
      "code": "TRX-260812-0014",
      "branch": {
        "id": "clx…",
        "name": "Cabang Margonda",
        "timezone": "Asia/Jakarta"
      },
      "status": "recorded",
      "paymentMethod": "cash",
      "subtotal": 25000,
      "discount": 0,
      "discountType": "fixed",
      "discountValue": 0,
      "taxRate": 0,
      "taxAmount": 0,
      "total": 25000,
      "paidAmount": 50000,
      "changeAmount": 25000,
      "vehicleType": "motorcycle",
      "plateNumber": "B 1234 XYZ",
      "occurredAt": "2026-08-12T04:20:00.000Z",
      "recordedByName": "Budi Santoso",
      "customer": null,
      "items": [
        {
          "id": "clx…",
          "serviceId": "clx…",
          "name": "Cuci motor kecil",
          "unitPrice": 12000,
          "quantity": 1,
          "lineTotal": 12000
        }
      ],
      "staff": [
        {
          "id": "clx…",
          "name": "Agus"
        }
      ]
    }
  ],
  "meta": {
    "page": 1,
    "limit": 20,
    "total": 1,
    "pageCount": 1
  }
}
POST/api/v1/transactions Bearer token or API key

Record a wash

Prices *and tax* are resolved server-side: line prices come from the branch's catalogue (send `unitPrice` on a line only to override deliberately, as a manual discount) and the tax percent comes from the branch's `taxRate`, so it cannot be set per request. Both are snapshotted onto the sale as `taxRate`/`taxAmount`. Attaches to the branch's open shift automatically. Tagging more staff than the plan allows returns 402 `LIMIT_REACHED`.

Request body

  • branchIdstringrequiredBranch the wash happened at.
  • itemsarrayrequired`[{ serviceId?, name?, unitPrice?, quantity }]` — at least one line; `serviceId` or `name` is required per line.
  • staffTagIdsarrayIds of the staff who did the job. Plan-limited by `staffTagsPerTransaction`.
  • customerIdstringExisting customer, if known.
  • vehicleIdstringExisting vehicle; fills vehicle type and plate from the record.
  • vehicleTypestring`car` | `motorcycle` | `truck` | `bus` for a walk-in with no saved vehicle.
  • plateNumberstringPlate for a walk-in.
  • discountTypestring`fixed` (default) reads `discountValue` as rupiah; `percent` reads it as a percentage of the subtotal, capped at 100.
  • discountValueintegerThe discount as typed — rupiah when `discountType` is `fixed`, a percentage when it is `percent`. The resolved rupiah comes back as `discount`.
  • discountintegerLegacy flat discount in rupiah, capped at the subtotal. Used only when `discountValue` is absent.
  • paidAmountintegerCash the customer handed over. `changeAmount` is derived from it server-side; omit it when no cash was counted.
  • paymentMethodstring`cash` (default) | `transfer` | `qris` | `other`.
  • notesstringFree-text note.
  • occurredAtstringISO timestamp for backdating a forgotten sale. Defaults to now.

Example response

{
  "data": {
    "id": "clx…",
    "code": "TRX-260812-0015",
    "status": "recorded",
    "subtotal": 25000,
    "discount": 2500,
    "discountType": "percent",
    "discountValue": 10,
    "taxRate": 11,
    "taxAmount": 2475,
    "total": 24975,
    "paidAmount": 25000,
    "changeAmount": 25,
    "paymentMethod": "cash",
    "shiftId": "clx…",
    "items": [
      {
        "id": "clx…",
        "name": "Cuci motor kecil",
        "unitPrice": 12000,
        "quantity": 1,
        "lineTotal": 12000
      }
    ],
    "staff": [
      {
        "id": "clx…",
        "name": "Agus"
      }
    ]
  }
}
Possible errors:VALIDATION_FAILEDNOT_FOUNDLIMIT_REACHEDCONFLICTFORBIDDEN
GET/api/v1/transactions/{id} Bearer token or API key

Get one transaction

Parameters

  • idpath · stringrequiredTransaction id.

Example response

{
  "data": {
    "id": "clx…",
    "code": "TRX-260812-0015",
    "total": 25000,
    "status": "recorded"
  }
}
Possible errors:NOT_FOUND
PATCH/api/v1/transactions/{id} Bearer token or API key

Fill in details after the fact

Attaches the customer, vehicle, staff or payment method that Quick Transaction Mode skipped. **Amounts and line items are immutable** — a wrong amount is voided and re-recorded, so the trail stays honest.

Parameters

  • idpath · stringrequiredTransaction id.

Request body

  • customerIdstringAttach or (null) detach a customer.
  • vehicleIdstringAttach or (null) detach a vehicle.
  • vehicleTypestring`car` | `motorcycle` | `truck` | `bus`.
  • plateNumberstringPlate number.
  • paymentMethodstringCorrect how it was paid.
  • staffTagIdsarrayReplaces the whole staff tag set.
  • notesstringFree-text note.

Example response

{
  "data": {
    "id": "clx…",
    "code": "TRX-260812-0015",
    "staff": [
      {
        "id": "clx…",
        "name": "Agus"
      }
    ]
  }
}
Possible errors:NOT_FOUNDVALIDATION_FAILEDCONFLICTLIMIT_REACHED
POST/api/v1/transactions/{id}/void Bearer token or API key

Void a transaction

There is no DELETE for a sale. Voiding keeps the row, records who and why, and drops it out of every total.

Parameters

  • idpath · stringrequiredTransaction id.

Request body

  • reasonstringrequiredWhy it was voided, minimum 3 characters.

Example response

{
  "data": {
    "id": "clx…",
    "status": "voided",
    "voidedAt": "2026-08-12T05:00:00.000Z",
    "voidReason": "Salah input layanan"
  }
}
Possible errors:NOT_FOUNDVALIDATION_FAILEDCONFLICT

Shifts & cash reconciliation

A branch has at most one open shift. Transactions recorded while it is open attach to it automatically, and closing it compares counted cash against what was recorded.

GET/api/v1/shifts/current Bearer token or API key

The open shift for a branch

`data` is null when no shift is open — that is a normal state, not a 404.

Parameters

  • branchIdquery · stringrequiredBranch id.

Example response

{
  "data": {
    "id": "clx…",
    "branch": {
      "id": "clx…",
      "name": "Cabang Margonda",
      "timezone": "Asia/Jakarta"
    },
    "status": "open",
    "openedAt": "2026-08-12T01:00:00.000Z",
    "openedByName": "Budi Santoso",
    "openingCash": 100000,
    "totals": {
      "transactionCount": 14,
      "revenue": 320000,
      "byMethod": {
        "cash": 250000,
        "transfer": 0,
        "qris": 70000,
        "other": 0
      },
      "cashSales": 250000
    }
  }
}
Possible errors:VALIDATION_FAILEDNOT_FOUND
GET/api/v1/shifts Bearer token or API key

List shifts

Parameters

  • pagequery · integerPage number, from 1.
  • limitquery · integerItems per page, capped at 100.
  • branchIdquery · stringRestrict to one branch.
  • statusquery · string`open` or `closed`.

Example response

{
  "data": [
    {
      "id": "clx…",
      "status": "closed",
      "openedAt": "2026-08-11T01:00:00.000Z",
      "closedAt": "2026-08-11T13:00:00.000Z",
      "openingCash": 100000,
      "countedCash": 445000,
      "expectedCash": 450000,
      "difference": -5000,
      "transactionCount": 21
    }
  ],
  "meta": {
    "page": 1,
    "limit": 20,
    "total": 1,
    "pageCount": 1
  }
}
POST/api/v1/shifts Bearer token or API key

Open a shift

Returns 409 `CONFLICT` when the branch already has one open.

Request body

  • branchIdstringrequiredBranch the shift belongs to.
  • openingCashintegerFloat in the drawer at the start, in rupiah.
  • notesstringFree-text note.

Example response

{
  "data": {
    "id": "clx…",
    "status": "open",
    "openedAt": "2026-08-12T01:00:00.000Z",
    "openingCash": 100000
  }
}
Possible errors:VALIDATION_FAILEDCONFLICTNOT_FOUND
POST/api/v1/shifts/{id}/close Bearer token or API key

Close a shift and reconcile cash

Expected cash is the opening float plus recorded **cash** sales — transfer and QRIS never reach the drawer. A negative `difference` means the drawer is short.

Parameters

  • idpath · stringrequiredShift id.

Request body

  • countedCashintegerrequiredWhat was actually counted, in rupiah.
  • notesstringExplanation of a discrepancy.

Example response

{
  "data": {
    "id": "clx…",
    "status": "closed",
    "countedCash": 445000,
    "expectedCash": 450000,
    "difference": -5000
  }
}
Possible errors:NOT_FOUNDVALIDATION_FAILEDCONFLICT
GET/api/v1/shifts/{id} Bearer token or API key

Get one shift with its totals

Parameters

  • idpath · stringrequiredShift id.

Example response

{
  "data": {
    "id": "clx…",
    "status": "closed",
    "difference": -5000,
    "totals": {
      "transactionCount": 21,
      "revenue": 520000,
      "cashSales": 350000
    }
  }
}
Possible errors:NOT_FOUND

Reports

One endpoint covers the daily summary and the monthly report — the only difference is the window. Days are cut on the branch's own timezone. How far back the window may start is a plan limit (`reportRetentionDays`); a request beyond it is clamped rather than refused, and the response says where the wall is.

GET/api/v1/reports/summary Bearer token or API key

Revenue, trends, top services, staff and customer mix

Omit `branchId` for a consolidated view across every branch — that rollup requires the `multiBranchRollup` plan feature, and without it the report falls back to the primary branch. `window.clamped` is true when plan retention moved the start date forward.

Parameters

  • rangequery · string`today` | `yesterday` | `last7` | `last30` (default) | `thisMonth` | `lastMonth` | `custom`.
  • fromquery · string`YYYY-MM-DD`, used when `range=custom`.
  • toquery · string`YYYY-MM-DD`, used when `range=custom`.
  • branchIdquery · stringOne branch; omit for the multi-branch rollup.

Example response

{
  "data": {
    "window": {
      "fromKey": "2026-07-14",
      "toKey": "2026-08-12",
      "timezone": "Asia/Jakarta",
      "branchId": null,
      "retentionFloorKey": "2025-08-13",
      "clamped": false
    },
    "totals": {
      "transactionCount": 412,
      "revenue": 9840000,
      "averageTicket": 23883,
      "discount": 120000,
      "voidedCount": 3,
      "dayCount": 30,
      "busiestDayKey": "2026-08-09"
    },
    "byMethod": {
      "cash": 7200000,
      "transfer": 640000,
      "qris": 2000000,
      "other": 0
    },
    "byVehicleType": {
      "car": 6100000,
      "motorcycle": 3740000,
      "truck": 900000,
      "bus": 0,
      "unknown": 0
    },
    "daily": [
      {
        "dateKey": "2026-08-12",
        "transactionCount": 14,
        "revenue": 320000
      }
    ],
    "topServices": [
      {
        "id": "clx…",
        "name": "Cuci mobil kecil (sedan/hatchback)",
        "count": 118,
        "revenue": 4130000
      }
    ],
    "staff": [
      {
        "id": "clx…",
        "name": "Agus",
        "count": 190,
        "revenue": 4400000
      }
    ],
    "branches": [
      {
        "id": "clx…",
        "name": "Cabang Margonda",
        "count": 412,
        "revenue": 9840000
      }
    ],
    "hours": [
      {
        "hour": 9,
        "transactionCount": 48,
        "revenue": 1120000
      }
    ],
    "customers": {
      "newCount": 22,
      "returningCount": 61,
      "walkInCount": 329
    },
    "previous": {
      "transactionCount": 388,
      "revenue": 9120000
    }
  }
}
Possible errors:VALIDATION_FAILEDFORBIDDEN

Notifications

In-app notifications for the signed-in user.

GET/api/v1/notifications Bearer token

List notifications

Parameters

  • pagequery · integerPage number, from 1.
  • limitquery · integerItems per page, capped at 100.

Example response

{
  "data": [
    {
      "id": "clx…",
      "title": "Uji coba Premium aktif",
      "body": "Semua fitur Premium terbuka sampai 25 Agustus 2026.",
      "actionUrl": "/app/billing",
      "level": "info",
      "readAt": null,
      "createdAt": "2026-08-11T04:41:38.000Z"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 20,
    "total": 1,
    "pageCount": 1
  }
}
POST/api/v1/notifications/{id}/read Bearer token

Mark one notification as read

Parameters

  • idpath · stringrequiredNotification id.

Example response

{
  "data": {
    "success": true
  }
}
POST/api/v1/notifications/read-all Bearer token

Mark every notification as read

Example response

{
  "data": {
    "success": true,
    "count": 4
  }
}

Plans & subscription

The public plan catalogue, the tenant's current subscription, and starting a payment.

GET/api/v1/plans Public

Public plan catalogue

No authentication. Prices are decimal strings; limits use -1 for "unlimited".

Example response

{
  "data": [
    {
      "slug": "premium",
      "name": "Premium",
      "description": "Untuk usaha yang tumbuh: banyak cabang, banyak staf, dan laporan yang bisa diekspor.",
      "priceMonthly": "99000",
      "priceYearly": "990000",
      "currency": "IDR",
      "trialDays": 14,
      "limits": {
        "branches": 5,
        "staffTagsPerTransaction": -1,
        "customers": -1,
        "services": -1,
        "reportRetentionDays": 365
      },
      "features": {
        "export": true,
        "multiBranchRollup": true,
        "apiAccess": false,
        "prioritySupport": false
      }
    }
  ]
}
GET/api/v1/subscription Bearer token or API key

Current subscription state

Includes trial days remaining and the grace deadline when payment has failed.

Example response

{
  "data": {
    "status": "trialing",
    "period": "monthly",
    "plan": {
      "slug": "premium",
      "name": "Premium"
    },
    "trialEndsAt": "2026-08-25T04:41:38.000Z",
    "trialDaysRemaining": 11,
    "currentPeriodEnd": null,
    "graceEndsAt": null,
    "needsBranchSelection": false
  }
}
POST/api/v1/subscription/checkout Bearer token

Start a subscription payment

Returns a hosted payment page URL to send the user to. `token` and `clientKey` are present when the active gateway supports an embedded checkout — do not hard-code a gateway, the active one can change. Not available inside an impersonated session.

Request body

  • planSlugstringrequiredTarget plan, e.g. `premium`.
  • periodstring`monthly` (default) or `yearly`.

Example response

{
  "data": {
    "orderId": "CM-M0X2K1-AB12",
    "redirectUrl": "https://pay.example-gateway.com/redirect/66e4fa55…",
    "token": "66e4fa55-fdac-4ef9-91b5-733b97d1b862",
    "clientKey": "SB-Mid-client-…",
    "amount": 99000,
    "expiresAt": "2026-08-11T05:41:38.000Z"
  }
}
Possible errors:VALIDATION_FAILEDNOT_FOUNDFORBIDDEN

App settings

Branding and client configuration that the mobile app reads at launch.

GET/api/v1/settings/public Public

Public app settings

No authentication. Read this before the login screen so the app can show branding, a support contact, and a forced-update prompt when it is below `minimumMobileVersion`.

Example response

{
  "data": {
    "company": {
      "name": "Cucimoo",
      "tagline": "Kelola usaha cuci kendaraan Anda tanpa ribet",
      "logoUrl": "",
      "email": "halo@cucimoo.id",
      "whatsapp": "628120000000"
    },
    "minimumMobileVersion": "1.0.0",
    "maintenanceMode": false,
    "maintenanceMessage": "",
    "registrationOpen": true,
    "timezones": [
      "Asia/Jakarta",
      "Asia/Makassar",
      "Asia/Jayapura"
    ]
  }
}

Dart quickstart

Login plus one authenticated call, using the http package. Copy it into a Flutter project and it runs as-is.

main.dart
import 'dart:convert';
import 'package:http/http.dart' as http;

const baseUrl = 'http://localhost:3000/api/v1';

/// Signs in and returns the session token.
Future<String> login(String identifier, String password) async {
  final response = await http.post(
    Uri.parse('$baseUrl/auth/login'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode({'identifier': identifier, 'password': password}),
  );

  final json = jsonDecode(response.body) as Map<String, dynamic>;

  if (response.statusCode >= 400) {
    final error = json['error'] as Map<String, dynamic>;
    // Switch on error['code'] (English); show error['message'] (Bahasa Indonesia).
    throw Exception('${error['code']}: ${error['message']}');
  }

  return json['data']['token'] as String;
}

/// Loads the current user, tenant, plan entitlements and branches.
Future<Map<String, dynamic>> fetchMe(String token) async {
  final response = await http.get(
    Uri.parse('$baseUrl/me'),
    headers: {'Authorization': 'Bearer $token'},
  );

  final json = jsonDecode(response.body) as Map<String, dynamic>;

  if (response.statusCode >= 400) {
    final error = json['error'] as Map<String, dynamic>;
    throw Exception('${error['code']}: ${error['message']}');
  }

  return json['data'] as Map<String, dynamic>;
}

Future<void> main() async {
  final token = await login('budi@example.com', 'rahasia123');
  final me = await fetchMe(token);
  print('Signed in to ${me['tenant']['name']}');
}

Webhooks

Cucimoo receives webhooks from its payment gateway at POST /api/webhooks/payments/{provider}. That endpoint is not part of the public API — it is authenticated by the gateway's signature rather than by a token, and every callback is stored with a deduplication key so provider retries are harmless.

Cucimoo does not yet send outbound webhooks to tenants. Poll GET /subscription after a checkout to observe the state change; a paid subscription usually settles within seconds of the gateway confirming.

Reserved endpoints

These paths are reserved for the wash domain and are not implemented yet. They are listed so you can plan around them; they do not appear in the OpenAPI document until they ship.

  • /api/v1/receipts/{id}Shareable digital receipt for a transaction.
  • /api/v1/staff/{id}/commissionCommission and payroll calculation per staff member.
  • /api/v1/promosDiscount codes and loyalty rewards.

Versioning & changelog

The version lives in the path. /api/v1 is frozen: we will add endpoints and add optional response fields, but we will not remove a field, rename one, or change the meaning of an existing value. A breaking change ships as /api/v2.

1.1.0

2026-08-12
  • Add the wash domain: service catalogue with per-branch price overrides, customers and their vehicles, transactions with line items and staff tagging, shifts with cash reconciliation, and reporting.
  • Add GET /cashier/context — one call that boots the point-of-sale screen.
  • Money in these endpoints is an integer number of rupiah, not a decimal string.
  • Additive only: no existing endpoint, field or error code changed.

1.0.0

2026-08-11
  • Initial public release of /api/v1.
  • Authentication, current user, branches, staff tags, notifications, plans, subscription and public settings.