TruvaLI Mobile API Documentation

A generic API for BackOffice's mobile client: a JSON mirror of the Django admin. Every model visible in the web interface is listed, every operation possible on the web is possible, nothing more.

Overview

All endpoints are served under the /api/mobile/ prefix and speak JSON. The API consists of three layers:

Layer Endpoints What it returns
Identity auth/qr, auth/refresh, auth/logout, me Device pairing via QR, access/refresh token pair
Schema schema/, schema/<app>/<model>/ Admin metadata that shapes the UI: columns, filters, form fields, permissions
Data data/<app>/<model>/... Paginated list, detail, create / update / delete, FK search
Case work …/process, …/decision, …/ai Deciding an approval request, marking a record, the AI review panel of a case file
Dashboard dashboard/… The blocks of the web dashboard, ready to draw
Reports reports/results/<pk>/ The header of a report result and one page of its output
Assistant assistant/… The Truvali assistant: conversations, long polling, approval of a tool call
Notifications devices/push-token, notifications/… Push registration, the notification list and the bell counter
Live websocket Real-time notifications: new alerts, incoming call. Should be treated as one-way; data is still read from REST
Schema-driven client

The mobile app does not hardcode model names, columns or form fields; it reads all of them from the schema endpoints. When a new field, filter or model is added to the web admin, no mobile update is needed: the schema returns its new shape on the next request. So treat an "unknown field/type" in the client as the normal case, not an error: render an unrecognized field type as plain text.

Response format

Every endpoint uses the same envelope:

{
  "status": true,          // the result of the operation (false on error)
  "message": "Updated.",   // human-readable summary
  "data": { ... }          // endpoint-specific payload
}

HTTP status codes

Status Meaning
200 Success (list, detail, update, delete)
201 Record created
202 The operation was placed in the approval queue, not yet written to the database (see Maker-Checker)
400 Invalid request: malformed JSON, unknown list parameter, or form validation error (data.errors)
401 Token missing / invalid / expired, or device revoked
403 No permission (would not have been possible in the web admin either)
404 Model is not registered with the admin, or object not found
405 Endpoint does not support this HTTP method
409 Conflict: the operation has already been performed, or the state forbids it right now (a second note on the same AI review, availability changed while in a call)
502 / 503 A dependent service cannot be reached, or is not installed in this environment; only the AI review endpoints answer this way

Dates and time zones

Every date and time in the API is ISO 8601 and UTC, to the second, with no offset written out: 2026-12-31T16:00:35 for a timestamp, 2026-12-31 for a date. This holds everywhere: the list cell (kind: date), the value and display of a detail field, the approval block, a notification.

The zone is stated rather than implied: every list and detail response carries timezone (always "UTC") at the root of data, and session info repeats it as datetime_timezone. Formatting is the application's job.

The user's web preference travels with the session

