Skip to main content

Mobile API

The mobile API is a JSON REST surface at {APP_URL}/api/mobile/... (Laravel registers routes/api.php under the /api prefix). Native clients authenticate with a Laravel Sanctum personal access token created at login. The browser technician UI uses the regular web session instead; this page documents only the /api/mobile contract.

Authentication

Login (public)

POST /api/mobile/auth/login

Body (JSON):

FieldRequiredRules
emailYesValid email
passwordYes
device_idNoString
device_tokenNoString
platformNoios or android

Rate limiting: Failed attempts are tracked per client IP (RateLimiter key login:{ip}). After 5 failures, Laravel throws a validation error on email with a cooldown message. Each bad password calls RateLimiter::hit(..., 60) (60-second decay). Successful login clears the counter.

Eligibility: Only users with Spatie role technician or admin may log in. Others receive a validation error on email.

HTTP status: Wrong password, rate limit, invalid payload, and the “no mobile access” role gate all use Laravel 422 with a JSON body (typically message and/or errors), not 401. 401 is only returned by mobile.api.auth when a protected route has a missing or invalid Bearer token.

Response 200: data.user (id, name, email, roles, permissions, location_tracking_enabled), plus data.token (plain-text Sanctum token). If device_id is present, mobile_device_id, mobile_device_token, and mobile_platform are updated; if device_id is omitted, existing device fields are left unchanged.

Protected requests

Send the token in the header:

Authorization: Bearer {token}

Middleware mobile.api.auth resolves the token via PersonalAccessToken::findToken, attaches the user, and updates last_used_at. Missing or invalid tokens return 401 JSON: message describes the reason (No token provided, Invalid token, Token expired, User not found).

Tokens are created with ability mobile:access (see AuthController::login).

Logout

POST /api/mobile/auth/logout (authenticated)

Clears mobile_device_id and mobile_device_token on the user, flushes the session, returns 200 with message. The current access token is not revoked in the database; discard it on the client. (Create a fresh token on the next login.)

Current user

GET /api/mobile/auth/user (authenticated)

Returns 200 with a user object (same shape as login, without token).


Schedule

GET /api/mobile/schedule (authenticated)

Returns today and tomorrow work for one technician. Payload matches the web GET /home/schedule shape from TechnicianScheduleService::buildForTechnician:

  • start_hour, end_hour — integers from settings calendar_start_hour / calendar_end_hour (defaults 8 and 20).
  • days — array of two objects: date (Y-m-d), label (Today / Tomorrow), work_orders (compact rows with id, type, type_label, status, status_label, start / end as H:i, customer, address, unit model/serial, due_at ISO, optional invoice { id, status }).

Query:

  • user_id — If the authenticated user canSuperviseWorkOrders() (work_orders.supervise or legacy permission supervise work orders), you may pass another user's id to load their schedule. Otherwise the schedule is always for the current user.

Headers: Cache-Control: no-store


Work orders — list

GET /api/mobile/work-orders (authenticated)

Pagination: 20 per page. The JSON payload is { "work_orders": [ ... ], "meta": { "current_page", "last_page", "per_page", "total" } }. Each element uses WorkOrderResource, but index only eager-loads unit.customer, technician, workOrderItems.item, and invoice. So list rows include scalars plus customer, unit, technician, items, and invoice when present. They do not include checklist, photos, notes, or status_histories (those keys are omitted unless the relation is loaded). Use GET /work-orders/{id} for the full shape.

Assignment: The list is scoped to technician_id = current user. Supervisors do not see other technicians' jobs in this list; they can still open a specific work order by id if ensureWorkOrderAccess passes (same assignment or supervise).

