FlowTriage
Developer Reference

Build against the FlowTriage APIs without spelunking through code.

Auth patterns, endpoint contracts, payload examples, and response shapes for the Bot, Integrations, and Mobile APIs.

Common request
PATCH /api/v1/mobile/tickets/789/assignment
Authorization: Bearer <token>

{
  "assigned_to_user_id": 42,
  "user_ids": [42, 51, 67]
}

Primary owner plus team members. The primary is auto-included in the team response.

Same source as the in-app assistant
This markdown is also ingested into the system help index.
Help Center

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:

HTTP request
HTTP
PATCH /api/v1/mobile/tickets/{ticket_id}/assignment
Authorization: Bearer <mobile_sanctum_token>
Content-Type: application/json
Accept: application/json
Request payload
JSON
{
  "assigned_to_user_id": 42,
  "user_ids": [42, 51, 67]
}
Response (200)
JSON
{
  "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:

HTTP request
HTTP
POST /api/v1/mobile/tickets
Authorization: Bearer <mobile_sanctum_token>
Content-Type: application/json
Accept: application/json
JSON example
JSON
{
  "client_id": 1,
  "title": "Coupon issue",
  "description": "Customer cannot redeem a coupon.",
  "assigned_to_user_id": 42,
  "user_ids": [42, 51, 67]
}
Response (201)
JSON
{
  "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:

HTTP request
HTTP
PATCH /api/v1/bot/tickets/{ticket_id}/assignment
Authorization: Bearer ft_xxxxxxxx
Content-Type: application/json
Accept: application/json
Request payload
JSON
{
  "assigned_to_user_id": 42,
  "user_ids": [42, 51, 67]
}
Response (200)
JSON
{
  "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_id and every user_ids[] entry must belong to the ticket's client.
  • Passing user_ids replaces the team list with the supplied users plus the primary assignee.
  • Omitting user_ids while setting assigned_to_user_id updates 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 header
HEADER
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 header
HEADER
Authorization: Bearer ft_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/json

Integrations API

Authorization header
HEADER
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 tickets
  • tickets:transition — atomically post a note + transition status + merge metadata
  • tickets: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 request
BASH
curl https://flowtriage.com/api/v1/bot/info \
  -H "Authorization: Bearer ft_xxxxxxxx" \
  -H "Accept: application/json"
Response (200)
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 request
BASH
curl https://flowtriage.com/api/v1/bot/users \
  -H "Authorization: Bearer ft_xxxxxxxx" \
  -H "Accept: application/json"
Response (200)
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 request
BASH
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 and updateOrCreate the client_users link 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 request
BASH
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 request
BASH
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 request
BASH
curl https://flowtriage.com/api/v1/bot/categories \
  -H "Authorization: Bearer ft_xxxxxxxx"
Response (200)
JSON
{
  "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 request
BASH
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).

Response (200)
JSON
{
  "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 request
BASH
curl https://flowtriage.com/api/v1/bot/subscribers/123 \
  -H "Authorization: Bearer ft_xxxxxxxx"
Response (200)
JSON
{
  "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 request
BASH
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
Response (200)
JSON
{
  "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 request
BASH
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).

Response (201)
JSON
{
  "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 request
BASH
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
Response (200)
JSON
{
  "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 request
BASH
curl https://flowtriage.com/api/v1/bot/tickets/789 \
  -H "Authorization: Bearer ft_xxxxxxxx"
Response (200)
JSON
{
  "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 (use PATCH /tickets/{id} to set, but read-back goes via GET not yet)
  • scheduled_start_at — not currently surfaced
  • closed_at, closed_by_id — not currently surfaced (use status to detect closed tickets)
  • work_started_at, work_completed_at — not currently surfaced
  • users[] (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 request
BASH
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[]:

JSON example
JSON
{
  "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 request
BASH
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 request
BASH
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:

  • draftopen / in-progress / closed
  • openin-progress / closed
  • in-progresspaused / work-complete / failed / closed
  • pausedin-progress / closed
  • work-completeawaiting-approval / in-progress / closed
  • awaiting-approvalclosed / in-progress
  • failedin-progress / closed
  • closed cannot be reopened

PATCH /tickets/{id}/assignment

Assign a ticket. Sets primary assignee and/or team membership.

cURL request
BASH
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 request
BASH
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 request
BASH
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.

cURL request
BASH
# 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
Response (200)
JSON
{
  "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.

cURL request
BASH
# 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 request
BASH
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 request
BASH
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 entries validation error.
  • Both started_at AND ended_at are REQUIRED per entry. Bulk doesn't support running timers — those go through POST /time (single) or POST /tickets/{id}/time. Bulk is for importing completed work.
  • Different users per entry are fine. Each entry resolves user_id independently (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.

Response (201)
JSON
{
  "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:

JSON example
JSON
{
  "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 request
BASH
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 request
BASH
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 request
BASH
curl https://flowtriage.com/api/v1/bot/tickets/789/time \
  -H "Authorization: Bearer ft_xxxxxxxx"
Response (200)
JSON
{
  "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 request
BASH
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 request
BASH
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):

JSON example
JSON
{
  "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 request
BASH
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 request
BASH
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 request
BASH
curl https://flowtriage.com/api/v1/bot/subscribers/123/membership \
  -H "Authorization: Bearer ft_xxxxxxxx"

Response when membership exists (200):

JSON example
JSON
{
  "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 request
BASH
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.

Response (200)
JSON
{
  "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 request
BASH
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_at returned 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 request
BASH
curl https://flowtriage.com/api/v1/integrations/me \
  -H "Authorization: Bearer <service-account-token>"
Response (200)
JSON
{
  "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 request
BASH
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.
Response (200)
JSON
{
  "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 request
BASH
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:

JSON example
JSON
{
  "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 request
BASH
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).

cURL request
BASH
# 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

Code example
TEXT
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()->iduser_id in the request body is silently ignored
  • Visibility filters to clients where the user is on the client_users pivot AND clients.time_tracking_enabled is 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 /tickets
  • GET /tickets/{id}
  • POST /tickets
  • POST /tickets/{id}/status (note: POST not PATCH for legacy reasons)
  • PATCH /tickets/{id}/assignment
  • POST /tickets/{id}/notes + PATCH /notes/{id} + DELETE /notes/{id} + POST /notes/{id}/attachments
  • GET/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}/messages
  • POST /subscribers/{id}/messages

Documents

  • GET /documents
  • GET /documents/{id}/versions/{version}/download

Todos

  • Full CRUD on the to-do board (GET/POST/PATCH/DELETE /todos)

Common patterns

Pagination

List endpoints return:

JSON example
JSON
{
  "data": [...],
  "meta": {
    "current_page": 1,
    "last_page": 5,
    "per_page": 25,
    "total": 117
  }
}

Errors

  • 401 — bad/missing bearer token
  • 403 — 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.