FlowTriage API Reference
This document is the authoritative reference for FlowTriage's three public API surfaces:
- Bot API (
/api/v1/bot/*) — client-scoped bearer-token auth, designed for general-purpose integrations and assistant tooling. - Integrations API (
/api/v1/integrations/*) — Sanctum service-account auth with abilities, designed for headless workers (Hermes-style) that poll for tickets assigned to them. - Mobile API (
/api/v1/mobile/*) — Sanctum user auth, backs the FlowTriage PWA.
When the in-app AI assistant is asked about API endpoints, it retrieves chunks from this document. Keep it accurate.
Quick Start
Most integrations need one of these two surfaces:
| Use case | API surface | Auth |
|---|---|---|
| Server-to-server client automation, bots, MCP tools, external dashboards | Bot API (/api/v1/bot/*) |
Client API token: Authorization: Bearer ft_... |
| Headless service-account workers that only see tickets assigned to them | Integrations API (/api/v1/integrations/*) |
Sanctum service-account token with abilities |
| The FlowTriage PWA / mobile app | Mobile API (/api/v1/mobile/*) |
Sanctum user token from /auth/login |
Assign Multiple Users To A Ticket
There are two assignment concepts:
| Field | Meaning |
|---|---|
assigned_to_user_id |
Primary assignee. This is the operational owner and the signal service-account workers use to pick up work. |
user_ids |
Full ticket team. These users can see/collaborate on the ticket. The primary assignee is auto-included even if omitted from user_ids. |
Mobile/PWA API
Use this when the caller is a logged-in staff/admin/manager user:
PATCH /api/v1/mobile/tickets/{ticket_id}/assignment
Authorization: Bearer <mobile_sanctum_token>
Content-Type: application/json
Accept: application/json
{
"assigned_to_user_id": 42,
"user_ids": [42, 51, 67]
}
{
"id": 789,
"assigned_to_user": {
"id": 42,
"name": "Sarah Smith"
},
"users": [
{"id": 42, "name": "Sarah Smith", "email": "sarah@example.com"},
{"id": 51, "name": "Mike Johnson", "email": "mike@example.com"},
{"id": 67, "name": "Priya Naidoo", "email": "priya@example.com"}
]
}
You can also assign multiple users during ticket creation:
POST /api/v1/mobile/tickets
Authorization: Bearer <mobile_sanctum_token>
Content-Type: application/json
Accept: application/json
{
"client_id": 1,
"title": "Coupon issue",
"description": "Customer cannot redeem a coupon.",
"assigned_to_user_id": 42,
"user_ids": [42, 51, 67]
}
{
"id": 789,
"ticket_number": 232,
"client_id": 1,
"title": "Coupon issue",
"description": "Customer cannot redeem a coupon.",
"status_id": 2,
"urgency_id": 1,
"subscriber": null,
"assigned_to_user": {"id": 42, "name": "Sarah Smith"},
"users": [
{"id": 42, "name": "Sarah Smith", "email": "sarah@example.com"},
{"id": 51, "name": "Mike Johnson", "email": "mike@example.com"},
{"id": 67, "name": "Priya Naidoo", "email": "priya@example.com"}
],
"category": null,
"location": null,
"department": null,
"opened_at": "2026-05-21T22:42:47+02:00",
"created_at": "2026-05-21T22:42:47+02:00"
}
Bot API
Use this for server-to-server automations with the client API token:
PATCH /api/v1/bot/tickets/{ticket_id}/assignment
Authorization: Bearer ft_xxxxxxxx
Content-Type: application/json
Accept: application/json
{
"assigned_to_user_id": 42,
"user_ids": [42, 51, 67]
}
{
"id": 789,
"assigned_to": {
"id": 42,
"name": "Sarah Smith"
},
"users": [
{"id": 42, "name": "Sarah Smith", "email": "sarah@example.com"},
{"id": 51, "name": "Mike Johnson", "email": "mike@example.com"},
{"id": 67, "name": "Priya Naidoo", "email": "priya@example.com"}
]
}
Assignment rules:
assigned_to_user_idand everyuser_ids[]entry must belong to the ticket's client.- Passing
user_idsreplaces the team list with the supplied users plus the primary assignee. - Omitting
user_idswhile settingassigned_to_user_idupdates the primary assignee and keeps/adds that primary assignee on the team. - Newly added team members receive a ticket-assignment notification; unchanged assignments do not duplicate notifications.
- Cross-client users return 422.
Authentication
Bot API
Authorization: Bearer <client api token>
The token is minted per-client in the admin UI and gives the holder full access to that client's resources via the Bot API. Treat as a secret. Headers required on every request:
Authorization: Bearer ft_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/json
Integrations API
Authorization: Bearer <sanctum service-account token>
Tokens are minted via the admin UI for users with is_service_account = true. Tokens carry abilities (Sanctum scopes) that gate per-route permissions:
tickets:read— read assigned ticketstickets:transition— atomically post a note + transition status + merge metadatatickets:time— log + edit time entries on assigned tickets
Default new tokens get tickets:read, tickets:transition, tickets:time. The full set also includes tickets:write and tickets:admin reserved namespaces.
Mobile API
Sanctum user-token auth — the PWA logs in via email/password and exchanges credentials for a token. Same Bearer header pattern.
Bot API — /api/v1/bot/*
Info & Reference
GET /info
Returns information about the calling client. Useful as a boot/health check — confirms the token is valid and surfaces which channels the client has enabled.
curl https://flowtriage.com/api/v1/bot/info \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Accept: application/json"
{
"client": {
"id": 1,
"name": "Acme Property Management",
"timezone": "Africa/Johannesburg",
"channels": {
"whatsapp": {"phone_number_id": "...", "verified_name": "Acme"},
"email": true,
"telegram": false,
"widget": true
},
"widget": {
"enabled": true,
"key": "wgt_abc123",
"script_url": "https://flowtriage.com/widget.js"
}
}
}
Fields on client:
| Field | Type | Notes |
|---|---|---|
id |
integer | Client primary key |
name |
string | Display name |
timezone |
string | IANA timezone, falls back to app default if unset |
channels.whatsapp |
object / null | Full whatsapp_details blob (phone_number_id, verified_name, etc.) when configured |
channels.email |
bool | Whether the inbound email channel is enabled |
channels.telegram |
bool | Whether Telegram is enabled |
channels.widget |
bool | Whether the web widget is enabled |
widget.enabled |
bool | Same as channels.widget |
widget.key |
string | Used as the data-key attribute on the widget embed |
widget.script_url |
string | Absolute URL to the widget loader script |
GET /users
List staff users on the calling client.
curl https://flowtriage.com/api/v1/bot/users \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Accept: application/json"
{
"data": [
{"id": 42, "name": "Sarah Smith", "email": "sarah@acme.com"},
{"id": 51, "name": "Mike Johnson", "email": "mike@acme.com"}
]
}
Per-user fields: id (integer), name (string), email (string). Ordered by name ascending. Role / phone / service-account-flag are NOT in this list — they're admin-side concerns, not relevant to API callers picking an assignee.
POST /users
Create a staff user and attach to the calling client.
curl -X POST https://flowtriage.com/api/v1/bot/users \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"name": "Jane Doe",
"email": "jane@example.com",
"role": "staff",
"phone_number": "+27 82 555 0123",
"password": "min-8-chars",
"is_service_account": false
}'
Fields: name, email, role (admin/manager/staff/third-party) are required. phone_number, password, is_service_account are optional. Password is auto-generated (random hashed) if omitted on a regular user; left null on a service account.
Email collision behaviour switchable via ?on_conflict=:
fail(default) — duplicate email returns 422. Use this when an existing email signals a bug.attach— idempotent. If the email already exists, skip user creation andupdateOrCreatetheclient_userslink with the given role. Returns 200 with"action": "attached". User-level identity fields (name, phone, password, is_service_account) are NOT overwritten — attach is purely a role linkage. Use this for bulk sync flows.
PATCH /users/{id}/role
Update a user's role on the calling client.
curl -X PATCH https://flowtriage.com/api/v1/bot/users/42/role \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"role": "manager"}'
Returns 404 (not 403) if the user isn't on the calling client — deliberately indistinguishable from "user doesn't exist" to avoid cross-tenant probing.
POST /users/{id}/notify
Send a notification to a staff user via Telegram.
curl -X POST https://flowtriage.com/api/v1/bot/users/42/notify \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"content": "Critical ticket needs your attention.", "channel": "telegram"}'
GET /categories
List ticket categories for the calling client.
curl https://flowtriage.com/api/v1/bot/categories \
-H "Authorization: Bearer ft_xxxxxxxx"
{
"data": [
{"id": 5, "name": "Maintenance"},
{"id": 6, "name": "Billing"},
{"id": 7, "name": "Bookings"}
]
}
Fields per row: id (integer), name (string). Ordered by name ascending. Use these ids on POST /tickets / PATCH /tickets/{id} when binding a ticket to a category.
Subscribers
GET /subscribers
Search subscribers with pagination. Search matches against name, email, OR phone_number with a LIKE %term% query.
curl "https://flowtriage.com/api/v1/bot/subscribers?search=smith&per_page=25&page=1" \
-H "Authorization: Bearer ft_xxxxxxxx"
Query parameters: search (string, max 255), per_page (1-100, default 25), page (1-indexed, default 1).
{
"data": [
{
"id": 123,
"name": "Jane Doe",
"email": "jane@example.com",
"phone_number": "+27821234567",
"created_at": "2025-11-15T10:14:00+02:00"
}
],
"meta": {"current_page": 1, "last_page": 5, "per_page": 25, "total": 117}
}
Per-subscriber fields in data[]: id (integer), name (string), email (string / null), phone_number (string / null), created_at (ISO 8601). For groups / details / messages, fetch the single subscriber.
GET /subscribers/{id}
Subscriber detail including group memberships.
curl https://flowtriage.com/api/v1/bot/subscribers/123 \
-H "Authorization: Bearer ft_xxxxxxxx"
{
"id": 123,
"name": "Jane Doe",
"email": "jane@example.com",
"phone_number": "+27821234567",
"created_at": "2025-11-15T10:14:00+02:00",
"groups": [
{"id": 4, "name": "VIP"},
{"id": 7, "name": "Stellenbosch HQ"}
]
}
Fields: same as the list response plus groups[] — an array of {id, name} for each group the subscriber belongs to (empty array when none).
GET /subscribers/{id}/messages
List messages for a subscriber, newest first.
curl "https://flowtriage.com/api/v1/bot/subscribers/123/messages?channel=whatsapp&since_id=500&limit=50" \
-H "Authorization: Bearer ft_xxxxxxxx"
Query parameters:
| Param | Type | Notes |
|---|---|---|
channel |
string | One of whatsapp / email / telegram / web. Omit for all channels. |
since_id |
integer | Return only messages with id > since_id. Useful for incremental polling. |
limit |
integer | 1-100, default 50 |
{
"data": [
{
"id": 998,
"type": "incoming",
"channel": "whatsapp",
"content": "Hi, my booking confirmation hasn't arrived",
"status": "delivered",
"read": false,
"created_at": "2026-05-20T09:00:00+02:00"
}
]
}
Per-message fields:
| Field | Type | Notes |
|---|---|---|
id |
integer | Message id |
type |
string | incoming (from subscriber) or response (from staff) |
channel |
string | whatsapp / email / telegram / web |
content |
string | Body text |
status |
string / null | Delivery status (channel-specific — e.g. delivered / read for WhatsApp) |
read |
bool | Whether a staff user has marked the message as read |
created_at |
ISO 8601 | When the message was recorded |
POST /subscribers/{id}/messages
Send a message to a subscriber.
curl -X POST https://flowtriage.com/api/v1/bot/subscribers/123/messages \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"content": "Hi Sarah, your booking is confirmed.", "channel": "whatsapp"}'
Body fields: content (required, max 4096 chars), channel (required — one of whatsapp / email / telegram / web).
{
"id": 1042,
"type": "response",
"channel": "whatsapp",
"content": "Hi Sarah, your booking is confirmed.",
"created_at": "2026-05-20T14:00:00+02:00"
}
Cc/Bcc/attachments are NOT supported via this endpoint (use the staff-facing reply UI for rich correspondence).
Tickets
GET /tickets
List tickets with optional filters.
curl "https://flowtriage.com/api/v1/bot/tickets?status=open&assigned_to_user_id=42&per_page=25" \
-H "Authorization: Bearer ft_xxxxxxxx"
Query parameters:
| Param | Type | Notes |
|---|---|---|
status |
string | One of draft, open, in-progress, paused, work-complete, awaiting-approval, failed, closed. Omit to include all statuses. |
assigned_to_user_id |
integer | Match BOTH primary assignee column AND multi-user team pivot. Mutually exclusive with assignee_email. |
assignee_email |
string | Email of an assignee — case-insensitive lookup scoped to the calling client's staff (422 if not on this client). Same OR-match as assigned_to_user_id. |
per_page |
integer | 1-100, default 25 |
page |
integer | 1-indexed, default 1 |
{
"data": [
{
"id": 789,
"ticket_number": 42,
"title": "Geyser leaking in unit 4B",
"status": "open",
"urgency": "high",
"metadata": {"origin": "email_auto_create", "email_thread_key": "..."},
"category": "Maintenance",
"assigned_to": "Sarah Smith",
"subscriber": "Jane Doe",
"updated_at": "2026-05-20T10:15:00+02:00"
}
],
"meta": {
"current_page": 1,
"last_page": 5,
"per_page": 25,
"total": 117
}
}
Per-ticket fields in data[]:
| Field | Type | Notes |
|---|---|---|
id |
integer | Internal ticket id — use this everywhere except staff URLs |
ticket_number |
integer | Per-client auto-increment, what appears in /tickets/{N} staff URLs |
title |
string | |
status |
string | Same enum as the filter param above |
urgency |
string | normal / medium / high / critical |
metadata |
object / null | Arbitrary JSON. Includes origin for auto-created tickets, email_thread_key for email-channel tickets, plus any keys merged via the Integrations API's transition endpoint |
category |
string / null | Category NAME only (not the id — use GET /tickets/{id} for that) |
assigned_to |
string / null | Primary assignee's NAME (not id — use GET /tickets/{id} for the full assignee object) |
subscriber |
string / null | Subscriber's NAME |
updated_at |
ISO 8601 datetime | When the ticket row was last touched (used for ordering — newest first) |
If you need the assignee id, category id, subscriber id, description, opened_at, target_completion_date, or notes, fetch the single ticket via GET /tickets/{id} — see next section.
GET /tickets/{id}
Single ticket detail including all metadata, the assignee + subscriber + category as nested objects (with ids), the due date, and all internal notes.
curl https://flowtriage.com/api/v1/bot/tickets/789 \
-H "Authorization: Bearer ft_xxxxxxxx"
{
"id": 789,
"ticket_number": 42,
"title": "Geyser leaking in unit 4B",
"description": "Tenant reports water on the floor.",
"metadata": {"origin": "email_auto_create"},
"status": "open",
"urgency": "high",
"category": {"id": 5, "name": "Maintenance"},
"assigned_to": {"id": 42, "name": "Sarah Smith"},
"subscriber": {"id": 123, "name": "Jane Doe"},
"opened_at": "2026-05-19T14:00:00+02:00",
"target_completion_date": "2026-05-25T17:00:00+02:00",
"notes": [
{
"id": 1001,
"note": "Called tenant — confirmed Tuesday morning slot.",
"user": "Sarah Smith",
"created_at": "2026-05-20T09:14:00+02:00"
}
]
}
Fields on the top-level object:
| Field | Type | Notes |
|---|---|---|
id |
integer | Internal ticket id |
ticket_number |
integer | Per-client display number |
title |
string | |
description |
string | Body text. Non-null (empty string when no description set) |
metadata |
object / null | Arbitrary JSON (same as the list response) |
status |
string | draft / open / in-progress / paused / work-complete / awaiting-approval / failed / closed |
urgency |
string | normal / medium / high / critical |
category |
object / null | {id, name} — the categories table FK on the calling client |
assigned_to |
object / null | {id, name} — primary assignee. The team pivot isn't returned on this endpoint; use the Bot API's PATCH /tickets/{id}/assignment if you need to inspect/modify the team set |
subscriber |
object / null | {id, name} — the subscriber the ticket was opened on behalf of (if any) |
opened_at |
ISO 8601 datetime / null | When the ticket entered open status (transitions from draft) |
target_completion_date |
ISO 8601 datetime / null | "Due date" — set/clear via PATCH /tickets/{id} |
notes[] |
array | Internal notes, oldest first. Each: {id, note, user, created_at} where user is the author's name as a string |
Fields NOT in this response (but on the model — may be added later if you need them):
ticket_department_id— not currently surfaced (usePATCH /tickets/{id}to set, but read-back goes viaGETnot yet)scheduled_start_at— not currently surfacedclosed_at,closed_by_id— not currently surfaced (usestatusto detect closed tickets)work_started_at,work_completed_at— not currently surfacedusers[](team pivot) — primary-only on this endpoint- Author / created_by — not currently surfaced
If you need any of those, ask and we'll add them to the serializer.
POST /tickets
Create a ticket.
curl -X POST https://flowtriage.com/api/v1/bot/tickets \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"title": "Geyser leaking in unit 4B",
"description": "Tenant reports water on the floor.",
"subscriber_id": 123,
"assigned_to_user_id": 42,
"category_id": 5,
"urgency": "high",
"metadata": {"source": "field_app"}
}'
Body fields:
| Field | Required | Notes |
|---|---|---|
title |
yes | string, max 255 |
description |
no | string, defaults to empty |
subscriber_id |
no | integer; 404s if subscriber isn't on this client |
assigned_to_user_id |
no | integer; 422 if user isn't on this client |
category_id |
no | integer; 404 if category isn't on this client |
urgency |
no | normal / high / critical (NB: medium is valid on the model but not on this endpoint). Defaults to normal. |
metadata |
no | arbitrary JSON object; merged into ticket metadata |
Response (201) — same shape as GET /tickets/{id} except without notes[]:
{
"id": 789,
"ticket_number": 42,
"title": "Geyser leaking in unit 4B",
"description": "Tenant reports water on the floor.",
"metadata": {"source": "field_app"},
"status": "open",
"urgency": "high",
"category": {"id": 5, "name": "Maintenance"},
"assigned_to": {"id": 42, "name": "Sarah Smith"},
"subscriber": {"id": 123, "name": "Jane Doe"},
"opened_at": "2026-05-20T14:00:00+02:00"
}
New tickets are created at status=open with opened_at = now().
PATCH /tickets/{id}
Update arbitrary mutable fields on a ticket — title, description, urgency, category, department, due date, scheduled start. Any subset; only the fields you pass get updated.
curl -X PATCH https://flowtriage.com/api/v1/bot/tickets/789 \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"title": "Geyser leaking — urgent",
"description": "Updated context from the field.",
"urgency": "critical",
"category_id": 5,
"ticket_department_id": 3,
"target_completion_date": "2026-06-15",
"scheduled_start_at": "2026-06-10T09:00:00Z"
}'
target_completion_date is the "due date" — accepts ISO 8601 date or datetime. category_id / ticket_department_id must be on the calling client (422 otherwise — no cross-tenant binding).
Status changes have a dedicated endpoint (PATCH /tickets/{id}/status) with transition validation. Assignment changes have a dedicated endpoint (PATCH /tickets/{id}/assignment) with team-notification side effects. The generic endpoint deliberately ignores status / assigned_to_user_id / user_ids if present in the body — pass them to the dedicated endpoints instead.
Returns the full ticket shape on 200.
PATCH /tickets/{id}/status
Update ticket status.
curl -X PATCH https://flowtriage.com/api/v1/bot/tickets/789/status \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"status": "in-progress"}'
Valid transitions:
draft→open/in-progress/closedopen→in-progress/closedin-progress→paused/work-complete/failed/closedpaused→in-progress/closedwork-complete→awaiting-approval/in-progress/closedawaiting-approval→closed/in-progressfailed→in-progress/closedclosedcannot be reopened
PATCH /tickets/{id}/assignment
Assign a ticket. Sets primary assignee and/or team membership.
curl -X PATCH https://flowtriage.com/api/v1/bot/tickets/789/assignment \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"assigned_to_user_id": 42,
"user_ids": [42, 51, 67]
}'
assigned_to_user_id sets the primary assignee (the only signal that triggers a service-account worker to pick up the ticket). user_ids sets the full team (primary is auto-included). Either/both are optional. Newly added team members receive a TicketAssignedNotification.
POST /tickets/{id}/notes
Post an internal note onto a ticket.
curl -X POST https://flowtriage.com/api/v1/bot/tickets/789/notes \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"note": "Spoke to tenant — plumber arriving 14:30.",
"user_id": 42
}'
note is required. user_id is optional — when omitted, the note is attributed to the first service-account user on the calling client (and 422 if there is none, with a remediation pointer). When provided, the user_id must be on the calling client (422 otherwise).
Returns {id, note, user: {id, name}, created_at} on 201.
POST /tickets/{id}/notes/{note_id}/attachments
Attach a single file to a note (multipart/form-data with a file part).
curl -X POST https://flowtriage.com/api/v1/bot/tickets/789/notes/42/attachments \
-H "Authorization: Bearer ft_xxxxxxxx" \
-F "file=@invoice.pdf"
Mime allowlist: jpg/jpeg/png/gif/bmp/webp/svg/doc/docx/pdf. Max 10MB.
Returns {id, url, original_name, mime_type, size, created_at} on 201. The note must belong to the ticket and the ticket to the calling client — 404 if either link is wrong.
GET /time — cross-ticket time list
Lists time entries across ALL tickets on the calling client. Designed for timesheet integrations (Toggl / Harvest / QuickBooks Time), weekly utilisation dashboards, and billing automation — anything that needs to roll up time by user / date range / category rather than per-ticket.
Feature-flagged: 404 when clients.time_tracking_enabled is off.
# All entries this week for Sarah, in the Maintenance category, billable only
curl "https://flowtriage.com/api/v1/bot/time?user_id=42&category_id=5&from=2026-05-19&to=2026-05-25&billable=true" \
-H "Authorization: Bearer ft_xxxxxxxx"
Query parameters (all optional, combine freely):
| Param | Type | Notes |
|---|---|---|
from |
ISO 8601 date | Lower bound on started_at (inclusive, start-of-day) |
to |
ISO 8601 date | Upper bound on started_at (inclusive, end-of-day) |
user_id |
integer | Filter to a single staff member |
ticket_id |
integer | Filter to a single ticket (redundant with the per-ticket endpoint but useful for parity) |
category_id |
integer | Filter to entries on tickets in this category |
billable |
bool | true / false / 1 / 0 / "true" / "false" all accepted. Omit for both. |
running |
bool | true returns only in-flight timers (ended_at null); false returns only completed entries. Omit for both. |
per_page |
integer | 1-200, default 50 |
page |
integer | 1-indexed, default 1 |
{
"data": [
{
"id": 12,
"ticket_id": 789,
"user": {"id": 42, "name": "Sarah Smith"},
"started_at": "2026-05-20T09:00:00+02:00",
"ended_at": "2026-05-20T09:45:00+02:00",
"duration_minutes": 45,
"description": "Investigated the issue.",
"billable": true,
"is_running": false,
"created_at": "2026-05-20T09:45:00+02:00",
"ticket": {
"id": 789,
"ticket_number": 42,
"title": "Geyser leaking in unit 4B",
"category": {"id": 5, "name": "Maintenance"}
}
}
],
"aggregate": {
"total_minutes": 135,
"total_formatted": "2h 15m",
"entry_count": 3,
"running_count": 1
},
"meta": {"current_page": 1, "last_page": 1, "per_page": 50, "total": 3}
}
Per-entry fields are the same as the per-ticket endpoint above, PLUS a nested ticket object so the caller doesn't need to do a separate lookup. Ticket fields: id, ticket_number, title, category (or null).
Aggregate fields (computed across the FULL filter result, not just the current page — so the rollup is correct even on paginated responses):
| Field | Type | Notes |
|---|---|---|
total_minutes |
integer | Sum of duration_minutes across completed entries. Running timers are excluded — they don't have a duration yet. |
total_formatted |
string | Humanised form: "2h 15m" / "45m" / "0m" |
entry_count |
integer | Total rows in the filtered set (both completed and running) |
running_count |
integer | Subset of entry_count that are currently running (ended_at null) |
So a dashboard can show "Logged this week: 2h 15m across 3 entries · 1 timer currently running" without needing aggregate math client-side.
Start / stop timer flow at a glance
The /time/* endpoints give you a fully symmetric URL surface for the start-timer / stop-timer / delete-entry flow. You don't need to remember the ticket_id between requests — the log_id from the start response is enough to construct every subsequent call.
# 1. Start a timer (no ended_at)
START=$(curl -s -X POST https://flowtriage.com/api/v1/bot/time \
-H "Authorization: Bearer ft_xxx" \
-H "Content-Type: application/json" \
-d '{"ticket_id":789, "started_at":"2026-05-21T09:00:00Z"}')
LOG_ID=$(echo "$START" | jq -r .id)
# → {"id":12, "ticket_id":789, "is_running":true, "ended_at":null, ...}
# 2. ... time passes ...
# 3. Stop the timer (PATCH the SAME log_id, no ticket_id required)
curl -X PATCH https://flowtriage.com/api/v1/bot/time/$LOG_ID \
-H "Authorization: Bearer ft_xxx" \
-H "Content-Type: application/json" \
-d '{"ended_at":"2026-05-21T09:45:00Z"}'
# → {"id":12, "ticket_id":789, "is_running":false, "duration_minutes":45, ...}
# 4. (or, if you started it by mistake) delete it
curl -X DELETE https://flowtriage.com/api/v1/bot/time/$LOG_ID \
-H "Authorization: Bearer ft_xxx"
# → {"deleted":true}
The full set under /time/*:
| Method | URL | Purpose |
|---|---|---|
GET |
/time |
List/filter entries across all tickets on the client |
POST |
/time |
Create one entry (running timer if ended_at omitted) |
POST |
/time/bulk |
Import an array of completed entries in one transaction |
PATCH |
/time/{log_id} |
Update one entry (typically: stop a running timer) |
DELETE |
/time/{log_id} |
Hard-delete one entry |
Per-ticket variants under /tickets/{id}/time/* exist too and are interchangeable for any single-entry operation — pick whichever is more natural for your caller's data shape. The cross-ticket /time/* surface is the better fit when you have the log_id but not the ticket_id (typical for sync flows that don't keep local state per ticket).
POST /time — single entry write
Single-entry write counterpart to GET /time — for callers that want to log time without constructing the per-ticket URL. The ticket_id moves into the body.
curl -X POST https://flowtriage.com/api/v1/bot/time \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"ticket_id": 789,
"started_at": "2026-05-21T09:00:00Z",
"ended_at": "2026-05-21T09:45:00Z",
"description": "Investigated the issue.",
"billable": true,
"user_id": 42
}'
Body fields:
| Field | Required | Notes |
|---|---|---|
ticket_id |
yes | integer; must be on the calling client (422 otherwise) |
started_at |
yes | ISO 8601 datetime |
ended_at |
no | ISO 8601 datetime; omit to start a running timer. Must be after started_at if provided. |
description |
no | string, max 10000 |
billable |
no | bool, default true |
user_id |
no | integer; falls back to first service-account user on the client (422 if neither provided nor available). |
Returns the created entry (201) with the same shape as POST /tickets/{id}/time. Starting a new running entry auto-stops any other running entry the same user has on any ticket.
POST /time/bulk — bulk import
Designed for sync flows from external timesheets (Toggl, Harvest, QuickBooks Time, calendar invites). Send an array of up to 100 entries in one request. All-or-nothing: if any entry fails validation, the whole request 422s with per-row error pointers and ZERO entries land. Fix the bad row, resubmit, retry.
curl -X POST https://flowtriage.com/api/v1/bot/time/bulk \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"entries": [
{
"ticket_id": 789,
"started_at": "2026-05-20T09:00:00Z",
"ended_at": "2026-05-20T09:45:00Z",
"description": "Triaged the report",
"user_id": 42,
"billable": true
},
{
"ticket_id": 790,
"started_at": "2026-05-20T10:00:00Z",
"ended_at": "2026-05-20T10:30:00Z",
"user_id": 51
}
]
}'
Constraints:
- 1 to 100 entries per request. Above 100 → 422
entriesvalidation error. - Both
started_atANDended_atare REQUIRED per entry. Bulk doesn't support running timers — those go throughPOST /time(single) orPOST /tickets/{id}/time. Bulk is for importing completed work. - Different users per entry are fine. Each entry resolves
user_idindependently (defaults to first service-account if omitted). - All-or-nothing. Single DB transaction; any row failing validation rolls back the whole request.
Per-entry fields are the same as the single-entry endpoint, except ended_at is required. description / billable / user_id remain optional.
{
"data": [
{
"id": 12,
"ticket_id": 789,
"user": {"id": 42, "name": "Sarah Smith"},
"started_at": "2026-05-20T09:00:00+02:00",
"ended_at": "2026-05-20T09:45:00+02:00",
"duration_minutes": 45,
"description": "Triaged the report",
"billable": true,
"is_running": false,
"created_at": "2026-05-21T17:00:00+02:00"
},
{
"id": 13,
"ticket_id": 790,
"user": {"id": 51, "name": "Mike Johnson"},
"started_at": "2026-05-20T10:00:00+02:00",
"ended_at": "2026-05-20T10:30:00+02:00",
"duration_minutes": 30,
"description": null,
"billable": true,
"is_running": false,
"created_at": "2026-05-21T17:00:00+02:00"
}
],
"created_count": 2
}
Validation errors point at the specific bad row in the array using entries.{N}.{field} paths — easy to map back to the input for retry:
{
"message": "One or more entries reference tickets not on the calling client.",
"errors": {
"entries.1.ticket_id": ["ticket_id does not belong to the calling client."]
}
}
PATCH /time/{log_id} — update / stop a running timer
Update any subset of started_at, ended_at, description, billable on an existing log entry. Same validation rules as the per-ticket PATCH endpoint (ended_at must be after started_at after the merge). Cross-tenant scoped — 404 if the log belongs to a ticket on another client.
Most common use: stop a running timer by passing just ended_at:
curl -X PATCH https://flowtriage.com/api/v1/bot/time/12 \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"ended_at": "2026-05-21T09:45:00Z"}'
Other patches work the same way — correct a typo in a description without touching the timestamps, flip billable on/off, back-fill an ended_at on a forgotten running timer. Returns the updated entry (same single-entry shape as POST /time).
DELETE /time/{log_id} — hard-delete one entry
Removes the log entry. No soft-delete. Cross-tenant scoped — 404 if not on the calling client.
curl -X DELETE https://flowtriage.com/api/v1/bot/time/12 \
-H "Authorization: Bearer ft_xxxxxxxx"
Returns {"deleted": true} on 200.
GET/POST/PATCH/DELETE /tickets/{id}/time/{log_id}
Time-tracking sub-resource on a ticket. Feature-flagged per client (time_tracking_enabled) — endpoints 404 when off.
GET /tickets/{id}/time — list entries on a ticket:
curl https://flowtriage.com/api/v1/bot/tickets/789/time \
-H "Authorization: Bearer ft_xxxxxxxx"
{
"data": [
{
"id": 12,
"ticket_id": 789,
"user": {"id": 42, "name": "Sarah Smith"},
"started_at": "2026-05-20T09:00:00+02:00",
"ended_at": "2026-05-20T09:45:00+02:00",
"duration_minutes": 45,
"description": "Investigated the issue.",
"billable": true,
"is_running": false,
"created_at": "2026-05-20T09:45:00+02:00"
}
],
"total_minutes": 75,
"total_formatted": "1h 15m"
}
Top-level fields: data[] (the entries — newest first), total_minutes (integer, sum of completed entries — running timers excluded), total_formatted (humanised display string like 1h 15m, 45m, 0m).
Per-entry fields:
| Field | Type | Notes |
|---|---|---|
id |
integer | Time-log entry id |
ticket_id |
integer | Parent ticket |
user |
object / null | {id, name} of the entry author |
started_at |
ISO 8601 | When work started |
ended_at |
ISO 8601 / null | When work stopped; null = timer currently running |
duration_minutes |
integer / null | Derived from timestamps; null while running |
description |
string / null | Optional "what I did" text |
billable |
bool | Reserved for future invoicing — defaults true, no UI surfaces it yet |
is_running |
bool | Convenience flag for ended_at === null |
created_at |
ISO 8601 | Row creation (not the same as started_at — entries can be back-dated) |
POST /tickets/{id}/time — start or log time:
curl -X POST https://flowtriage.com/api/v1/bot/tickets/789/time \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"started_at": "2026-05-20T09:00:00Z",
"ended_at": "2026-05-20T09:45:00Z",
"description": "Investigated the issue.",
"billable": true,
"user_id": 42
}'
Body: started_at required, ended_at optional (null = running timer), description optional, billable optional (default true), user_id optional.
Omit ended_at to start a running timer. PATCH later with ended_at to stop. Starting a new running entry auto-stops any other running entry for the same user (one clock at a time).
user_id resolution follows the same rule as POST /notes — explicit user_id (must be on calling client) or fall back to first service-account user, or 422 with remediation pointer.
Response (201) — same shape as a single entry in the list above.
PATCH /tickets/{id}/time/{log_id} — update any subset of started_at, ended_at, description, billable. Same single-entry response shape.
DELETE /tickets/{id}/time/{log_id} — returns {"deleted": true} on 200.
Bookings
GET /resources
List bookable resources.
curl "https://flowtriage.com/api/v1/bot/resources?active_only=true" \
-H "Authorization: Bearer ft_xxxxxxxx"
Query param: active_only (bool, default true). Set to false to include archived resources too.
Response shape (200):
{
"data": [
{
"id": 5,
"name": "Court 1",
"description": "Outdoor tennis court (clay)",
"active": true,
"slot_minutes": 60,
"max_concurrent_bookings": 1
// (additional fields per the resource model — see admin UI)
}
]
}
GET /resources/{id}/availability
Per-day availability for a resource.
curl "https://flowtriage.com/api/v1/bot/resources/5/availability?date=2026-05-25" \
-H "Authorization: Bearer ft_xxxxxxxx"
Query param: date (required, YYYY-MM-DD).
Response includes the resource's bookable slots on that day with each slot's availability status (free / taken). Exact field set is determined by BuildResourceDateAvailabilityAction — see admin UI for the full schema.
POST /bookings
Create a booking request.
curl -X POST https://flowtriage.com/api/v1/bot/bookings \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"bookable_resource_id": 5,
"booking_date": "2026-05-25",
"start_time": "14:30",
"end_time": "15:30",
"purpose": "Coaching session",
"subscriber_id": 123
}'
Body fields:
| Field | Required | Notes |
|---|---|---|
bookable_resource_id |
yes | integer |
booking_date |
yes | YYYY-MM-DD |
start_time |
yes | HH:mm (24h) |
end_time |
no | HH:mm; falls back to resource's default slot length if omitted |
purpose |
no | string, max 255 |
notes |
no | string |
subscriber_id |
no | integer — bind to existing subscriber |
subscriber_email / subscriber_phone_number / subscriber_name |
no | Alternative to subscriber_id — let FlowTriage match/create the subscriber from the contact info |
user_id |
no | integer — staff user the booking is for |
booked_by_user_id |
no | integer — staff user who made the booking on behalf of someone else |
Returns the created booking on 201.
Memberships
GET /subscribers/{id}/membership
Get a subscriber's current membership.
curl https://flowtriage.com/api/v1/bot/subscribers/123/membership \
-H "Authorization: Bearer ft_xxxxxxxx"
Response when membership exists (200):
{
"data": {
"id": 88,
"subscriber_id": 123,
"membership_plan_id": 4,
"plan_name": "Annual Gold",
"payment_status": "paid",
"cost_amount": 1200.00,
"currency": "ZAR",
"term_model": "rolling_12_month",
"starts_at": "2026-01-01",
"ends_at": "2026-12-31",
"auto_renew": false,
"last_payment_at": "2026-01-01T10:00:00+02:00",
"next_due_at": "2027-01-01",
"cancelled_at": null,
"notes": null,
"terms_and_conditions_html": "...",
"terms_accepted_at": "2026-01-01T10:00:00+02:00"
}
}
When the subscriber has no membership, the response is {"data": null} (still 200).
Field reference:
| Field | Type | Notes |
|---|---|---|
payment_status |
string | paid / unpaid / partial / waived / refunded / overdue |
cost_amount |
float | Cast to float for JSON precision |
currency |
string | ISO 4217 three-letter code |
term_model |
string | rolling_monthly / rolling_12_month / calendar_year |
starts_at / ends_at / next_due_at |
date (YYYY-MM-DD) | Date precision |
last_payment_at / cancelled_at / terms_accepted_at |
ISO 8601 datetime / null | |
auto_renew |
bool | Whether the membership auto-renews at term end |
PUT /subscribers/{id}/membership
Create or update a subscriber's membership. Body accepts all the fields shown in the GET response above; either membership_plan_id (bind to an existing plan) OR all of plan_name + cost_amount + currency + term_model (free-form custom membership). payment_status and starts_at always required. Returns the upserted membership wrapped in {"data": ...} on 201.
POST /subscribers/{id}/membership/renew
Renew a membership. Body: {"renewed_at": "YYYY-MM-DD"}. Returns the renewed membership wrapped in {"data": ...}.
GET /membership-plans
List membership plans on the calling client.
curl "https://flowtriage.com/api/v1/bot/membership-plans?active_only=1" \
-H "Authorization: Bearer ft_xxxxxxxx"
Query param: active_only (bool). When set, restricts to active=true plans.
{
"data": [
{
"id": 4,
"name": "Annual Gold",
"description": "Yearly membership with full booking access.",
"terms_and_conditions_html": "...",
"term_model": "rolling_12_month",
"cost_amount": 1200.00,
"currency": "ZAR",
"auto_renew": false,
"grace_days": 14,
"active": true,
"created_at": "2026-01-15T10:00:00+02:00",
"updated_at": "2026-05-01T14:30:00+02:00"
}
]
}
Per-plan fields: id, name, description, terms_and_conditions_html, term_model, cost_amount (float), currency, auto_renew (bool), grace_days (int), active (bool), created_at, updated_at.
GET /membership-plans/{id} — same shape as a single entry above, wrapped in {"data": ...}.
POST /membership-plans
Create a plan.
curl -X POST https://flowtriage.com/api/v1/bot/membership-plans \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"name": "Annual Gold",
"term_model": "rolling_12_month",
"cost_amount": 1200,
"currency": "ZAR",
"auto_renew": false,
"grace_days": 14,
"active": true
}'
Body: name (required, unique per client), term_model (required, one of the three values), cost_amount (required, ≥ 0), currency (required, 3-letter ISO), description (optional), terms_and_conditions_html (optional), auto_renew (optional, default false), grace_days (optional, 0-90, default 0), active (optional, default true).
Returns the created plan wrapped in {"data": ...} on 201.
PATCH /membership-plans/{id} / DELETE /membership-plans/{id}
Update or delete a plan. DELETE returns 409 if any memberships exist — deactivate via PATCH active=false instead.
Integrations API — /api/v1/integrations/*
Designed for headless service-account workers (Hermes-style) that poll for tickets assigned to them. NOT for general agent tooling — use the Bot API for that. Key differences from the Bot API:
- Per-user (the service account behind the token), not per-client
- Primary-assignee-only ticket matching — putting a service account on the team pivot must NOT trigger it to start working on the ticket
- Cursor-based pagination (not page/per_page) — designed for incremental polling
- Server-side
polled_atreturned on every list response — eliminates clock-drift between the worker and FlowTriage
GET /me
Boot-time sanity check. Confirms the token is valid, surfaces the service-account identity + client context + token abilities + server time + current rate-limit posture, all in one call. Workers should hit this at startup and on auth-failure recovery paths.
curl https://flowtriage.com/api/v1/integrations/me \
-H "Authorization: Bearer <service-account-token>"
{
"service_account": {
"id": 42,
"name": "Hermes"
},
"client": {
"id": 1,
"name": "Acme Property Management"
},
"token": {
"id": 17,
"name": "Hermes Production",
"abilities": ["tickets:read", "tickets:transition", "tickets:time"],
"last_used_at": "2026-05-20T16:30:00+02:00"
},
"server_time": "2026-05-20T16:30:42+02:00",
"rate_limit": {
"remaining": 58,
"reset_at": "2026-05-20T16:31:42+02:00"
}
}
Field reference:
| Field | Type | Notes |
|---|---|---|
service_account.id / .name |
int / string | The user behind the token. Note this is the user whose is_service_account = true flag is set — not a generic staff user. |
client.id / .name |
int / string | Service accounts are scoped to a single client by design. null if somehow unbound (configuration error). |
token.id / .name |
int / string | Sanctum personal-access-token row id + the human-readable label set at issue time. |
token.abilities |
array | Sanctum scopes granted. The full enum: tickets:read, tickets:transition, tickets:time, tickets:write (reserved), tickets:admin (reserved). |
token.last_used_at |
ISO 8601 / null | Updated on every authed request — useful for detecting stale tokens. |
server_time |
ISO 8601 | FlowTriage's clock at the moment of response. Worker should use this (not its own clock) as the next ?since cursor on /me/tickets to eliminate drift. |
rate_limit.remaining |
int | Calls left in the current 60-second window |
rate_limit.reset_at |
ISO 8601 | When the rate-limit window resets |
GET /me/tickets
List tickets where assigned_to_user_id = the calling service account. Primary-assignee only — team-pivot membership does NOT match here, even though the Bot API's filter does. Putting a service account on the team without making them primary is a deliberate "watch but don't pick up" signal.
curl "https://flowtriage.com/api/v1/integrations/me/tickets?status=in-progress&since=2026-05-19T00:00:00Z&limit=50" \
-H "Authorization: Bearer <service-account-token>"
Query parameters:
| Param | Type | Notes |
|---|---|---|
since |
ISO 8601 datetime | Return only tickets with updated_at > since. Standard incremental-polling pattern — use the previous response's polled_at value here. |
status |
string | Filter to one of draft / open / in-progress / paused / work-complete / awaiting-approval / failed / closed |
limit |
integer | 1-200, default 50. Defines the chunk size for cursor pagination. |
cursor |
string | Opaque cursor from a previous response's next_cursor. Use to fetch the next page after exhausting limit results. |
{
"data": [
{
"id": 789,
"ticket_number": 42,
"title": "Geyser leaking in unit 4B",
"description": "Tenant reports water on the floor.",
"status": "in-progress",
"urgency": "high",
"metadata": {"hermes_run_id": "abc-123"},
"created_at": "2026-05-19T14:00:00+02:00",
"updated_at": "2026-05-20T09:15:00+02:00",
"assigned_to_user_id": 42
}
],
"next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wNS0yMFQwOToxNTowMCswMjowMCIsImlkIjo3ODl9",
"polled_at": "2026-05-20T16:30:42+02:00"
}
Top-level fields:
| Field | Type | Notes |
|---|---|---|
data[] |
array | Tickets matching the query, ordered by updated_at ASC then id ASC (stable cursor key). |
next_cursor |
string / null | Opaque base64'd JSON {updated_at, id}. null when no more pages. Pass back as ?cursor= on the next request. |
polled_at |
ISO 8601 | Server timestamp at response time. Use this as the next ?since value on subsequent polls to avoid clock-drift bugs. |
Per-ticket fields in data[]:
| Field | Type | Notes |
|---|---|---|
id |
integer | Internal ticket id — use this for the /me/tickets/{id} URL |
ticket_number |
integer | Per-client display number (the /tickets/{N} staff URL value) |
title |
string | |
description |
string | Body text (NOT null — empty string if unset) |
status |
string | Current status |
urgency |
string | normal / medium / high / critical |
metadata |
object / null | Arbitrary JSON. Workers typically write their own keys here via POST /transition for cross-call state (hermes_run_id, last_external_check_at, etc.) |
created_at / updated_at |
ISO 8601 | |
assigned_to_user_id |
integer | Always the calling service account's id. Surfaced for clarity even though it's tautological at this endpoint. |
GET /me/tickets/{id}
Full ticket detail including all notes. 404 if not primary-assigned to the calling service account.
curl https://flowtriage.com/api/v1/integrations/me/tickets/789 \
-H "Authorization: Bearer <service-account-token>"
Response (200) — same field set as the list response above, plus a notes[] array, wrapped under data:
{
"data": {
"id": 789,
"ticket_number": 42,
"title": "Geyser leaking in unit 4B",
"description": "Tenant reports water on the floor.",
"status": "in-progress",
"urgency": "high",
"metadata": {"hermes_run_id": "abc-123"},
"created_at": "2026-05-19T14:00:00+02:00",
"updated_at": "2026-05-20T09:15:00+02:00",
"assigned_to_user_id": 42,
"notes": [
{
"id": 1001,
"note": "Picked up the ticket. Investigating now.",
"user": {
"id": 42,
"name": "Hermes",
"is_service_account": true
},
"created_at": "2026-05-20T09:14:00+02:00"
},
{
"id": 1002,
"note": "Plumber dispatched for 14:30.",
"user": {
"id": 51,
"name": "Mike Johnson",
"is_service_account": false
},
"created_at": "2026-05-20T09:20:00+02:00"
}
]
}
}
Per-note fields:
| Field | Type | Notes |
|---|---|---|
id |
integer | Note row id |
note |
string | Body text |
user |
object / null | {id, name, is_service_account}. The is_service_account flag lets workers spot their own previous notes (vs human staff notes) without keeping local state — useful for "have I already commented on this update?" logic. |
created_at |
ISO 8601 |
Notes are ordered oldest-first (chronological).
POST /me/tickets/{id}/transition
Atomic note + status change + metadata merge in a single DB transaction. The canonical "do something with this ticket" call for a worker — every Hermes loop iteration ends with a transition.
curl -X POST https://flowtriage.com/api/v1/integrations/me/tickets/789/transition \
-H "Authorization: Bearer <service-account-token>" \
-H "Content-Type: application/json" \
-d '{
"note": "Picked up the ticket. Investigating now.",
"status": "in-progress",
"metadata": {"hermes_run_id": "abc-123", "step": "investigating"}
}'
Body fields:
| Field | Required | Notes |
|---|---|---|
note |
yes | string, 1-10000 chars. Every transition writes a note — even no-op state changes leave a trail. |
status |
no | Target status. Service accounts can ONLY drive to in-progress / work-complete / failed (approval / closure stays human). Must also be a valid transition from the current status — invalid combos return 422 with allowed_from_current in the body. |
metadata |
no | Object — shallow-merged into the ticket's existing metadata. Keys in the patch overwrite; existing keys not in the patch are preserved. |
Response (200) — same shape as GET /me/tickets/{id} (the refreshed ticket with notes including the new one).
Error responses worth knowing:
| Status | Body | Cause |
|---|---|---|
| 422 | {message: "Service accounts cannot transition tickets to status 'closed'. Allowed: in-progress, work-complete, failed."} |
Status not in the integration-allowed set |
| 422 | {message: "Invalid transition from 'closed' to 'in-progress'.", allowed_from_current: [...]} |
Status change isn't a legal transition from the current state |
| 404 | (empty) | Ticket isn't primary-assigned to the calling service account |
| 403 | (Sanctum middleware) | Token lacks tickets:transition ability |
GET/POST/PATCH/DELETE /me/tickets/{id}/time/{log_id}
Time-tracking sub-resource on assigned tickets. Author is ALWAYS the service-account behind the token — any user_id in the body is silently ignored.
Token needs the tickets:time ability. 404 when time_tracking_enabled is off on the ticket's client OR when the log isn't owned by the calling service account (no cross-user editing on this surface; for that use the Bot API).
# Start a running timer
curl -X POST https://flowtriage.com/api/v1/integrations/me/tickets/789/time \
-H "Authorization: Bearer <service-account-token>" \
-H "Content-Type: application/json" \
-d '{"started_at": "2026-05-20T09:00:00Z"}'
# Stop it later
curl -X PATCH https://flowtriage.com/api/v1/integrations/me/tickets/789/time/12 \
-H "Authorization: Bearer <service-account-token>" \
-H "Content-Type: application/json" \
-d '{"ended_at": "2026-05-20T09:45:00Z"}'
Same response shape as the Bot API time-tracking endpoints — see GET /tickets/{id}/time above. Per-entry: {id, ticket_id, user: {id, name}, started_at, ended_at, duration_minutes, description, billable, is_running, created_at}.
List response also includes total_minutes (integer) and total_formatted (humanised string) for the ticket's aggregate logged time across ALL users (not just the calling service account).
App-level "one running timer per user" rule: starting a new running entry (no ended_at) auto-stops any other running entry the service account has on any ticket. Workers can rely on this — no need to remember to stop a previous timer before starting a new one.
Mobile API — /api/v1/mobile/*
Sanctum user auth. Backs the FlowTriage PWA. Author of every write is $request->user() (the staff user behind the token).
Authentication
POST /api/v1/mobile/auth/login
{"email": "...", "password": "..."}
Returns a Sanctum token. Pass it as Authorization: Bearer <token> on every request.
Cross-ticket time tracking — /me/time/*
Mirrors the Bot API's /time/* surface but scoped to the calling Sanctum user. Used by the PWA's floating "currently timing" chip and the "My time this week" view.
| Method | URL | Purpose |
|---|---|---|
GET |
/me/time |
List the user's own entries across all tickets, with filters + aggregate |
GET |
/me/running-timer |
Convenience: the user's single active timer if any, else {"data": null}. Cheaper than /me/time?running=true because no pagination/aggregate computation |
POST |
/me/time |
Create a single entry — ticket_id in body, ended_at optional (running timer) |
PATCH |
/me/time/{log_id} |
Update an entry (typically: stop a running timer by passing ended_at) |
DELETE |
/me/time/{log_id} |
Delete one entry |
Per-user scope rules:
- Author is ALWAYS
$request->user()->id—user_idin the request body is silently ignored - Visibility filters to clients where the user is on the
client_userspivot ANDclients.time_tracking_enabledis on. Entries on tickets whose client has the feature off are invisible regardless of any filter. - 404 for cross-user editing/deletion (a user can't act on another user's entries via this surface)
Auto-stop rule: starting a new running entry (no ended_at) auto-stops any other running entry the user has on any ticket. Same as Bot API.
Response shape matches the Bot API's /time endpoints — each entry includes the ticket object inline ({id, ticket_number, title, category}) so the PWA doesn't need a separate lookup per row.
Tickets
The Mobile API mirrors the Bot API's ticket surface but is scoped per-user (a staff user only sees tickets they have access to — primary-assigned or on the team pivot).
GET /ticketsGET /tickets/{id}POST /ticketsPOST /tickets/{id}/status(note: POST not PATCH for legacy reasons)PATCH /tickets/{id}/assignmentPOST /tickets/{id}/notes+PATCH /notes/{id}+DELETE /notes/{id}+POST /notes/{id}/attachmentsGET/POST/PATCH/DELETE /tickets/{id}/time/{log_id}- Filter reference data:
GET /tickets/statuses,GET /tickets/urgencies,GET /tickets/categories,GET /tickets/locations,GET /tickets/departments
Messaging
GET /subscribers/{id}/messagesPOST /subscribers/{id}/messages
Documents
GET /documentsGET /documents/{id}/versions/{version}/download
Todos
- Full CRUD on the to-do board (
GET/POST/PATCH/DELETE /todos)
Common patterns
Pagination
List endpoints return:
{
"data": [...],
"meta": {
"current_page": 1,
"last_page": 5,
"per_page": 25,
"total": 117
}
}
Errors
401— bad/missing bearer token403— token valid but lacks the required ability (Integrations API) OR user can't access this resource (Mobile API)404— resource not found, OR exists but isn't on the calling tenant (deliberately indistinguishable to avoid cross-tenant probing)422— validation error. Body shape:{message, errors: {field: [messages...]}}
Cross-tenant safety
Every endpoint scopes through the calling client/user. The Bot API resolves $client from the bearer token then queries $client->tickets() / $client->subscribers() / etc. The Integrations API additionally filters by primary-assignee. Cross-tenant queries always return 404, never 403, so the response can't be used as an enumeration oracle.
Endpoints that don't exist yet (FAQ)
These come up in questions but aren't implemented. If you need one, ask and we'll add it:
- Bulk operations — no batch create/update endpoints. Send one request per resource for now.
- Webhook subscriptions — outbound webhooks (e.g. "notify me when a ticket transitions") are not yet user-configurable. The workflows engine (in the roadmap) will cover this.
- OAuth / OIDC — currently bearer tokens only.