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):
| Field | Required | Rules |
|---|---|---|
email | Yes | Valid email |
password | Yes | — |
device_id | No | String |
device_token | No | String |
platform | No | ios 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 settingscalendar_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 withid,type,type_label,status,status_label,start/endasH:i, customer, address, unit model/serial,due_atISO, optionalinvoice{ id, status }).
Query:
user_id— If the authenticated usercanSuperviseWorkOrders()(work_orders.superviseor legacy permissionsupervise 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_approvalwithinwork_order_tap2pay_expiry_hours_after_waiting_approval_transitionhours (setting, default 24), and the invoice is missing or notpaid. - Completed + unpaid invoice: Status
completedwith a related invoice insentstatus.
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):
| Field | Required | Notes |
|---|---|---|
status | Yes | Target status string (see transitions below) |
note | No | Max 500 chars; written to the latest status history row when provided |
lat, lng | No | If both set and the new status is ongoing, waiting_approval, or paused, stored on the latest status history |
customer_signature or signature | No | Base64 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_accepted | No | Boolean; when moving to waiting_approval, updates the unit's is_service_accepted |
customer_email, customer_phone | No | When moving to waiting_approval, updates the unit's customer if present |
confirm_skip_optional | No | Boolean; required when checklist feature is on and optional checklist lines remain incomplete (see below) |
Transition rules match WorkOrder::getAllowedTransitions (same as web):
| Current status | Technician may move to | Supervisor may also |
|---|---|---|
pending | ongoing | cancelled |
ongoing | waiting_approval, paused | cancelled |
waiting_approval | — | ongoing, completed, cancelled |
paused | ongoing | cancelled |
completed | — | billed |
Checklists (when featureChecklistsEnabled()):
- Moving to
waiting_approvalorcompleted: all lines withis_required_snapshotmust be completed unless the user haschecklists.bypass_required_items. - If any non-required lines are still incomplete, the API returns
422onconfirm_skip_optionaluntil you retry withconfirm_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:
| Field | Required | Notes |
|---|---|---|
checked | Yes | Boolean; false marks the line incomplete (photos on the line are not deleted by this endpoint) |
result | No | pass, 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_fail | No | pass or fail |
result_numeric | No | Number |
result_text | No | Max 1000 |
notes | No | Max 1000 |
override_frequency | No | Boolean; 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 withunit(andunit.customer).technician,items,invoice,checklist,photos,notes,status_histories— only if those relations were loaded.pdf_url— only whenpdf_pathis 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).
Related docs
- Work order lifecycle — status meanings and office-side steps.
- Checklists — result types, frequency, and gates that also apply on mobile.
- Technician permissions — roles and
work_orders.supervise. - Mobile and field context — browser technician experience vs native app.