The Xagle Communications API
REST access to the Communication Hub: the unified inbox covering every channel — client/vendor email, general mail (unmatched inbound), support tickets, SMS, WhatsApp, web chat and voice. The API reads the same thread list the in-app Conversations page uses and replies through the same per-channel send paths, so anything sent here shows up in the app (and vice versa) instantly.
All endpoints require authentication: POST /api/v1/login with email + password returns {"token": "..."}; pass it as Authorization: Bearer {token} on every call. Responses follow the standard envelope: {"success": true, "message": "...", "data": ...} on success, {"success": false, "message": "..."} on failure, and {"errors": {field: [...]}} with HTTP 422 on validation failure.
Status codes: 200 read/update, 201 reply sent, 404 thread/client/vendor not found (or not visible to the token's user), 410 attachment record with no stored file, 422 validation failure, 400 business-rule failure (no texting consent, closed conversation, channel cannot receive replies, Conversations feature disabled), 409 duplicate in-flight idempotency key.
Visibility: results are scoped exactly like the in-app inbox. Admin tokens see everything; a standard user's token sees client threads only with the clients view permission and vendor threads only with the vendor view permission. General-mail and voice threads are admin-only. Free-plan companies receive 403 on the whole API.
Pagination: the thread list paginates with per_page (max 200) + page; the paginator object is returned under data. Thread messages use id cursors instead — see below.
Threads (unified inbox)
| Operation | Endpoint |
|---|---|
| List threads | GET /api/v1/communications/threads |
| Thread + messages | GET /api/v1/communications/threads/{id} |
| Messages only | GET /api/v1/communications/threads/{id}/messages |
| One message | GET /api/v1/communications/threads/{id}/messages/{message_id} |
| Reply | POST /api/v1/communications/threads/{id}/reply |
| Mark read | POST /api/v1/communications/threads/{id}/read |
| Archive | POST /api/v1/communications/threads/{id}/archive |
| Unarchive | POST /api/v1/communications/threads/{id}/unarchive |
| Client perspective | GET /api/v1/clients/{id}/communications |
| Vendor perspective | GET /api/v1/vendors/{id}/communications |
| Download an attachment | GET /api/v1/communications/attachments/{url_key}/download |
List filters: channel (email / support_ticket / sms / whatsapp / chat / general_mail / voice), client_id or vendor_id (or the explicit pair party_type = client/vendor + party_id — the spellings are mutually exclusive), status (active / archived / closed, applies only when channel is sms/whatsapp/chat), unread_only=1, archived=1 (the default view excludes archived threads), include_ignored=1 (include ignored general-mail senders), search (subject / preview / party name / sender email), participant_email (see below), updated_after (threads whose last activity is at or after this time — for sync polling).
participant_email: every conversation whose correspondent is that address — the "what is the latest with this person" lookup, for when you have an email address but not the record it belongs to. It matches the client or vendor the thread is filed under (their own address, or a contact of theirs at that address), and unrouted general-mail senders. Case does not matter; a malformed address returns 422.
It does not search every message’s To/CC line, so an address that only ever appears copied on someone else’s thread will not match. That keeps the lookup fast on large mailboxes.
Thread row shape:
{
"id": 42, "channel": "sms", "subject": "Order questions",
"party": {"type": "client", "id": 7, "name": "Bender McBender"},
"last_message_preview": "Sounds good, thanks!",
"last_message_direction": "in",
"last_message_at": "2026-08-31T14:05:12-04:00",
"is_unread": true, "archived_at": null,
"threadable_type": "FI\\Modules\\Conversations\\Models\\Conversation",
"threadable_id": 12, "conversation_status": "active"
}
party.type is null for unlinked threads (general mail, system tickets, unknown callers); conversation_status is null outside sms/whatsapp/chat. threadable_* identifies the underlying system-of-record row.
updated_after semantics: timestamps compare in the company's local timezone frame. The reliable pattern is to echo back the last_message_at value from a previous response (offset included) rather than generating your own UTC timestamps.
The client/vendor perspective routes return the same rows pre-filtered to that party (any list filter can be combined) and 404 when the client/vendor does not exist. They require the corresponding clients/vendors view permission.
Thread detail + messages
GET /api/v1/communications/threads/{id} returns:
{
"success": true,
"message": "Record retrieved successfully",
"data": {
"thread": { ...thread row... },
"messages": [
{"id": 310, "direction": "in", "author": "Bender McBender",
"body": "Hi there", "at": "2026-08-31T13:59:02-04:00", ...}
],
"paging": {"has_more": true, "next_before_id": 310, "latest_id": 315}
}
}
Messages come back oldest-first, newest page by default (per_page, default 20, max 200). Message rows carry channel-specific extras: email/general mail add mail_id, subject, attachments ({filename, size, is_image, url, url_key, stored}), delivery (delivery-state icon + label), original_url, is_hidden (plus include_hidden=1 to show archived messages); conversation channels add chips (reactions) and is_tapback; voice rows add audio_url for voicemail recordings.
Cursors:
- History scrollback: pass
before_id= the previous response'spaging.next_before_idto fetch the next older page.next_before_idisnullwhen history is exhausted. - Delta polling: pass
after_id= the lastpaging.latest_idyou saw; you get only newer messages, oldest-first.
General mail caveat: subject-split general-mail threads are filtered in application code over a bounded 200-row window, so has_more is best-effort within that window for very long sender histories.
Fetching a thread does not mark it read — pollers and indexers would silently clear the team's unread state. Call the mark-read endpoint explicitly.
Messages without the thread row: GET /api/v1/communications/threads/{id}/messages returns the same messages and paging under data, with thread_id and channel in place of the full thread row. It takes the same per_page, before_id, after_id and include_hidden parameters. Use it for paging and polling once you already have the thread.
Reading one message: GET /api/v1/communications/threads/{id}/messages/{message_id} returns {thread_id, channel, message}. Bodies come back in full here, where the list shortens them to 10,000 characters with a trailing ... — so this is how you read a long email in its entirety.
Message ids come from the record behind each channel, which means the same id identifies a different message depending on the channel. Both endpoints are therefore addressed through their thread, and asking a thread for a message it does not carry returns 404.
Attachments
To pull the file behind an attachment, use GET /api/v1/communications/attachments/{url_key}/download, taking url_key from an attachment on a message row. It streams the file and authenticates with your bearer token like every other call.
Use this rather than the url field on the attachment. That one is the in-app download link, which only works inside a signed-in browser session — an API client is turned away with a 403 whether or not the file is there.
| Response | What it means |
|---|---|
200 | The file, streamed as a download. |
404 | No such attachment, or no thread you have access to carries it. The two look the same on purpose, so the endpoint cannot be used to discover attachments you are not allowed to read. |
410 | {"error": "attachment_not_stored"} — the attachment is listed on the message, but no file was ever kept for it. |
That last case is normal rather than something to retry. Some senders list an attachment their message does not actually carry — a completed e-signature notice, for example, usually links to a secure download instead of enclosing the PDF — and the record arrives with a name and no file. The stored flag on each attachment tells you this up front, so a sync job can skip those without attempting a download.
A note on size: it always reads 0 for email attachments, because the file size is not recorded for them. It is not an indicator of anything — read stored instead.
Reply
POST /api/v1/communications/threads/{id}/reply with {"body": "text up to 10,000 chars"} sends through the same per-channel paths the in-app reply box uses:
| Channel | Reply | Path |
|---|---|---|
| Yes | queued + sent to the party's email address, "Re:" prefixed | |
| general_mail | Yes | sent from the inbound intake address so the counter-reply threads back |
| sms / whatsapp / chat | Yes | conversation send path — consent and active-conversation guards apply |
| support_ticket | Yes* | Support Desk add-on required; clean 400 without it |
| voice | No | read-only channel — 400 |
Success returns 201 with {"thread_id", "channel", "last_message_at"} and marks the thread read for the sending user. Guard failures (no texting consent, closed conversation, missing party email, Conversations feature disabled for the company) return 400 with a human-readable message. Replies are currently text-only; attachments are planned.
Idempotency: send an Idempotency-Key header (max 80 chars) on replies so a retried request cannot double-send. A concurrent duplicate gets 409; a completed key replays the original response with an Idempotency-Replayed: true header.
Read state and archive
POST .../readstamps the thread read for the token's user (per-user state, same as opening it in the app).POST .../archive/.../unarchivetoggle hub-level archive (company-wide; archived threads leave the default list and the unread badge, and resurface automatically on new inbound mail for mail channels). Both are naturally idempotent.
Notes
- New inbound messages surface as new/updated thread rows — poll the list with
updated_after, or poll a thread withafter_id. Push webhooks for integrators are a planned follow-up. - Composing a brand-new conversation/email (no existing thread) is not available yet; reply requires an existing thread.
- SMS/WhatsApp sending requires the company's Conversations channels to be provisioned and, for SMS, the recipient's texting consent — the same rules as in-app sending.