/me/ returns the format the user chose in the web panel as user.date_format, user.time_format and user.show_today (whether today's timestamps should read "Today, 16:30"). An application that wants the same look on both screens can apply them; nothing requires it to.

Permission model

Every request runs in the context of the real staff user the token belongs to. Permissions are not reimplemented; they are read directly from the web admin's own ModelAdmin classes:

QR pairing flow

This is the same as WhatsApp's device-linking flow:

  1. The user opens their own user page in the web admin (Users → their own record). A QR code appears in the "Mobile application" section at the bottom of the page.
  2. The QR code is single-use and must be scanned within 5 minutes; if it expires, refreshing the page gets a new one.
  3. The mobile app scans the QR code, sends the ticket inside it to the auth/qr/ endpoint, and receives the token pair. The device is paired from that moment on.

The QR content is plain JSON:

{
  "v": 1,
  "typ": "truvali.mobile-login",
  "ticket": "Qy3v…single-use ticket…",
  "endpoint": "/api/mobile/auth/qr/"
}
Security notes

The QR code is only rendered on the user's own changeform; an administrator cannot open a mobile session on someone else's behalf from that person's page. The ticket is stored on the server only as a hash, and scanning it a second time returns 401. Every paired device appears in the web admin under Mobile API → Mobile Devices, and can be revoked instantly from there.

Device pairing

POST /api/mobile/auth/qr/ no authentication required

Request

{
  "ticket": "…ticket read from the QR code…",
  "device_name": "Alex's iPhone",
  "platform": "ios",            // ios | android | other
  "app_version": "1.0.0"
}

Response | 200

{
  "status": true,
  "message": "Device paired successfully.",
  "data": {
    "access_token": "eyJhbGciOiJIUzI1NiIs…",   // valid for 1 hour
    "refresh_token": "wJ9c…48 random bytes…",  // valid for 30 days
    "token_type": "Bearer",
    "expires_in": 3600,
    "device_id": "7f3a1c22-…",
    // Address of the live notification socket; null if not configured.
    // See Websocket connection.
    "websocket_url": "wss://fraud-ws.truvali.com/stream/",
    "user": {
      "id": "6a75b080f432181237c5725a",
      "username": "systest",
      "full_name": "Sys Test",
      "email": "[email protected]",
      "is_superuser": true,
      "timezone": "Europe/Istanbul",
      "language": "tr",
      "mobile": "+90…"
    }
  }
}

Usage in subsequent requests

Authorization: Bearer eyJhbGciOiJIUzI1NiIs…
Content-Type: application/json

Errors

Status Message Reason
401 QR ticket is invalid, expired or already used. Ticket is wrong, more than 5 minutes have passed, or it was already scanned
403 User account is not eligible for mobile access. Account has been deactivated

Token refresh

POST /api/mobile/auth/refresh/ no auth required

When the access token expires (401), use this endpoint to get a new pair. The refresh token is rotating: every successful refresh returns a new refresh token and immediately invalidates the old one. The client must store both tokens from the response atomically.

Request

{
  "device_id": "7f3a1c22-…",
  "refresh_token": "wJ9c…"
}

Response | 200

{
  "status": true,
  "message": "Token refreshed.",
  "data": {
    "access_token": "…new…",
    "refresh_token": "…new; the old one is now invalid…",
    "token_type": "Bearer",
    "expires_in": 3600,
    "device_id": "7f3a1c22-…",
    "websocket_url": "wss://fraud-ws.truvali.com/stream/"
  }
}
If the refresh also fails

If the refresh token is invalid (401), it means the device has been revoked, the 30-day period has expired, or the token was already used and rotated by another client. The only thing to do is redirect the user to scan the QR code again.

Logout

POST /api/mobile/auth/logout/ Bearer

Revokes the registration of the requesting device: the access token becomes invalid at its next check, and the refresh token immediately. The same revocation can also be done from the web admin (Mobile Devices → "Revoke selected devices").

{ "status": true, "message": "Device logged out.", "data": {} }

Session info

GET /api/mobile/me/ Bearer

The session owner, their groups, permission list, and paired devices.

{
  "status": true,
  "data": {
    "user": {
      "id": "…", "username": "systest", "full_name": "Sys Test",
      "email": "[email protected]", "is_superuser": true,
      "timezone": "Europe/Istanbul", "language": "tr", "mobile": "+90…",
      // The web date preference; the application may format to match.
      "date_format": "d.m.Y", "time_format": "H:i", "show_today": true,
      // Which rows the account menu draws.
      "has_usable_password": true,
      "video_call": { "status": "AVAILABLE", … },   // null: not an agent
      "profile_target": { "model": "auth.user", "pk": "…" },
      // Home screen: agent lobby instead of the ordinary dashboard.
      "agent_dashboard": false
    },
    "datetime_timezone": "UTC",
    "websocket_url": "wss://fraud-ws.truvali.com/stream/",
    "push": { "provider": "onesignal", "app_id": "…" },   // null: no push here
    "groups": ["Compliance"],
    "permissions": ["engine.view_watchlist", "engine.change_watchlist", …],
    "unread_notifications": 3,
    "device":  { "id": "…", "name": "Alex's iPhone", "platform": "ios" },
    "devices": [
      { "id": "…", "name": "…", "platform": "ios",
        "last_seen_at": "2026-08-09T11:24:58", "current": true }
    ]
  }
}

Account menu

The rows of the web panel's member menu, as endpoints. Each write answers with the same user payload session info returns, so one response is enough to refresh the screen.

GET /api/mobile/me/preferences/ Bearer
PATCH /api/mobile/me/preferences/ Bearer

Interface language and time zone. GET returns the current selection together with the valid languages and timezones, so the client carries no list of its own. The web reads the same profile, so the next page it serves uses the new language and zone.

PATCH /api/mobile/me/preferences/
{ "language": "tr", "timezone": "Europe/Istanbul" }
POST /api/mobile/me/video-call-availability/ Bearer

{"available": true} or false: the agent's availability switch, with the web's rules. 403 for a user who is not a video-call agent, and 409 while a call is in progress, because that state is set by the system rather than by the agent. Draw the row only when user.video_call is not null.

POST /api/mobile/me/password/ Bearer

{"old_password", "new_password1", "new_password2"}, validated by Django's own password-change form: the current password, the match and every configured password validator. Field errors come back in data.errors with the text the web form prints. The mobile token is tied to the device rather than to the password, so the session stays open; web sessions drop, which is the intent. Show the row only when user.has_usable_password is true.

Store review ticket

App Store and Google Play reviewers have to run the application by hand and see every feature, and the ordinary QR ticket cannot be handed to them: it is single-use and lives five minutes, so it is dead by the time it reaches the reviewer. An application that cannot be signed into is rejected under Apple's "2.1 Information Needed".

For that there is a multi-use ticket: it does not close when it is used, it is long-lived, and it is issued only from the command line. There is no way to produce one from the interface.

manage.py issue_review_ticket --user store-review --days 120 \
    --note "App Store review 1.0.0" --qr /tmp/review-qr.png

manage.py issue_review_ticket --user store-review --list     # show the existing ones
manage.py issue_review_ticket --user store-review --revoke   # revoke

The command prints the two values to type into the application's "Enter the code manually" screen: the address of the workspace and the code itself. The code is never shown again; only its hash is kept.

This is a standing credential

Until it expires or is deleted, whoever holds it opens a mobile session as that user. So issue it only for a read-only reviewer account: the command refuses a superuser without --force and issues nothing at all for an inactive or non-staff user. A second ticket for the same user deletes the first, so a single standing code exists at a time. Revoke it with --revoke when the review is over. The Mobile Login Tickets list in the web admin can be filtered by multi_use, which is where a forgotten ticket shows up.

Each redemption opens its own device record, so the audit trail is the device list. Because the ticket belongs to no single device, used_by_device is left empty on a multi-use ticket.

Schema | Model list and menu

GET /api/mobile/schema/ Bearer

All models the user has at least view permission for, grouped by app in parallel with the admin index. Build the main menu from this response; models the user has no permission for are not included in the response at all.

Response | 200

{
  "status": true,
  "data": {
    "apps": [
      {
        "app_label": "engine",
        "models": [
          {
            "app_label": "engine",
            "model": "watchlist",
            "object_name": "WatchList",
            "verbose_name": "Watch List",
            "verbose_name_plural": "Watch Lists",
            "is_proxy": false,
            "perms": { "view": true, "add": false, "change": true, "delete": false },
            "endpoints": {
              "schema": "/api/mobile/schema/engine/watchlist/",
              "data":   "/api/mobile/data/engine/watchlist/"
            }
          },
          …
        ]
      },
      …
    ]
  }
}

The menu tree

The same response carries a second tree, menu: the web panel's own left menu, with its curated groups, translated labels, hand-given order and icon names. Draw the side menu from this and a model added to the web menu appears without a client update. Rows are {type: "model"|"url", label, icon, …}; model rows pass the permission filter, and a group whose rows are all filtered out is dropped. Where the panel has no curated menu the list is empty and the client falls back to apps.

"menu": [
  {
    "label": "COMPLIANCE", "icon": "bx-shield",
    "items": [
      { "type": "model", "label": "Cases", "icon": "bx-folder-open",
        "app_label": "engine", "model": "watchlist",
        "perms": { "view": true, "add": false, "change": true, "delete": false },
        "endpoints": { "schema": "…", "data": "…" },
        "shortcuts": [
          { "id": "…", "label": "My open cases",
            "params": { "status__exact": ["1"] },
            "query": "status__exact=1",
            "url": "/api/mobile/data/engine/watchlist/?status__exact=1" }
        ] },
      { "type": "url", "label": "Dashboards", "icon": "bx-home", "url": "/" }
    ]
  }
]
Shortcuts are the user's saved filters

The shortcuts of a model row are the shortcuts in that user's web menu: changelist filters they saved. params are the GET parameters, and url hands them back already appended to the data/<app>/<model>/ endpoint, so tapping one opens the list filtered exactly as it is on the web. Empty values are dropped; they carry no meaning on the web either.

Schema | Model schema

GET /api/mobile/schema/<app>/<model>/ Bearer

The full schema of a single model. If ?object_id=<pk> is given, the change-form schema is returned: record-specific readonly fields, fieldsets, and permissions (some admins configure these based on the record) are evaluated for that record.

Sections of the response

Field Content
perms view / add / change / delete, for this user
list.columns List columns: name, label, index (used in the ordering parameter), sortable
list.title_column Which column is the card's title. The mobile list is a card rather than a table: title, status badge in the top right, a strip whose first cell is the date
list.status_column The column drawn as the badge in the card's top right corner
list.date_column The card's date column. The client does not guess it: a guess would mean keeping the same list in two places
list.columns[].kind How to draw the cell: text, date, badge, number or bool
list.filters Filter definitions (below)
list.search_enabled Whether the search box should be shown (q param)
form.fields Form fields: type, label, required flag, choices, FK target
form.fieldsets Field grouping: section the detail/form screen the same way as on the web
form.readonly_fields Names of fields to display as read-only
inlines Related model blocks (inline tables on the web) + their permissions
actions List of admin bulk actions (informational only in v1)

Form field example

"form": {
  "fields": [
    { "name": "name", "type": "string", "label": "name",
      "required": true, "disabled": false, "max_length": 150 },
    { "name": "client", "type": "fk", "label": "Source Service",
      "required": true, "disabled": false,
      "related": { "app_label": "merchant", "model": "client",
                   "object_name": "Client" },
      "related_registered": true },
    { "name": "trigger_event", "type": "choice", "required": true,
      "choices": [ { "value": "financial", "label": "Financial" }, … ] },
    { "name": "is_active", "type": "boolean", "required": false }
  ],
  "fieldsets": [
    { "title": "General Settings", "fields": ["name", "client", …],
      "classes": [], "description": null }
  ],
  "readonly_fields": ["id", "create_date"]
}

Field types

type Value to send
string / text Text (text: a multi-line editor is recommended)
integer / float / decimal Number (decimal is also accepted as a string)
boolean / nullboolean true / false (nullboolean: null is also allowed)
date / datetime / time ISO 8601 string (2026-08-09T14:30:00)
choice A value from the choices list
fk The pk of the related record; options are searched via autocomplete
m2m List of pks
json Free-form JSON object/array
uuid UUID string
file / image Upload is not supported in v1 (upload_supported: false)

Filter definitions

There are two kinds of filters. Choice filters come with choices populated; when the user taps an option, add that option's params dictionary to the list request's query string as-is. Input filters (input: true, such as a range or free text) require you to send the values yourself under the names in the parameters list.

"filters": [
  {
    "title": "status", "class": "ChoicesFieldListFilter",
    "parameters": ["status__exact", "status__isnull"],
    "choices": [
      { "display": "All",     "selected": true,  "params": {} },
      { "display": "Success", "selected": false, "params": { "status__exact": "1" } }
    ],
    "input": false
  },
  {
    "title": "score", "class": "RangeFilter",
    "parameters": ["score__range__gte", "score__range__lte"],
    "choices": [], "input": true
  }
]
The add-form schema may not be generatable for every model

For some view-only models (e.g. transaction records, alerts) the form schema is returned with a form.error field. This is exactly the same as the web admin: those models have no add page on the web either (perms.add is already false). The change-form schema requested with ?object_id= still works for these models.

Data | listing

GET /api/mobile/data/<app>/<model>/ Bearer

Paginated list. Query parameters are identical to the web admin changelist; the filter params from the schema are added directly:

Parameter Description
p Page number (1-based)
q Search term (uses the admin's search_fields logic)
o Sorting; use the schema's column index values, dot-separated, minus for descending: o=2.-1 (ascending on column 2, descending on column 1)
filters Lookups from the schema, e.g. is_active__exact=1, create_date__range__gte=2026-08-01
page_size Page size (default is the model's admin setting, maximum 200)
fields Comma-separated field names: adds the raw values of these fields to each row (useful when building a custom UI)

Example

GET /api/mobile/data/engine/watchlist/?p=1&q=john&o=1.-3&page_size=25

Response | 200

{
  "status": true,
  "data": {
    "timezone": "UTC",
    "results": [
      {
        "pk": "0a1b2c…",
        "repr": "WatchList object (0a1b2c…)",
        "cells": {                     // list_display columns: text, bool or ISO date
          "create_date": "2026-09-19T07:41:02",
          "get_customer_list": "John Smith - CUST-104",
          "get_status_colored": "OPEN",
          "get_alerts": "3 alerts"
        },
        "tones": { "get_status_colored": "warn" },   // only the coloured columns
        "fields": { "status": 1 }      // only when ?fields= is requested
      }
    ],
    "pagination": { "page": 1, "page_size": 25, "total": 132, "pages": 6 },
    "applied": { "query": "john", "filters": { "o": ["1.-3"] } },
    "columns": ["create_date", "get_customer_list", "get_status_colored", "get_alerts"]
  }
}
Cells are a display layer

cells values are the HTML-stripped plain-text form of the web columns (colored badge, link, etc.); they exist for drawing a quick list. If you need a programmatic value, request the raw values with ?fields= or use the detail endpoint. An unknown filter parameter returns 400.

The list card

Which column plays which role is a presentation decision that changes from model to model, so it is taken next to the admin's list_display (mobile_list_display, mobile_list_title, mobile_list_status, mobile_list_date, mobile_list_tones, mobile_list_kinds) and reaches the client through the schema. An admin that defines none of them is not broken; it simply looks plainer, and its columns are list_display plus a date column.

Data | detail

GET /api/mobile/data/<app>/<model>/<pk>/ Bearer

All fields of the record, in admin change-form order. Each field carries both a raw value (value) and a display text (display); editable says whether the field can be edited on this record. ?include_inlines=1 also fetches related-record blocks (up to 50 rows per block).

Response | 200

{
  "status": true,
  "data": {
    "pk": "0a1b2c…",
    "repr": "Sample Customer",
    "timezone": "UTC",
    "status_field": "status",            // the badge beside the title; not guessed
    "fields": {
      "name":     { "type": "field", "label": "Name", "kind": "text",
                    "value": "Sample Customer", "display": "Sample Customer",
                    "editable": true },
      "client":   { "type": "field", "label": "Source Service", "kind": "relation",
                    "value": "9f8e7d…", "display": "Source Service A",
                    "editable": true,
                    "related_model": "merchant.client", "related_pk": "9f8e7d…" },
      "kyc_risk": { "type": "field", "label": "KYC risk", "kind": "number",
                    "value": "37.5", "display": "37.5", "editable": false },
      "status":   { "type": "display", "label": "Status", "kind": "badge",
                    "display": "REVIEW", "tone": "warn" }
    },
    "perms": { "view": true, "add": false, "change": false, "delete": false },
    "inlines": [                        // only with ?include_inlines=1
      {
        "model": { "app_label": "merchant", "model": "clientcredential", … },
        "verbose_name_plural": "Client Credentials",
        "total": 2,
        "rows": [
          { "pk": "…", "repr": "…",
            "fields": { "name": { "type": "field", "value": "prod", … } } }
        ]
      }
    ]
  }
}

Field kinds

kind and tone follow the same contract as the list card, with json, relation, file and image added. status_field names the field to draw as the badge next to the title.

A link comes through as a link, not as text

The admin methods that print links produce HTML, and flattening one left only the link's text behind, which on the decision log meant an untouchable raw identifier sitting on the screen. A field carrying a single admin change-link now comes back as kind: "relation" with related_model and related_pk. The target is not guessed: the address is resolved against the admin routes and matched in the model registry, and where the link text is a bare primary key the record's own label is shown. A field with several links, or with text besides the link, stays plain, and what is not a link on the web is not a link on mobile either.

A generic relation (content_type plus object_id) becomes a single link the same way, resolved through the model the content type points at. If that target is not registered with the admin there is no link and the field falls back to plain text, rather than handing the client a row it cannot open.

Data | creation

POST /api/mobile/data/<app>/<model>/ Bearer

The body is a flat JSON object keyed by the schema's form field names. Send a pk for FK fields, and a list of pk values for M2M fields. Validation is done with the admin's actual form class; the record passes through the admin's save_model chain; the audit trail (AuditLog) and approval policy are the same as on the web.

Request

POST /api/mobile/data/engine/ruleset/
{
  "name": "New rule set",
  "client": "9f8e7d…",
  "trigger_event": "financial",
  "is_active": true
}

Response | 201

{
  "status": true,
  "message": "Created.",
  "data": {
    "messages": [],
    "pending_approval": false,
    "object": { "pk": "…", "repr": "New rule set", "fields": { … } }
  }
}

Response | 400 (validation)

{
  "status": false,
  "message": "Validation failed.",
  "data": {
    "errors": {
      "name": [ { "message": "This field is required.", "code": "required" } ]
    }
  }
}
Unknown fields

If the body contains a key that isn't in the form schema, the request isn't rejected; the field is ignored and reported in the response's data.warnings list.

Data | update

PATCH /api/mobile/data/<app>/<model>/<pk>/ Bearer
PUT /api/mobile/data/<app>/<model>/<pk>/ Bearer

PATCH updates partially: only the fields you send change, the rest are validated with their current values. PUT expects the full body. PATCH is the right default for a mobile client.

Request

PATCH /api/mobile/data/engine/ruleset/0a1b2c…/
{ "is_active": false }

Response | 200

{
  "status": true,
  "message": "Updated.",
  "data": {
    "messages": [],
    "pending_approval": false,
    "object": { "pk": "0a1b2c…", "repr": "New rule set", "fields": { … } }
  }
}

Data | deletion

DELETE /api/mobile/data/<app>/<model>/<pk>/ Bearer

Response | 200

{
  "status": true,
  "message": "Deleted.",
  "data": { "messages": [], "pending_approval": false }
}

Data | autocomplete (FK search)

GET /api/mobile/data/<app>/<model>/autocomplete/ Bearer

Option search for form fields of type fk / m2m. The search uses the target model's own admin search_fields logic; view permission on the target model is required.

Parameters

Parameter Description
field Name of the relation field on the source model (required)
q Search term (may be left empty)
p Page (1-based, page size 20)

Example

GET /api/mobile/data/engine/ruleset/autocomplete/?field=client&q=source

{
  "status": true,
  "data": {
    "results": [ { "value": "9f8e7d…", "text": "Source Service A" } ],
    "more": false,
    "page": 1
  }
}

Maker-Checker (approval)

For models with an approval policy defined, write requests are not written to the database; an approval request is opened instead and the endpoint returns 202. This is exactly the same behaviour as on the web.

Response | 202

{
  "status": true,
  "message": "Change submitted for approval.",
  "data": {
    "messages": [
      { "level": "success",
        "message": "Change request #… has been submitted for approval." }
    ],
    "pending_approval": true
  }
}
Handling on the client side

When you get 202, show the record as "pending approval", not as "updated"; it's normal to see the old values when you refresh the list. If a second change is submitted while a request is already pending for the same record, it is not processed and an error-level notification is returned in messages. Pending requests are listed at data/approval/pendingapprovalrequest/.

The approval block

The detail response of an approval request carries an approval block: the web change form in structural form.

Field Content
diff What would change: fields[] and inline_blocks[], each typed scalar, text, json or set
stage, signatures, signatures_required Which stage the request is at, who has signed, how many signatures the stage needs
attachments The evidence files attached to the request
action.code What this user can do right now: review, withdraw, signed, waiting_stage, not_authorized, processed, forwarded
target The record the request is about: model, title, pk, display, exists, related_model. display is filled for an addition and for a deleted target too, and related_model is the admin list the record lives in, resolved to its proxy
mcp_origin Set when the request was raised through the model context protocol rather than by a person

The list card of a request carries the same information in short: the target record's name as the title, an action badge, the translated name of the target model, and related_model with related_pk so the card can open the record itself.

Deciding

POST /api/mobile/data/approval/<model>/<pk>/process/ Bearer
{ "action": "approve", "reason": "Checked against the source document." }

action is approve or reject. The same fields can be sent as multipart/form-data together with attachments[] to add evidence. The decision is handed to the web's own processing view, which is where authority, threshold, stage and attachment validation happen; when the request moves on to the next stage the response says so with forwarded_to.

Compliance decision (Mark as)

The web panel's "Mark as" menu, as an endpoint. It covers the decision log (engine.executionlog) and financial and non-financial events (transaction.financialevent, transaction.nonfinancialevent). The detail response of such a record carries a mark block, whose allowed is the same gate as the web menu: change permission on the target plus permission to add a compliance decision.

"mark": {
  "allowed": true,
  "options": ["ACCEPT", "REVIEW", "REJECT"],
  "current": "REVIEW",
  "score": 62, "score_enabled": true,
  "needs_approval": false,
  "max_attachments": 10,
  "has_decisions": true
}
POST /api/mobile/data/<app>/<model>/<pk>/decision/ Bearer
{
  "decision": "REJECT",
  "description": "Counterparty matched the sanctions list.",   // required
  "score": 90,                       // optional, 0-100; omitted leaves the score alone
  "document_description": "Screening output"
}

The same fields can be sent as multipart/form-data with up to ten attachments[] files, against the same upload allowlist the web uses. The logic is shared with the web panel, so the resulting decision is identical whichever screen it was taken on.

Status Meaning
201 The decision was written; the response carries it and the target's new decision and score
202 An approval policy covers it (pending_approval, approval_request): the attachments are written together with the approval and the callback fires at that moment
400 / 403 Validation error / no permission
Decisions are append-only

The decision records themselves (engine.compliancedecision) are view-only: add, change and delete are always false in perms. A record is opened by the endpoint above and never changes afterwards; a wrong decision is corrected with a new one. The rows carry score, original_score and batch_id, which is shared by every decision written by one bulk case closure.

Case update

PATCH /api/mobile/data/engine/watchlist/<pk>/ Bearer

A case is updated through the ordinary data endpoint, with two rules of its own. If the status, the flag, the account action or the postponement changes, rationale is required; without it the request comes back 400 with errors.rationale.

While a case is being closed, the alerted transactions can be marked in bulk in the same request: clear_decision (ACCEPT or REJECT; empty means leave them as they are) and an optional clear_score between 0 and 100. These are the web form's own fields, and they are in the schema under form.fields.

PATCH /api/mobile/data/engine/watchlist/0a1b2c…/
{
  "status": 3,                     // a value from the schema's choices
  "rationale": "Source of funds documented; closing.",
  "clear_decision": "ACCEPT",
  "clear_score": 10
}

AI review panel

The web panel's "AI Assistant Review" is HTML. The mobile client gets the same content structurally, in the ai block of the case detail; the endpoints exist only on the case models.

"ai": {
  "status": "done",            // "" | queued | running | done | failed
  "in_flight": false,
  "can_run": true, "can_feedback": true,
  "topic": { "subject": "…", "typologies": ["…"] },
  "warnings": [],
  "result": {
    "decision": "…", "decision_label": "…", "decision_tone": "warn",
    "confidence": 0.72, "confidence_tone": "warn",
    "str_required": false,
    "reasoning": "…",
    "recommended_actions": ["…"],
    "rule_proposal": { … },
    "knowledge_gaps": ["…"]
  },
  "references": [ … ],         // the ones it relied on come first
  "feedback": { "verdict": null, "note": "", "to_knowledge": false,
                "delivered": false, "error": null, "draft": "" },
  "legacy": null               // an older-format report, as it stands
}
GET /api/mobile/data/engine/<model>/<pk>/ai/ Bearer

The panel on its own; poll it while an analysis is running.

POST /api/mobile/data/engine/<model>/<pk>/ai/run/ Bearer

Ask for an analysis, the web's "Re-run analysis". 202 when it is accepted; 403 on a closed case, 503 where the gateway is not installed and 502 when it cannot be reached.

POST /api/mobile/data/engine/<model>/<pk>/ai/feedback/ Bearer
{ "verdict": "partial", "note": "The typology is right, the amount is not.",
  "to_knowledge": true }

{ "resend": true }

verdict is agree, partial or disagree. The rules are shared with the web: one note per review, so a second returns 409, and a note that is to become part of the knowledge base cannot be empty (400).

Decision context

The detail of a decision log entry and of an alert carries a data_context block: the raw dictionary the rules were evaluated against. Its top-level keys are transaction, customer, aggregates and event, each a flat set of key and value. The web admin renders the same data as HTML, which flattens into an unreadable run of words; draw it as tabs and a table instead.

Dashboard blocks

The blocks of the web dashboard arrive ready to draw: the ratio, the change and the series are all computed on the server. A user without permission gets 403 and the client simply does not draw that card.

Endpoint Block
GET dashboard/widgets/ The cards along the top: value, change and trend
GET dashboard/decisions-by-day/ The decisions chart
GET dashboard/high-volume-customers/ The volume table
GET dashboard/lobby/ The agent lobby

Agent dashboard

GET /api/mobile/dashboard/agent-summary/ Bearer

On the web, a user with the agent flag set (superusers aside) sees an agent lobby on the home page instead of the ordinary dashboard. Mobile makes the same distinction with user.agent_dashboard from session info, and fills the screen from this one endpoint: today (today's breakdown), recent, kpis, series (14 days of days, calls and on_screen_hours), shifts, and availability with availability_label and in_call. The field names are the web's and the computation is shared. For a user who is not an agent it returns {"agent": false}.

The mobile endpoint writes no heartbeat

On the web this summary is polled every 60 seconds and stamps "last seen": being on screen means a console that could join a video call is open. A call cannot be joined from a phone, so a heartbeat from there would hold the agent available for nothing, and the sweep that retires stale agents could never stand them down.

Report output

GET /api/mobile/reports/results/<pk>/ Bearer

The header of a report result and one page of its output: the mobile counterpart of the web's "View Data" button. It is not on the generic data/ path because a report result is not registered with the admin in its own right; it is an inline of the report, so data/report/reportresult/<pk>/ answered 404, which is where the inline row, the notification row and the target of the "report ready" push all landed.

{"id", "title", "status", "status_display", "tone",
 "started_at", "completed_at", "duration_seconds", "row_count",
 "error_message", "has_file",
 "report": {"id", "name", "description"},
 "rows": {"columns": [...], "rows": [[...], ...],
          "page", "page_size", "total", "has_more", "error"}}

Assistant | availability

GET /api/mobile/assistant/status/ Bearer

The assistant runs in a separate service, and the panel reaches it through a proxy that is tied to the Django session cookie. A mobile client has a Bearer token rather than a cookie, so these endpoints are a second door onto the same gateway. They are deliberately not a general proxy: the paths that can be reached are the ones listed here and no others, because a mobile token sits on a device that can be lost.

{
  "status": true,
  "data": {
    "enabled": true,
    "reason": "",
    "connected": true,            // the analyst's tool connection
    "needs_reauth": false,
    "username": "…", "expires_at": "2026-10-04T09:12:00+00:00",
    "server_name": "…", "tools": ["…"],
    "retention_days": 7
  }
}
These payloads belong to the gateway

Everything under data in this chapter is the gateway's own JSON, passed through with only the envelope added. It therefore follows the gateway's conventions rather than this API's: timestamps carry an offset instead of the bare UTC used everywhere else, and fields may be added to it without a client update. Ignore what you do not recognise.

Closed is not an error

This endpoint answers 200 even when the assistant is unavailable, with enabled: false and a human-readable reason: the gateway is not configured in this installation, the assistant is switched off, or the account is not a staff account. None of these is a fault, so the application simply does not draw the card. Every other assistant endpoint returns 503 with the same reason.

The connection made on the web is the one used here

The identity sent to the gateway is the same pair the panel's own proxy sends, the Django user, and the assistant's tool connection hangs off it. So a user who pressed "Connect" once on the web is connected on mobile too, and there is no second authorisation flow to write. When connected is false, send the user to the panel rather than imitating the browser flow on a phone.

Assistant | conversations

GET /api/mobile/assistant/conversations/ Bearer
POST /api/mobile/assistant/conversations/ Bearer

GET lists this user's conversations under data.conversations, each one {id, title, status, updated_at, message_count}. POST with no body opens an empty conversation and answers 201 with the conversation itself.

GET /api/mobile/assistant/conversations/<id>/ Bearer
DELETE /api/mobile/assistant/conversations/<id>/ Bearer
{
  "id": "…", "title": "Case 4821", "status": "awaiting_approval",
  "seq": 12,                   // the sequence number of the last message
  "tick": 5,                   // the phase counter; see long polling
  "error": "",
  // Set while the model wants to run a tool that writes:
  "pending_call": { "id": "…", "alias": "…", "name": "propose_rule",
                    "arguments": "{…}" },
  // The live phase, one line, while a turn is running:
  "progress": { "phase": "queued", "label": "…", "ahead": 2, "since": "…" },
  // A summary of the turn that just finished:
  "last_turn": { "model_calls": 2, "tool_calls": 3,
                 "prompt_tokens": 8120, "completion_tokens": 460,
                 "duration_ms": 7400 },
  "messages": [
    { "seq": 11, "role": "assistant", "content": "…",
      "tool_calls": [ … ], "tool_call_id": "", "name": "", "arguments": "",
      "status": "", "navigate": null, "created_at": "2026-09-20T13:02:11+00:00" }
  ]
}

status is idle, running, awaiting_approval or error. A message whose status is navigate carries a navigate object: the assistant sent the user to a screen, and the address is relative to the panel itself.

Long polling

Parameter Description
after Return only the messages after this sequence number; pass the seq you already hold
wait How many seconds to hold the request open waiting for something new. The gateway caps it at 30, so the client's own request timeout has to sit above that or it cuts itself off every round
tick The phase counter you last saw. A change in phase also ends the wait, which is what keeps a "calling a tool…" line alive on screen
GET /api/mobile/assistant/conversations/1f2e…/?after=12&wait=25&tick=5

Assistant | messages and approval

POST /api/mobile/assistant/conversations/<id>/messages/ Bearer
{ "text": "Which rules fired on this case?" }

text is required and at most 8000 characters. The answer is 202 with {job_id, status, seq}: no model call is waited for here. The work goes into the gateway's queue and the content is read back with the long poll above. 409 means the assistant is still busy with the previous message or is waiting for an answer to an approval question.

POST /api/mobile/assistant/conversations/<id>/approval/ Bearer
{ "approve": true }

The answer to the pending_call: run it or skip it. The assistant asks before it calls a tool that writes, and the question is asked on mobile as well, or a specialist would change a record from their phone without knowing it. 202, the same envelope as a message; 409 when there is no call waiting for an answer.

A gateway failure is not a session failure

When the gateway cannot be reached the response is 502, and a 401 from the gateway is deliberately turned into 502 as well: that one is about the key between the two services, not about the device's token. A mobile client reads 401 as "my access token has expired", refreshes once and then drops the session, and there is no reason to throw the user back to the QR screen because a service behind the panel said no.

Push registration

Which service to register with is told by the push field in the pairing, refresh and session responses: {"provider": "onesignal", "app_id": …}, or null where the installation has no push, in which case the application never starts the SDK. The service's own key stays on the server.

POST /api/mobile/devices/push-token/ Bearer
{ "token": "…", "provider": "onesignal" }

Writes this device's push identity and turns push on for it. The identity belongs to the device, so two phones of the same user are two rows. With OneSignal the value to send is not an FCM token but the subscription id; fcm remains as the older path.

DELETE /api/mobile/devices/push-token/ Bearer

Deletes the token before signing out; revoking a device drops it anyway. The sender looks only for devices that are active, have push enabled, hold a token and are registered with that provider, and clears the token when the service answers that it is unregistered.

Notification endpoints

Notifications come from one central model, and these endpoints show only the session owner's rows.

Endpoint Description
GET notifications/ Paginated list: page, page_size up to 100, unread=1, kind. The response also carries unread_count
GET notifications/unread-count/ The bell counter, the same number as unread_notifications in /me/
POST notifications/<id>/read/ Mark one as read; someone else's row is a 404
POST notifications/read-all/ Mark every notification as read

A row's target ({"model": "engine.watchlist", "pk": …}) is there for deep linking: open that record's detail screen when the notification is tapped.

Websocket connection

WSS <websocket_url> no auth required when opening the socket

In addition to the REST endpoints, there is a single websocket connection for the instant delivery of alerts and call notifications. The web panel connects to the same socket; the difference for the mobile client is that it identifies itself with an access token rather than a Django session.

The address varies from environment to environment and cannot be guessed: the socket lives on a separate service, not at the API's address. That's why the websocket_url field is returned in three responses: device pairing, token refresh, and session info:

"websocket_url": "wss://fraud-ws.truvali.com/stream/"

The field may return null (the socket service is not configured in this environment); in that case, disable the live-notification feature and let the rest of the app work normally.

Dropped connection

The socket can drop at any moment (network change, the app being backgrounded, an intervening proxy closing an idle connection), and the drop is silent. The client should reconnect with increasing backoff (1s, 2s, 4s … up to ~15s), and must resend the login message on every new connection: to the server, this is an unrecognized connection.

Messages that arrive while disconnected are lost and are not delivered later. After reconnecting, refresh the on-screen list from REST; the socket is not a data source, it is a refresh trigger.

Socket login

This is the first message to send after the connection opens. Nothing sent before it is taken into account.

Request

{
  "type": "login",
  "payload": {"token": "eyJhbGciOiJIUzI1NiIs…"}   // access_token
}

Response | success

{
  "type": "login",
  "token": "…socket session token…",
  "permissions": [
    "engine.executionlog",
    "customer.customer",
    "document.documentverificationsession"
  ]
}

permissions is the list of app_label.model for the models the user has view permission on, the same as visibility in the web admin, computed with the rules in the Permission model section. Broadcasts are filtered by this list; if permission is missing, the message is never sent. You can use this list to decide which notification types to enable in the UI.

Response | failure

{"type": "login_failed"}

The reason is deliberately not returned (an expired token, a revoked device, an inactive account, and a transient infrastructure error all produce the same response); the details are in the socket service's log. The correct client-side behavior: refresh the token once and retry with the new access token; if it fails again, close the socket and return to the QR screen.

Token lifetime is shorter than the socket's

The access token is valid for 1 hour, while the socket can stay open for days. When you refresh the token, send a new login message over the same socket; identity is refreshed in place and reconnecting is not required. The socket does not close on its own when the token expires.

Device revocation

Socket login goes through the exact same UserDevice checks as HTTP requests: the moment a device is revoked from the web admin (Mobile API → Mobile Devices) or via auth/logout/, socket login is rejected even if the signature is still valid. The socket service does not validate the signature on its own; the decision is always made by BackOffice.

Incoming messages

After logging in, incoming broadcasts share a single envelope:

{
  "event": "broadcast",
  "client_id": "anti_fraud_service",
  "timestamp": 1754740000000,          // epoch ms
  "correlation_id": "6f1c…",
  "data": {
    "to": "mainstream",                // target: broadcast group
    "type": "videoCallQueue",          // message type
    "model": "document.documentverificationsession",   // optional
    "data": { … }                      // type-specific payload
  }
}
Field Meaning
data.to mainstream goes to all logged-in staff; other values are individual targets (your device's identity on mobile)
data.type Message type. The set of types grows over time
data.model If present, the model the message relates to. The server compares this against permissions in the login response; if you lack permission, the message never reaches you
data.data Type-specific payload: record summary, room status, etc.
Ignore types you don't recognize

New message types are added without a client update. If data.type is a value you don't recognize, silently skip the message; don't show an error, don't close the connection. New fields may likewise be added to the envelope.

Example application flow

  1. Startup: GET /me/ with the stored access token → on 401 try auth/refresh/; if that also fails, show the QR screen.
  2. Main menu: GET /schema/ → render the app/model tree (only ones with perms.view are included).
  3. List screen: get the column and filter definitions with GET /schema/<app>/<model>/ (cacheable); fetch the rows with GET /data/<app>/<model>/?p=1.
  4. Detail: GET /data/…/<pk>/?include_inlines=1; show the edit button based on perms.change.
  5. Edit form: get the record-specific form schema with GET /schema/…/?object_id=<pk>; use the autocomplete endpoint for fk fields; save → PATCH; on 202 show the "pending approval" state.
  6. Live notifications: as soon as the session is established, connect to websocket_url and send the login message; refresh the relevant list from REST when a broadcast arrives. On disconnect, reconnect with increasing backoff and resend the login.
  7. Case work: on a case detail, draw the AI panel from the ai block and the "Mark as" menu from the mark block; decide an approval request with process/.
  8. Home screen: choose the layout from user.agent_dashboard and fill it from the dashboard endpoints; register the device with the provider in the push field and keep the bell in step with the notification endpoints.

Limits (v1)