Query:

  • status — When set, filters to that status only (still only for the current user's assignments).

Default filter (no status): Active statuses pending, ongoing, paused, plus:

  • Tap to Pay follow-up: Work orders where the technician moved the job to waiting_approval within work_order_tap2pay_expiry_hours_after_waiting_approval_transition hours (setting, default 24), and the invoice is missing or not paid.
  • Completed + unpaid invoice: Status completed with a related invoice in sent status.

Ordered by due_at, then created_at descending.


Work orders — detail, patch, status

Show

GET /api/mobile/work-orders/{workOrder} (authenticated)

{workOrder} is the numeric primary key. Loads unit/customer, technician, line items, invoice, checklist instances (with item photos_count when loaded), photos, notes, status histories.

Response 200: { "work_order": { ... WorkOrderResource } }

404: No work order with that id (implicit route model binding).

403: Not assigned and not a supervisor.

Patch (suggestions only)

PATCH /api/mobile/work-orders/{workOrder} (authenticated)

Body: optional suggestions (string).

Response 200: work_order (refreshed resource) and message: Work order updated.

Change status

POST /api/mobile/work-orders/{workOrder}/status (authenticated)

Body (JSON):

FieldRequiredNotes
statusYesTarget status string (see transitions below)
noteNoMax 500 chars; written to the latest status history row when provided
lat, lngNoIf both set and the new status is ongoing, waiting_approval, or paused, stored on the latest status history
customer_signature or signatureNoBase64 image; when transitioning to waiting_approval, saved under path_customer_signature (PNG on public disk). May be replicated to same-day sibling unsigned WOs for the same technician + customer
is_service_acceptedNoBoolean; when moving to waiting_approval, updates the unit's is_service_accepted
customer_email, customer_phoneNoWhen moving to waiting_approval, updates the unit's customer if present
confirm_skip_optionalNoBoolean; required when checklist feature is on and optional checklist lines remain incomplete (see below)

Transition rules match WorkOrder::getAllowedTransitions (same as web):

Current statusTechnician may move toSupervisor may also
pendingongoingcancelled
ongoingwaiting_approval, pausedcancelled
waiting_approvalongoing, completed, cancelled
pausedongoingcancelled
completedbilled

Checklists (when featureChecklistsEnabled()):

  • Moving to waiting_approval or completed: all lines with is_required_snapshot must be completed unless the user has checklists.bypass_required_items.
  • If any non-required lines are still incomplete, the API returns 422 on confirm_skip_optional until you retry with confirm_skip_optional: true.

Response 200: work_order (refreshed) and message: Work order status updated successfully.

422: Validation (illegal transition, checklist gates, etc.).


Work orders — photos and notes

Upload photo (work order)

POST /api/mobile/work-orders/{workOrder}/photos (authenticated)

Body: photo — required string: base64 data URL or raw base64; a data:image/...;base64, prefix is stripped. Stored as JPEG under work-orders/{id}/photos/.

Response 200: photo.id, photo.url, photo.uploaded_at, message.

Add note

POST /api/mobile/work-orders/{workOrder}/notes (authenticated)

Body: content — required string, max 1000 chars (stored in DB column note; JSON exposes it as content).

Response 200: note object with id, content, user (name), created_at ISO, plus message.


Work orders — checklists

Routes use the WorkOrderChecklistItem primary key (the checklist line id), not the template item id.

Toggle / complete line

POST /api/mobile/work-orders/{workOrder}/checklist/{item} (authenticated)

{item} = work_order_checklist_items.id belonging to this work order.

Body:

FieldRequiredNotes
checkedYesBoolean; false marks the line incomplete (photos on the line are not deleted by this endpoint)
resultNopass, fail, or n/a. Only pass and fail set result_pass_fail via this field; n/a does not (it resolves like omitting pass/fail). Prefer result_pass_fail when you need an explicit value.
result_pass_failNopass or fail
result_numericNoNumber
result_textNoMax 1000
notesNoMax 1000
override_frequencyNoBoolean; requires checklists.override_frequency permission

When checked is true: required result_type_snapshot = photo lines must have at least one photo or the API returns 422 on photos.

Response 200: With checked: true, the body includes message and an item summary (id, is_completed, result fields, completed_at, photos_count). With checked: false, the body is only message (Checklist item marked incomplete).

404: Unknown checklist line id or line not on this work order (firstOrFail).

Upload photo (checklist line)

POST /api/mobile/work-orders/{workOrder}/checklist-items/{checklistItem}/photos (authenticated)

{checklistItem} = same work_order_checklist_items.id.

Body: photo — base64 string (same rules as work-order photo). Invalid base64 → 422 on photo.

Response 200: photo.id, photo.url, message.


Work orders — QR and unit serial

Assign QR to unit

POST /api/mobile/work-orders/{workOrder}/assign-qr (authenticated)

Body: guid — required UUID string.

Errors 422 JSON error: no unit on the work order, unit already has a QR, or GUID already used on another unit.

Success 200: { "success": true, "message": "..." }

Extract serial/model from label photo

POST /api/mobile/work-orders/{workOrder}/extract-serial (authenticated)

Body: photo — base64 image (data-URL prefix stripped like other photo endpoints). Persists a non-deletable work-order photo, then calls OpenAiService::extractFromLabel.

Response: Always 200 unless validation fails (422 for bad base64 / missing photo) or the work order has no unit (422 error). On success with extracted text: success: true, serial_number, model_number, photo_id. When the model returns no serial and no model: 200 with success: false, error (message), and photo_id (the saved label image for retry).

Save serial/model (and optional GPS)

POST /api/mobile/work-orders/{workOrder}/save-serial (authenticated)

Body: serial_number, model_number (each optional string max 255, but at least one required), optional lat, lng.

Success 200: { "success": true, "message": "..." }


Location tracking

POST /api/mobile/location (authenticated) — Throttle: throttle:100,1 (100 requests per minute; for authenticated requests the limiter resolves per authenticated user, not per IP).

403 if location_tracking_enabled is false for the user.

Body: latitude, longitude (required, valid ranges), optional accuracy, speed, heading, activity_type (still | walking | running | driving | cycling | unknown), is_moving, recorded_at (date; defaults to now).

Response 200: message, location (created model attributes).

History

GET /api/mobile/location/history (authenticated)

Returns rows for the authenticated user only (user_id = caller).

Query: optional start_date, end_date (end_date must be ≥ start_date), limit (1–1000, default 100).

Response: locations (newest first by recorded_at), count. Each row is the TechnicianLocation model as JSON (ids, lat/lng, timestamps, etc.).

Latest

GET /api/mobile/location/latest (authenticated)

404 if no rows. 200: { "location": { ... } }.


Payment config (v1)

GET /api/mobile/v1/payment-config (authenticated)

Returns non-secret integration metadata for CaribeTickets + Stripe Terminal:

{
"data": {
"caribetickets_api_url": "...",
"stripe_publishable_key": "...",
"terminal_location_id": "..."
}
}

Values come from config/services.php (services.caribetickets.*). Headers: Cache-Control: private, no-store, must-revalidate.


WorkOrderResource summary

Every work_order includes scalar fields: id, type, type_label, status, status_label, timestamps (due_at, site_arrived_at, job_completed_at, customer_signed_off_at, paused_until, created_at, updated_at), confirmation_state, suggestions, customer_requests, estimated_duration, allowed_transitions (from WorkOrder::getAllowedTransitions for the current API user), needs_approval, public_url.

Nested objects use when($relationLoaded) in code — they appear only when the controller eager-loaded the relation:

  • customer, unit — loaded with unit (and unit.customer).
  • technician, items, invoice, checklist, photos, notes, status_histories — only if those relations were loaded.
  • pdf_url — only when pdf_path is set on the model.

GET /work-orders (list) loads unit.customer, technician, workOrderItems, invoice only — see the list section above. GET / PATCH / POST .../status load the full set used in show / updateStatus (including checklist lines with photos_count, photos, notes, status histories).