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 tickets (also gates/me/categoriesand ticket correspondence)tickets:transition— atomically post a note + transition status + merge metadatatickets:time— log + edit time entries on assigned ticketsproducts:read— search the client's product index (/me/products,/me/products/ai-search). Mintable on its own, with no ticket access, for website-only integrations.subscribers:write— batch-upsert the client's audience (/me/subscribers)broadcasts:send— create WhatsApp-template broadcasts (/me/broadcasts)
Tokens are issued from an ability picker in the admin UI: Ticket worker (tickets:read / tickets:transition / tickets:time), Product search (products:read), Broadcasts & subscribers (broadcasts:send / subscribers:write), or any combination on one token. 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 |
Voice notes: when the Voice Note Transcription module is enabled for the client, an inbound WhatsApp voice note arrives with its transcript as
content(prefixed🎤), so consumers, search, and the AI copilot all see the spoken words as text. Until transcription completes — or if it was skipped (too long) or failed —contentis the[Audio] …placeholder instead.
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"
}
Attachments (email channel only). Send the request as multipart/form-data and add files under file (single) or file[] (array) — up to 5 files, 10MB each, mime allowlist jpg/jpeg/png/gif/bmp/webp/svg/doc/docx/pdf. Attaching a file on any non-email channel returns 422. When files are sent the 201 response gains an attachments array of {filename, mime_type, size, url}.
curl -X POST https://flowtriage.com/api/v1/bot/subscribers/123/messages \
-H "Authorization: Bearer ft_xxxxxxxx" \
-F "content=Here is your invoice." \
-F "channel=email" \
-F "file[]=@invoice.pdf"
Do-not-contact. If the subscriber is flagged do-not-contact, this endpoint returns 422 ("This subscriber is flagged do-not-contact and cannot be sent automated messages.") before any send work — Bot API sends are automation-initiated. Staff can still reply from the inbox (with a warning), and replying on a ticket thread via POST /tickets/{id}/correspondence is servicing and stays allowed.
Cc/Bcc are still not supported via this endpoint (use POST /tickets/{id}/correspondence or the staff-facing reply UI for those).
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. |
tag / tags |
string / array | Filter by free-form tag. Accepts slugs or ids, as an array (tags[]=warranty&tags[]=vip) or comma-separated (tags=warranty,vip). Both spellings behave identically; tags wins if both are sent. Multiple tags are OR (any-of), not AND — see below. Unknown or another client's tag matches nothing (empty result), never an unfiltered list. |
per_page |
integer | 1-100, default 25 |
page |
integer | 1-indexed, default 1 |
Tag filter semantics — OR, deliberately. ?tags=warranty,vip returns tickets carrying warranty OR vip. Tagging is ad-hoc and sparse (staff label what's in front of them), so an AND intersection would return almost nothing and read as a broken filter; OR answers the question people actually ask — "show me everything touching these labels".
{
"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",
"tags": [{"id": 7, "name": "Warranty", "slug": "warranty", "color": "amber"}],
"attachments": [
{"id": 31, "filename": "geyser.jpg", "mime_type": "image/jpeg", "size": 184320, "url": "https://…/geyser.jpg", "uploaded_at": "2026-05-20T09:02:00+02:00"}
],
"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) |
tags |
array | Free-form tags as {id, name, slug, color} rows. Always present — an empty array when the ticket has no tags or the client's Ticket Tags module is off. color is a palette key (e.g. amber), never null. |
attachments |
array | Files on the TICKET as {id, filename, mime_type, size, url, uploaded_at} rows. Always present — an empty array when the ticket carries no files. These are the customer's / requester's own files, not the attachments on internal notes (those stay under notes[] on the detail endpoint). |
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"},
"tags": [{"id": 7, "name": "Warranty", "slug": "warranty", "color": "amber"}],
"attachments": [
{"id": 31, "filename": "geyser.jpg", "mime_type": "image/jpeg", "size": 184320, "url": "https://…/geyser.jpg", "uploaded_at": "2026-05-20T09:02:00+02:00"}
],
"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 |
tags |
array | Free-form tags as {id, name, slug, color}. Always present (empty array when none / module off) |
attachments |
array | The ticket's own files as {id, filename, mime_type, size, url, uploaded_at}. Always present (empty array when none). Manage with POST /tickets/{id}/attachments and DELETE /tickets/{id}/attachments/{attachmentId} |
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 |
file / file[] |
no | multipart/form-data only — one or more files to attach to the ticket. Max 5 files, 10MB each; mime allowlist jpg/jpeg/png/gif/bmp/webp/svg/doc/docx/pdf |
JSON or multipart — your choice. The body above may be sent as plain JSON (unchanged from before — JSON callers are completely unaffected) OR as multipart/form-data additionally carrying file (single) or file[] (array). Files land on the TICKET, not on an internal note, so a customer's upload sits with their request where everyone can see it. A rejected file 422s before the ticket is created — you never end up with a ticket whose files silently vanished.
curl -X POST https://flowtriage.com/api/v1/bot/tickets \
-H "Authorization: Bearer ft_xxxxxxxx" \
-F "title=Geyser leaking in unit 4B" \
-F "description=Tenant reports water on the floor." \
-F "subscriber_id=123" \
-F "file[]=@geyser.jpg" \
-F "file[]=@plumber-quote.pdf"
Response (201) — same shape as GET /tickets/{id} except without notes[], and carrying attachments[] for whatever was uploaded (an empty array on the JSON path):
{
"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"},
"attachments": [
{"id": 31, "filename": "geyser.jpg", "mime_type": "image/jpeg", "size": 184320, "url": "https://…/geyser.jpg", "uploaded_at": "2026-05-20T14:00:00+02:00"}
],
"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.
This files the attachment as an internal note attachment. For a customer's own file — the thing they uploaded with their request — use the ticket-level endpoints below instead.
POST /tickets/{id}/attachments
Attach one or more files to the TICKET itself (multipart/form-data, file for one or file[] for several).
curl -X POST https://flowtriage.com/api/v1/bot/tickets/789/attachments \
-H "Authorization: Bearer ft_xxxxxxxx" \
-F "file[]=@geyser.jpg" \
-F "file[]=@plumber-quote.pdf"
Max 5 files per call, 10MB each. Mime allowlist: jpg/jpeg/png/gif/bmp/webp/svg/doc/docx/pdf. Same rules as POST /tickets and the note-attachment endpoint.
Returns 201 with {"data": [{id, filename, mime_type, size, url, uploaded_at}, ...]} — the rows created by this call. 404 when the ticket isn't on the calling client.
DELETE /tickets/{id}/attachments/{attachmentId}
Remove one file from a ticket. Deletes the stored file as well as the row.
curl -X DELETE https://flowtriage.com/api/v1/bot/tickets/789/attachments/31 \
-H "Authorization: Bearer ft_xxxxxxxx"
Returns {"deleted": true} on 200. 404 — never 403 — when the attachment isn't on that ticket, or the ticket isn't on the calling client, so the endpoint can't be used to probe attachment ids on another ticket or another tenant.
GET /tickets/{id}/correspondence
A ticket's correspondence thread — the messages that belong to THIS ticket (matched on ticket_id), so a sender's other tickets never bleed in. Oldest-first, paginate forward with since_id.
curl "https://flowtriage.com/api/v1/bot/tickets/789/correspondence?since_id=0&limit=50" \
-H "Authorization: Bearer ft_xxxxxxxx"
Query parameters: since_id (integer, return only rows with id > since_id), limit (1-100, default 50).
{
"data": [
{
"id": 5001,
"direction": "incoming",
"channel": "email",
"subject": "Geyser leaking in unit 4B",
"content": "There is water on the floor again.",
"html": "<p>There is water on the floor again.</p>",
"cc": [],
"bcc": [],
"attachments": [
{"filename": "photo.jpg", "mime_type": "image/jpeg", "size": 208432, "url": "https://..."}
],
"read": true,
"author": {"id": 123, "name": "Jane Doe"},
"created_at": "2026-05-19T14:00:00+02:00"
}
]
}
Per-row fields:
| Field | Type | Notes |
|---|---|---|
id |
integer | Message id |
direction |
string | incoming (from the customer) or outgoing (from staff / the API) |
channel |
string | email / whatsapp / telegram / web |
subject |
string / null | Email subject (null on non-email channels) |
content |
string | Plain-text body |
html |
string / null | Email HTML body — inbound cid: inline images are rewritten to hosted URLs; null on plain-text channels |
cc / bcc |
array | Email addresses on the row (empty array when none) |
attachments |
array | ALWAYS an array (empty when none). Each: {filename, mime_type, size, url}, populated for inbound email too, not just outbound |
read |
bool | Whether a staff user has marked the message read |
author |
object / null | {id, name} — the staff user or subscriber the row is attributed to |
created_at |
ISO 8601 |
POST /tickets/{id}/correspondence
Send a reply ON a ticket's thread. Mirrors POST /subscribers/{id}/messages, but the message is stamped onto THIS ticket: an email reply carries the thread's In-Reply-To/References headers so it lands in the customer's existing conversation, and the subject defaults to Re: <the ticket's last inbound email subject> (falling back to the ticket title). This is a servicing reply, so it is allowed even when the subscriber is flagged do-not-contact.
curl -X POST https://flowtriage.com/api/v1/bot/tickets/789/correspondence \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"content": "A plumber is booked for Tuesday 14:30.",
"channel": "email",
"subject": "Re: Geyser leaking in unit 4B",
"cc": ["manager@acme.com"],
"bcc": "audit@acme.com",
"user_id": 42
}'
Body fields:
| Field | Required | Notes |
|---|---|---|
content |
yes | string, max 10000 |
channel |
yes | email / whatsapp / telegram / web |
subject |
no | Email only. Defaults to Re: <thread subject> |
cc / bcc |
no | Email only. An array of addresses OR a comma-separated string (both shapes accepted), max 10 each. An invalid address returns 422 naming it — a requested Cc is never silently dropped |
user_id |
no | Author attribution — must be a user on the calling client, else the first service-account user is used, else 422 with a remediation pointer |
Attachments work exactly as on the messages endpoint: send multipart/form-data with file / file[] (email channel only, max 5 × 10MB, same mime allowlist).
Returns the created correspondence row (same shape as the GET above) on 201.
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.
Ticket Tags
Free-form labels staff put on tickets — "warranty", "chasing-supplier", "VIP" — as opposed to the curated category / sub-category / department lists. Every endpoint below returns 404 when the client's Ticket Tags module is off (Settings → General → Additional Modules), exactly like GET /products.
Normalisation. A tag's identity within a workspace is its slug, not its display name: names are matched case- and whitespace-insensitively, so "Urgent", "urgent " and "URGENT" are ONE tag. Creating a name that already exists returns the existing tag rather than erroring or duplicating.
Tenant scoping. A tag belongs to exactly one client and can never be attached to another client's ticket. Passing another workspace's tag id is a hard 422 (never a silent skip); another workspace's tag is invisible to GET /ticket-tags and 404s on delete.
GET /ticket-tags
curl https://flowtriage.com/api/v1/bot/ticket-tags \
-H "Authorization: Bearer ft_xxxxxxxx"
{
"data": [
{"id": 7, "name": "Warranty", "slug": "warranty", "color": "amber", "ticket_count": 12},
{"id": 9, "name": "VIP", "slug": "vip", "color": "rose", "ticket_count": 3}
]
}
| Field | Type | Notes |
|---|---|---|
id |
integer | |
name |
string | Display name as curated by the workspace |
slug |
string | The normalised identity — unique per client, and what ?tags= matches on |
color |
string | Palette key (zinc, red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, rose). Defaulted, never null |
ticket_count |
integer | How many tickets currently carry the tag |
POST /ticket-tags
curl -X POST https://flowtriage.com/api/v1/bot/ticket-tags \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"name": "Chasing supplier", "color": "amber"}'
| Field | Type | Notes |
|---|---|---|
name |
string | Required, max 255. Slugified for identity |
color |
string | Optional palette key (list above). An unrecognised value is dropped, not rejected |
201with the tag row when the tag is new.200with the existing tag row when its slug already exists (idempotent create — the stored name and colour are left alone).422whennameslugifies to nothing (e.g."!!!").
DELETE /ticket-tags/{id}
Deletes the tag and detaches it from every ticket. The tickets themselves are untouched. 404 for another client's tag.
{"deleted": true, "detached_from_tickets": 12}
POST /tickets/{id}/tags
Attach tags to a ticket. Pass ids, names, or both.
curl -X POST https://flowtriage.com/api/v1/bot/tickets/789/tags \
-H "Authorization: Bearer ft_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"tag_ids": [7], "tags": ["warranty", "vip"]}'
| Field | Type | Notes |
|---|---|---|
tag_ids |
array of integer | Must already exist on this client. A foreign or unknown id is a hard 422 naming the id — never a silent skip |
tags |
array of string | Names, find-or-created (case-/whitespace-insensitive) |
At least one of the two is required (422 otherwise). Idempotent — re-attaching a tag that's already on the ticket is a no-op, not an error. Returns 200 with the ticket's full tag set:
{"data": [{"id": 7, "name": "Warranty", "slug": "warranty", "color": "amber"}]}
404 when the ticket belongs to another client.
DELETE /tickets/{id}/tags/{tagId}
Detach one tag. Idempotent — detaching a tag that isn't attached still returns 200 with the current set (same {"data": [...]} shape as attach).
Bookings
GET /resources
List bookable resources.
curl "https://flowtriage.com/api/v1/bot/resources?active_only=true" \
-H "Authorization: Bearer ft_xxxxxxxx"
Query params:
active_only(bool, defaulttrue) — set tofalseto include archived resources too.location_id(int, optional) — restrict to one site. Client-scoped: another workspace's location id is a422, not an empty list, so "that site isn't yours" can never read as "that site has nothing".
Every row carries location — {id, name}, or null. A resource need not belong to a site, and a client operating from one place will have null on every row, so treat it as optional.
Every row also carries services[] — what this resource can be booked for, and what it costs here: {id, name, duration_minutes, price, currency}. The price is the resource's own, where it charges differently from the service's standard rate. price is nullable — a service may be free or priced on enquiry, and null must never be read as 0; currency is null alongside it. The array is empty for a resource simply booked by the hour, which is every resource until a client defines services.
POST /bookings accepts an optional bookable_service_id. It must be a service that resource actually offers and is still active, else 422. The price is snapshotted onto the booking at that moment — changing the rate later, or deleting the service, never revalues a booking already made.
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)
}
]
}
The availability response (below) also returns any group classes on that day in sessions[].
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.
Products
Search the client's synced product index. Source-blind and customer-safe: enquiry-only items never carry a price, and every row states the date it was last synced (prices are indicative "as of" that date). 404 when the client's Products module is off.
GET /products
curl "https://flowtriage.com/api/v1/bot/products?search=A4+paper&in_stock=true&per_page=25&page=1" \
-H "Authorization: Bearer ft_xxxxxxxx"
Query parameters:
| Param | Type | Notes |
|---|---|---|
search |
string | Required, min 2 chars. Ranks an exact code/barcode match first, then keyword relevance across name / brand / description / tags / category. |
in_stock |
bool | Optional. Restrict to in-stock items. |
per_page |
integer | 1-100, default 25 |
page |
integer | 1-indexed, default 1 |
{
"data": [
{
"id": 5501,
"external_id": "34319",
"code": "PPR-A4-80",
"barcode": "6001234567890",
"name": "A4 Paper 80gsm (Ream)",
"brand": "Typek",
"description": "White A4 multipurpose paper, 500 sheets.",
"category": "Paper",
"tags": ["a4", "80gsm", "ream"],
"uom": "ream",
"price": 89.90,
"currency": "ZAR",
"in_stock": true,
"is_promo": false,
"enquiry_only": false,
"image_reference": "https://...",
"url": "https://shop.example.co.za/product/PPR-A4-80",
"synced_at": "2026-07-17T02:00:00+02:00"
}
],
"meta": {"current_page": 1, "last_page": 3, "per_page": 25, "total": 62}
}
Per-row fields: id, external_id, code, barcode, name, brand, description, category, tags (array), uom, price (number, or null for enquiry_only items), currency, in_stock (bool), is_promo (bool), enquiry_only (bool), image_reference, url, synced_at (ISO 8601, always present).
id vs external_id — they are different things and are easy to confuse. id is FlowTriage's own primary key for the row and means nothing in your system. external_id is the source system's own product id — ONeX's ProductID (the number in a stock-view URL and in media/PROD/N…/ image paths), the WooCommerce product or variation id, the Yoco item id — and it is the stable key for joining our rows back to the catalogue they came from. It is also the value a connector's url_pattern interpolates as {external_id}. Always a string.
url is the product's public click-through link — nullable, treat it as optional. It is populated only where the source publishes a product page (WooCommerce's permalink, per variation where one exists) or where the connector has a url_pattern configured (Settings → the connector's tab; https://shop.example.co.za/product/{code}, supporting {code} / {barcode} / {external_id}, values URL-encoded). A real source URL always wins over the pattern. ONeX and Yoco publish no product page, so their rows carry url: null unless a pattern is set.
The link is written onto the row at sync time, not per request — so after changing a connector's url_pattern, existing rows keep their old value (or null) until a full re-sync: php artisan products:sync-connectors --connector={id} --full. An ordinary run is incremental and only touches what the source reports as modified, which a config change on our side does not affect.
GET /products/ai-search
AI-interpreted product search. A small LLM pass turns a natural-language search ("something to get brake dust off mag wheels") into catalogue keywords, then the SAME ranked index search runs — the AI improves recall but results always come from the real index (no invented products or prices). Same auth, gate, parameters and row shape as GET /products, plus an ai block describing how the query was interpreted.
Fail-soft: any AI failure (or AI terms matching nothing) falls back to plain keyword search with ai.used = false — it never 500s. Throttled to 30 requests/minute.
curl "https://flowtriage.com/api/v1/bot/products/ai-search?search=something+to+clean+brake+dust+off+wheels" \
-H "Authorization: Bearer ft_xxxxxxxx"
Response (200) — the normal data + meta, plus:
{
"ai": {
"used": true,
"terms": ["wheel cleaner", "brake dust remover"],
"in_stock": null
}
}
ai.used (bool — was the AI interpretation applied), ai.terms (array of the keywords it produced, or null on fallback), ai.in_stock (the stock intent the AI inferred, or null).
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 |
tag / tags |
string / array | Filter by free-form tag — slugs or ids, array or comma-separated. Identical semantics to the Bot API: multiple tags are OR (any-of), and an unknown or foreign tag matches nothing rather than falling back to unfiltered. Needs tickets:read like the rest of this endpoint. |
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"},
"tags": [{"id": 7, "name": "Warranty", "slug": "warranty", "color": "amber"}],
"attachments": [
{"id": 31, "filename": "geyser.jpg", "mime_type": "image/jpeg", "size": 184320, "url": "https://…/geyser.jpg", "uploaded_at": "2026-05-20T09:02:00+02:00"}
],
"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.) |
tags |
array | Free-form tags as {id, name, slug, color}. Always present — empty array when the ticket has no tags or the client's Ticket Tags module is off. Also on GET /me/tickets/{id} and the transition response |
attachments |
array | The ticket's own files as {id, filename, mime_type, size, url, uploaded_at}. Always present — empty array when the ticket carries no files. Also on GET /me/tickets/{id} and the transition response. These are the requester's files; internal-note attachments are not included. Read-only on this surface — write via the Bot or Mobile API |
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"},
"attachments": [
{"id": 31, "filename": "geyser.jpg", "mime_type": "image/jpeg", "size": 184320, "url": "https://…/geyser.jpg", "uploaded_at": "2026-05-20T09:02:00+02:00"}
],
"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).
GET /me/tickets/{id}/correspondence
The ticket's correspondence thread — the messages that belong to THIS ticket, so a sender's other tickets don't bleed in. Same 404-if-not-assigned rule as show; needs tickets:read. Identical row shape to the Bot API's GET /tickets/{id}/correspondence.
curl "https://flowtriage.com/api/v1/integrations/me/tickets/789/correspondence?since_id=0&limit=50" \
-H "Authorization: Bearer <service-account-token>"
Query parameters: since_id (integer), limit (1-100, default 50). Each row: {id, direction, channel, subject, content, html, cc, bcc, attachments, read, author, created_at} — attachments is always an array (empty when none) and is populated for inbound email too.
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. |
category_id / sub_category_id |
no | Optional reclassification, applied in the same transaction. Must belong to the ticket's client (and the sub-category to the effective parent category) — 422 otherwise. Setting category_id without sub_category_id auto-clears a now-stale child; pass null on either to clear explicitly. |
tags |
no | Array of tag NAMES, find-or-created (case-/whitespace-insensitive). Labels the ticket as part of the same atomic write. Additive only — never removes tags a human put on. Uses the existing tickets:transition ability. |
tag_ids |
no | Array of tag ids that must already exist on the ticket's client. A foreign or unknown id is a 422 and nothing is written (not even the note). |
Response (200) — same shape as GET /me/tickets/{id} (the refreshed ticket with notes including the new one, and the ticket's tags[]).
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 |
| 422 | {message: "Ticket tags are not enabled for this client."} |
tags / tag_ids sent while the client's Ticket Tags module is off |
| 422 | {errors: {tag_ids: ["Unknown tag id(s) for this client: 99."]}} |
A tag id that isn't on the ticket's client |
| 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.
GET /me/categories
Categories on the service account's client, with sub_categories[] embedded per row. Same shape as the Bot API's category surface so worker decoders can be shared. Needs tickets:read.
curl https://flowtriage.com/api/v1/integrations/me/categories \
-H "Authorization: Bearer <service-account-token>"
Response: {data: [{id, name, sub_categories: [{id, name}]}], meta: {sub_categories_enabled}}.
GET /me/products / GET /me/products/ai-search
Product search over the client's synced index, for external systems that need product data and nothing else — a client's own website search box, for example. Needs the products:read ability, which can be minted with NO ticket access. Identical parameters, row shape and semantics to the Bot API's GET /products / GET /products/ai-search (shared presenter: enquiry-only → price: null, synced_at always present; external_id — the SOURCE system's product id, distinct from our own id; url present but nullable — the product's public click-through link, only where the source or the connector's url_pattern supplies one; the ai block + keyword fallback on ai-search). 404 when the client's Products module is off.
curl "https://flowtriage.com/api/v1/integrations/me/products?search=A4+paper&in_stock=true" \
-H "Authorization: Bearer <service-account-token>"
POST /me/subscribers
Batch audience upsert — the staging step before a broadcast. Push up to 500 contacts per call; find-or-create by phone first, then email. Provided fields overwrite, omitted fields are left alone. Group sync is attach-only (never strips memberships), and group_names find-or-creates client groups. Needs the subscribers:write ability. Throttled to 30 requests/minute.
Validation is all-or-nothing: a row with neither phone nor email, or any group_ids entry that isn't on this client, 422s the whole batch.
curl -X POST https://flowtriage.com/api/v1/integrations/me/subscribers \
-H "Authorization: Bearer <service-account-token>" \
-H "Content-Type: application/json" \
-d '{
"subscribers": [
{
"phone_number": "+27821234567",
"email": "jane@example.com",
"name": "Jane Doe",
"group_ids": [4],
"group_names": ["Newsletter"],
"do_not_contact": false
}
]
}'
Body: subscribers (array, 1-500 rows). Per row — phone_number and/or email (at least one required), name, group_ids (existing client group ids), group_names (find-or-created, max 10), and do_not_contact.
do_not_contact is a one-way ratchet: true sets the subscriber's do-not-contact flag (stamping the source as the integrations API); false or omitted never clears an existing flag — un-flagging is a human decision. Do-not-contact subscribers are then silently skipped at broadcast staging.
{
"data": [
{"id": 123, "phone_number": "+27821234567", "email": "jane@example.com", "name": "Jane Doe", "created": false, "group_ids": [4, 9]}
],
"meta": {"created": 0, "updated": 1}
}
POST /me/broadcasts
Optional segment object as a third way to target (combinable with group_ids/subscriber_ids, or on its own): {"type": "lapsed|booked_within|not_booked_within|membership_expiring", "days": 1..730}. The segment is resolved to subscribers when the broadcast sends, not when it's created — a scheduled "lapsed 90 days" broadcast targets whoever is lapsed on send day. Unknown types are a 422 at the door (the resolver's failure mode is deliberately "nobody", never "everybody"); do-not-contact subscribers are dropped at staging exactly as for any other recipient; the segment echoes back as recipient_segment on responses.
Create a WhatsApp-template broadcast through the same staging/sending machinery as the in-app Broadcasts page. Needs the broadcasts:send ability (mintable on its own). The template must belong to the client and be Meta-approved (422 otherwise, including media-header templates — UI-only for now). Recipients are strictly client-scoped: a foreign group or subscriber id is a hard 422, never a silent skip. Throttled to 10 requests/minute.
curl -X POST https://flowtriage.com/api/v1/integrations/me/broadcasts \
-H "Authorization: Bearer <service-account-token>" \
-H "Content-Type: application/json" \
-d '{
"template_name": "order_update",
"group_ids": [4],
"subscriber_ids": [123, 456],
"variables": {
"header": {"1": "SO12345"},
"body": {"1": "Site A", "2": "Wood"}
},
"scheduled_at": "2026-08-01T09:00:00Z"
}'
Body fields:
| Field | Notes |
|---|---|
template_name or whatsapp_template_id |
Identify the approved template (one or the other). |
group_ids |
Client-scoped group ids. |
subscriber_ids |
Client-scoped subscriber ids. Provide at least one of group_ids / subscriber_ids; duplicates across the two are de-duplicated. |
variables |
Template placeholder values (see below). Unknown variable names 422 with the defined list. |
scheduled_at |
Optional ISO 8601 in the future → the broadcast is scheduled; omit to send now (queued). |
Two variables shapes are accepted:
- Flat —
{"1": "SO12345", "2": "Wood"}. Fills the header and body of the same number with the same value (back-compatible). - Sectioned —
{"header": {"1": "SO12345"}, "body": {"1": "Site A", "2": "Wood"}}, mirroring Meta's header/body split. Meta scopes placeholder numbering per component, so header{{1}}and body{{1}}are DIFFERENT placeholders and may carry different values — the sectioned shape lets you set each independently.
{
"id": 88,
"status": "queued",
"template": {"id": 12, "name": "order_update"},
"scheduled_at": null,
"sent_at": null,
"totals": {"recipients": 3, "messages": 3, "processed": 0, "failed": 0}
}
GET /me/broadcasts/{id}
Broadcast progress. Same broadcasts:send ability; 404 cross-tenant.
The show response (and only the show response — the 201 create payload is unchanged) also carries an outcomes block: {window_days, recipients, replied, reply_rate_pct (null when nobody was reached), booked} — distinct recipients who wrote back, and who made a booking, within window_days of the send. Attribution is deliberately simple ("within the window"), so read it as engagement, not proven causation.
curl https://flowtriage.com/api/v1/integrations/me/broadcasts/88 \
-H "Authorization: Bearer <service-account-token>"
Returns the same shape as the create response — poll status and totals (recipients, messages, processed, failed) until sent_at is populated.
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}/attachmentsPOST /tickets/{id}/attachments+DELETE /tickets/{id}/attachments/{attachmentId}GET/POST/PATCH/DELETE /tickets/{id}/time/{log_id}
Ticket attachments. POST /tickets/{id}/attachments takes multipart/form-data with file (single) or file[] (array) — max 5 files, 10MB each, mime allowlist jpg/jpeg/png/gif/bmp/webp/svg/doc/docx/pdf. Returns 201 with {"data": [{id, filename, mime_type, size, url, uploaded_at}, ...]}; the uploader is the Sanctum user behind the token. DELETE .../{attachmentId} removes the row and the stored file, returning {"deleted": true} — 404 (not 403) when the attachment isn't on that ticket.
GET /tickets/{id} carries attachments[] in the same {id, filename, mime_type, size, url, uploaded_at} shape, always present (empty array when the ticket has no files). These are the ticket's OWN files — a customer's uploads — distinct from the per-note attachments[] still nested under notes[].
- 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
Bookings
The coach's half of the bookings flow, built on the same actions as the staff calendar and the Bot API — the phone and the front desk can never disagree about whether 10:00 is free. Every endpoint takes an explicit client_id the signed-in user must be a member of (403 otherwise) and returns 404 on all of them when the client's bookings module is off, so the app can treat the feature as simply absent. The login/me payload carries features.bookings_enabled per client so the app knows at boot.
GET /bookings/resources?client_id=&location_id=— active resources withlocation(nullable) andservices[](per-resource prices,pricenullable — never read as 0). A foreignlocation_idis a422, not an unfiltered list.GET /bookings/resources/{id}/availability?client_id=&date=YYYY-MM-DD&duration=— open start times from the shared engine (capacity-aware: a part-full class blocks the 1:1 slot but stays joinable). Foreign resource →404.GET /bookings?client_id=&date_from=&date_to=&resource_id=&status=— the diary: bookings in time order with resource, service, subscriber, price snapshot and attendance. Defaults to today; range capped at 62 days.POST /bookings— create a booking request. Same body as the Bot API'sPOST /bookings(resource, optional service with price snapshot, date, start time, subscriber by id or email/phone/name) plusclient_id;booked_byis always the signed-in user — a phone can't book as someone else.POST /bookings/{id}/cancel— cancel + customer notification. Idempotent (cancelling twice sends one notification); an attended booking refuses with 422 — attendance is a fact an invoice rests on, not a state to roll back from a phone.PATCH /bookings/{id}/attendance— the coach confirming the lesson happened, attributed to the signed-in user. Same rules as everywhere: not before the booking's start, never on a cancelled booking, idempotent.GET /bookings/resources/{id}/availability-range?client_id=&date_from=&date_to=&duration=— the calendar month in one call:available_weekdays[]plus per-day{date, weekday_available, open_slots, joinable_sessions}counts (past days render but count 0; today drops already-passed times). A summary on purpose — drill into a day with the single-date endpoint. Range capped at 62 days; a baddurationis a 422, not sixty silently-empty days.GET /bookings/sessions?client_id=&date_from=&date_to=&resource_id=— upcoming group classes with live seat counts:{id, date, start_time, end_time, resource, service, capacity, seats_taken, seats_left, joinable, price (nullable — never read as 0)}. Defaults to a two-week window; same 62-day cap; foreignresource_idis a 422.POST /bookings/sessions/{id}/join— take one seat in a class for a contact (subscriber_id, client-scoped — foreign id fails validation). Seat count is checked in a locked transaction; re-joining is idempotent (same booking back, never a second seat); full class / cancelled class → 422. Returns the booking (201) with the usual shape plusresource_session_id; price is snapshotted at join time.POST /bookings/{id}/payment-request— mint a Yoco payment link for the booking and send the customer the payment-request WhatsApp template (the same action + notification the automatic payment policy uses). Returns201 {id, amount, currency, status, payment_url}. Refusals mirror the action: already paid / no price → 422, no enabled Yoco connector → 422, link mint failure → 502 — and the booking always stands.
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.