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 |
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.
/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:
-
Model and record visibility passes through the admin's
get_querysetscoping; user-specific (SoD) restrictions, such as hidden rules/reports, apply identically on mobile. -
add / change / deletepermissions are evaluated with the admin'shas_*_permissionmethods, in the context of the write request. A model that is read-only on the web (e.g. transaction records, audit trails, alerts) cannot be modified from mobile either. -
The
permsfields in the schema and in the detail response are the result of this evaluation: usepermsto decide whether to show a button in the UI, but know that the server also enforces it independently in every case.
QR pairing flow
This is the same as WhatsApp's device-linking flow:
- 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.
- The QR code is single-use and must be scanned within 5 minutes; if it expires, refreshing the page gets a new one.
-
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/"
}
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
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
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 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
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
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.
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" }
{"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.
{"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.
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
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": "/" }
]
}
]
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
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
}
]
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
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 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.
-
tonescarries the colour of the columns drawn as badges:good,warn,bad,neutral. A column with no colour is absent from the dictionary, and the threshold stays in one place, the web's own badge classes. -
Boolean cells are JSON
true/falserather than text, and date cells are ISO strings, so the card can render "Today, 16:30" itself instead of parsing a sentence back out of a formatted date. -
Some admins add keys of their own to a row. An approval request, for instance, carries
related_modelandrelated_pk, the address of the target record, so the card can open the record itself.
Data | detail
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.
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
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" } ]
}
}
}
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 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
Response | 200
{
"status": true,
"message": "Deleted.",
"data": { "messages": [], "pending_approval": false }
}
Data | autocomplete (FK search)
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
}
}
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
{ "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
}
{
"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 |
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
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
}
The panel on its own; poll it while an analysis is running.
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.
{ "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
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}.
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
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"}}
-
?p=is the page and?page_size=the page size, 50 by default and 200 at most. Because there is no download on a phone, paging walks the whole file; the web prints a single 500-row preview and sends you to the download for the rest. -
rows.totalis counted from the file rather than taken fromrow_count: that field is what the query producing the report counted, and the two diverge when the file is missing or stale. -
rows.errorexplains an empty table: the file is not on this server (it is on the disk of the machine that produced it) or cannot be read. The header is still returned. -
Permission is resolved through the parent report's own admin queryset, so the private / owner / shared-with filter applies unchanged, and view permission on the result is required as well.
403without permission,404when it does not exist.
Assistant | availability
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
}
}
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.
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 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 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.
{
"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
{ "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.
{ "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.
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.
{ "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.
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
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.
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.
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.
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. |
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
-
Startup:
GET /me/with the stored access token → on401tryauth/refresh/; if that also fails, show the QR screen. -
Main menu:
GET /schema/→ render the app/model tree (only ones withperms.vieware included). -
List screen: get the column and filter definitions with
GET /schema/<app>/<model>/(cacheable); fetch the rows withGET /data/<app>/<model>/?p=1. -
Detail:
GET /data/…/<pk>/?include_inlines=1; show the edit button based onperms.change. -
Edit form: get the record-specific form schema with
GET /schema/…/?object_id=<pk>; use the autocomplete endpoint forfkfields; save →PATCH; on202show the "pending approval" state. -
Live notifications: as soon as the session is established, connect to
websocket_urland send the login message; refresh the relevant list from REST when a broadcast arrives. On disconnect, reconnect with increasing backoff and resend the login. -
Case work: on a case detail, draw the AI panel from the
aiblock and the "Mark as" menu from themarkblock; decide an approval request withprocess/. -
Home screen: choose the layout from
user.agent_dashboardand fill it from the dashboard endpoints; register the device with the provider in thepushfield and keep the bell in step with the notification endpoints.
Limits (v1)
-
The generic write endpoints take no file or image upload (
upload_supported: false); a file travels only as a multipart attachment on the decision endpoints,process/anddecision/. - Inline records are readable but cannot be written through the inline (the web approval flow doesn't cover inlines either). To modify the related record, use its own model's endpoint.
- Admin bulk actions (
actions) are listed but cannot be run.