Zodiac Tech CRM Docs

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.

developer support Advanced ⏱ 25 min Updated 2026-10-06

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 a meta object.
  • 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"
}
  • identifier accepts e-mail or phone. A legacy email field is also accepted.
  • device_uuid is 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 {&quot;ok&quot;:true}, not the {&quot;data&quot;:...} 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 (and call_logged activity if matched to a lead)
  • duplicate — idempotency key already seen; the existing row id is returned
  • excluded — number is on the exclusion list; no row is created
  • invalid — with reason: "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" }
}
  • disposition must be one of: connected, voicemail, no_answer, not_interested, callback.
  • notes replaces the stored note (send null to clear), max 2000.
  • create_followup.due_at is required whenever create_followup is 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_key for 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 (prefix v1/mobile, name api.mobile.*); middleware App\Http\Middleware\DeviceAuthenticate.
  • Controllers: App\Http\Controllers\Api\Mobile\* (AuthController with TOKEN_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's Dto.kt).

Doc vs app labels Some Android release notes show user-facing chip labels like &quot;Connected / Interested / Callback&quot;. The API contract is the code list above — integrations should use those disposition values.

Was this page helpful?