Mobile API (device tokens)
Every /api/v1/mobile endpoint used by the Android sales app — auth, request bodies with real field names, responses and errors.
The Mobile API powers the ZodiacTech CRM Android app (com.zodiactech.crm). It is a separate surface from the REST API: per-user device tokens instead of API keys, and a {"data": ...} / {"error": {"message","code"}} envelope.
Base URL https://crm.zodiactechsoft.in/api/v1/mobile/ in production. The XAMPP dev default baked into the app is http://10.0.2.2/Lead%20Hub/api/v1/mobile/, and the agent can override the server URL on the login screen under Advanced → Server URL (must start with http:///https:// and end with /).
Auth flow and token lifecycle: Authentication. Endpoint list below is the exact set from routes/api.php and the app's LeadHubApi.kt Retrofit contract.
Who this is for
- Developer — extending the Android app or building another mobile client.
- Support — confirming what the app sends before blaming "the app" for a sync issue.
Prerequisites: a device token (or the ability to log in and mint one).
Envelopes
- Success:
{"data": ...}— paginated lists add ametaobject. - Failure:
{"error": {"message": "...", "code": "..."}}. - Exception: the capability push returns a literal
{"ok": true}(documented below).
Public endpoints (no token)
POST /login
Password login. Throttled 10/min/IP.
{
"identifier": "agent@example.com",
"password": "your-password",
"device_uuid": "unique-device-id"
}
identifieraccepts e-mail or phone. A legacyemailfield is also accepted.device_uuidis required; re-login on the same UUID rebinds the device record.
Response data: token, expires_at (90 days), user{id,name,email,phone,role}, tenant{id,name,slug,timezone}, device{...}.
Errors: INVALID_CREDENTIALS (401), IDENTIFIER_REQUIRED (422), invalid_identifier (422), ACCOUNT_SUSPENDED (403), NO_WORKSPACE (403), WORKSPACE_INACTIVE (403), login_method_disabled (403), ACCOUNT_LOCKED (429 — locked out; message includes a retry time), RATE_LIMITED (429 — only the route throttle on this path).
GET /auth-config
Platform capability probe the app calls before showing login options: the platform-default method list (password / OTP) and the platform Firebase project details. It is deliberately platform-only — workspace overrides are resolved later, at login and OTP calls, once the account is known.
GET /app-config
Public (an optional device token unlocks tenant overrides). Serves the force-update gate, announcement banner and support-contact card (App\Services\MobileApp\MobileAppConfig). Never returns 401.
POST /otp/start
{ "identifier": "+919876500000" }
Sends a one-time code. Limits: 3 per identifier and 10 per IP per 10 minutes (in addition to the route throttle). Codes: invalid_identifier (422), user_not_found (404), RATE_LIMITED (429, includes retry_after). otp_unavailable / otp_send_failed are returned so the endpoint does not reveal whether an account exists.
POST /otp/verify
{ "identifier": "+919876500000", "code": "123456", "device_uuid": "unique-device-id" }
device_uuid is required (re-login on the same UUID rebinds the device record); platform / model are optional. For the Firebase path send id_token instead of code.
Returns the same login payload as /login (token, expiry, user, tenant, device). Errors: code_required (422), otp_invalid (401) after a wrong code (max 5 attempts), RATE_LIMITED (429).
Authenticated endpoints
All of the following require Authorization: Bearer <device-token> and run behind DeviceAuthenticate, which binds both the user and the workspace.
POST /logout
Revokes the current token (revoked_at). Log in again to obtain a new one.
GET /me
Returns the authenticated user, tenant and device records.
GET /home
Dashboard summary:
| Field | Meaning |
|---|---|
greeting_name |
First name for the greeting |
assigned_leads_count |
Leads assigned to this user |
follow_ups_due_count |
Tasks due |
calls_logged_today / calls_connected_today / calls_missed_today |
Today's call counters |
whatsapp_sent_today |
WhatsApp clicks/messages logged today |
appointments_upcoming |
Upcoming appointment count |
latest_lead |
Most recently assigned/created lead summary |
next_follow_up |
The nearest due task |
server_time |
Server clock for the app to sync against |
POST /device/register
Upserts this device's metadata. Body (all optional, but this is what the app sends):
{
"sim_label": "Jio",
"sim_number_hint": "******21",
"push_token": "fcm-registration-token",
"model": "Pixel 8",
"os_version": "Android 15",
"app_version": "2.0.12"
}
Masked hints only sim_number_hint must match ******12 style — at most 6 asterisks followed by 2–6 digits. A full phone number is rejected. The server never stores the device's real MSISDN in this field.
POST /device/heartbeat
Keeps the device marked alive. Response data: server_time, device_id, last_seen_at.
GET /device/exclusions
List of phone numbers excluded from call logging — the merge of workspace-wide and this-device exclusions.
POST /device/exclusions
{ "phone_number": "+919876500000", "label": "Personal", "scope": "device" }
scope is device or tenant (default tenant). Matching uses the last 10 digits in both directions. A duplicate add returns duplicate: true instead of an error. Errors: INVALID_PHONE (422, message A valid phone number is required.).
DELETE /device/exclusions/{id}
Removes an exclusion. Response: { "deleted": true }.
POST /devices/capabilities
Pushes a call-capability snapshot (what recording modes this phone supports). Append-only; the server prunes to the latest 20 snapshots per device.
Different envelope This endpoint returns a literal {"ok":true}, not the {"data":...} envelope.
GET /leads
Query params: per_page (default 25, max 50), search (name/phone/email), stage_id.
Each list item: id, first_name, last_name, name, phone, email, company, job_title, city, source, source_label, status, stage{id,name,color}, next_follow_up_at.
GET /leads/{id}
Lead detail plus: pipeline_stages (for the stage picker), recent_calls (last 10), open_tasks, recent_notes (last 5). {id} must be numeric.
POST /leads/{id}/stage
{ "pipeline_stage_id": 4 }
The stage must belong to the same workspace. Response: lead_id and the new stage{id,name,color}. Logs a stage-moved activity.
POST /leads/{id}/notes
{ "body": "Called — asked for a site visit on Saturday." }
body required, max 5000. Responds 201 with id, body, created_at.
POST /leads/{id}/follow-ups
{
"title": "Site visit confirmation",
"due_at": "2026-10-08T11:00:00+05:30",
"priority": "high",
"notes": "Confirm with family"
}
due_at required; title optional (defaults to "Follow up with {lead name}"); priority must be one of the workspace's task priorities (server default: normal). Responds 201 with id, title, due_at, priority.
POST /leads/{id}/whatsapp-click
{ "message": "Hi, sharing the brochure as discussed." }
Logs a click-to-chat touch on the lead timeline. message optional, max 2000. Responds 201 with id, status: "clicked", created_at.
Honest logging This endpoint records intent only — the message is actually sent from the agent's own WhatsApp app. It never claims delivery.
POST /call-events
Batch upload of SIM call events (max 100 per request):
{
"events": [
{
"idempotency_key": "device-uuid:1712345678",
"phone_number": "+919876500000",
"direction": "outgoing",
"started_at": "2026-10-05T09:10:00+05:30",
"ended_at": "2026-10-05T09:12:40+05:30",
"duration_seconds": 160,
"sim_label": "Jio",
"contact_name": "Rohan",
"recording_hint": true,
"device_local_id": "evt-8812"
}
]
}
Per-event field notes:
| Field | Rules |
|---|---|
idempotency_key |
Required, max 64 — unique per workspace; duplicates never create a second call |
phone_number |
Required, max 40; matched on digits only, workspace-scoped |
direction |
incoming, outgoing, missed or unknown |
started_at / ended_at |
Optional dates |
duration_seconds |
Optional, 0–86400 |
sim_label, contact_name, disposition |
Optional metadata |
recording_hint |
Boolean — the app thinks a recording may exist |
device_local_id |
The app's local row id, echoed back |
Each event comes back with a status:
created— a call record was written (andcall_loggedactivity if matched to a lead)duplicate— idempotency key already seen; the existing row id is returnedexcluded— number is on the exclusion list; no row is createdinvalid— withreason: "unparseable_phone"
GET /calls
Call history, newest first. Query: direction (outgoing / incoming / missed / all), page, per_page (default and max 50). Rows include recording_id, has_recording, disposition, notes and the matched lead (lead fields are null when the call belongs to another agent in a restricted workspace).
PATCH /calls/{id}
Post-call editing:
{
"disposition": "connected",
"notes": "Interested, wants pricing",
"create_followup": { "due_at": "2026-10-07T10:00:00+05:30", "title": "Send pricing" }
}
dispositionmust be one of:connected,voicemail,no_answer,not_interested,callback.notesreplaces the stored note (sendnullto clear), max 2000.create_followup.due_atis required whenevercreate_followupis present.
Errors: CALL_NOT_LINKED (422 — the call is not matched to a lead), NOT_FOUND (404).
GET /appointments
Upcoming appointments, soonest first (statuses confirmed, scheduled, pending in the future). Query ?past=1 returns history instead. per_page default and max 100.
GET /follow-ups
Every open task in the workspace, soonest first — the full list behind the Home tile's follow_ups_due_count. Not paginated; meta.total is the open-task count and meta.overdue counts those already past due. Rows: id, lead_id, lead_name, lead_phone, title, notes, due_at.
POST /recordings
Multipart upload of a call recording.
| Part | Rules |
|---|---|
file |
Required, ≤ 20 MB, audio/* MIME |
lead_call_id |
Target call (or use idempotency_key instead) |
idempotency_key |
Alternative dedupe key — a repeat returns duplicate: true |
Stored on the local disk under recordings/{tenant}/{lead_call}/. Errors: NUMBER_EXCLUDED (422), INVALID_MIME (422), CALL_NOT_FOUND (404).
GET /recordings/{id}
Private, workspace-scoped audio stream. After the 30-day retention window the file is purged and the API returns 410 GONE. An unknown or cross-workspace id returns 404 NOT_FOUND; FILE_MISSING (404) means the stored row exists but its file is gone from disk.
GET /notifications
Latest 30 notifications plus unread_count.
POST /notifications/read-all
Marks everything read.
POST /notifications/{id}/read
Marks one notification read. Unknown id → 404 NOT_FOUND.
Common mistakes
- Sending a full phone number in
sim_number_hint— rejected by the masked-only rule. - Reusing an
idempotency_keyfor a different call — the second event is silently treated as a duplicate. - Uploading recordings larger than 20 MB or with a
video/*MIME — both fail 422. - Expecting
{"data":...}from/devices/capabilities— it is the{"ok":true}exception.
Troubleshooting
| Symptom | Check |
|---|---|
| App shows "session expired" | Token 90-day TTL passed or was revoked — log in again |
| Calls not appearing in the CRM | Call events excluded by number match, or excluded status in the batch response |
CALL_NOT_LINKED on PATCH |
The call's number does not match any lead in the workspace |
| 410 on recording playback | 30-day retention purge — see Data retention & privacy (GDPR) |
Technical details
- Routes:
routes/api.php(prefixv1/mobile, nameapi.mobile.*); middlewareApp\Http\Middleware\DeviceAuthenticate. - Controllers:
App\Http\Controllers\Api\Mobile\*(AuthControllerwithTOKEN_TTL_DAYS = 90,HomeController,LeadController,CallEventController,CallHistoryController,RecordingController,DeviceController,CapabilityController,AppConfigController,AppointmentController,NotificationController). - Call dispositions (server source of truth):
connected,voicemail,no_answer,not_interested,callback(CallHistoryController::DISPOSITIONS, mirrored in the app'sDto.kt).
Doc vs app labels Some Android release notes show user-facing chip labels like "Connected / Interested / Callback". The API contract is the code list above — integrations should use those disposition values.
Related guides
Was this page helpful?
Thanks — noted. ✦