Integration API reference
A small, client-scoped API for the Blesta support module. Every route is keyed on the Blesta client ID the module passes in the URL, so the module can only ever read or write that customer’s tickets. Responses contain only customer-safe data: no internal notes, no activity log, no AI summary, no staff email addresses and no integer database IDs.
Base URL: https://<your-host>/api/v1/blesta
All requests and responses are JSON, except ticket and reply creation with attachments
(multipart form data) and attachment downloads (file stream). Send Accept: application/json.
Authentication
Create a token in the admin under API Tokens with access Blesta integration. Store it in the module’s configuration on the Blesta server; it must never reach the browser.
Authorization: Bearer <token>A Blesta token is limited to the routes on this page. Calling any admin route with it
returns 403. Tokens can be given an expiry when created and revoked at any time.
Identifiers
| Thing | Identifier | Example |
|---|---|---|
| Client | Blesta client ID (yours) | 42 |
| Ticket | 14-digit ticket number | 71941423127349 |
| Reply, attachment | ULID | 01m1h6exa2cakh0ds3g5mbqcqk |
| Department | integer ID | 3 |
Ticket numbers are random, not sequential. The id field on tickets, replies and
attachments is a ULID and is what you should store if you need a stable reference.
Enumerations
| Field | Values |
|---|---|
status | open, awaiting_reply (waiting on the client), in_progress, on_hold, closed |
priority | low, medium, high, critical, emergency |
Every status and priority also comes with a *_label field for display. priority_label is null when the ticket’s department does not let customers choose a priority, so the module can hide it.
Recommended flow
- When a customer opens the support area,
PUT /clients/{id}with their current name and email. This creates the local record the first time and keeps it in sync afterwards. GET /departmentsto populate the department selector.GET /clients/{id}/ticketsfor the list,GET /clients/{id}/tickets/{number}for a thread.POST …/ticketsto open,POST …/tickets/{number}/repliesto reply,POST …/closeto close.- Offer a “share credentials securely” form that posts to
…/tickets/{number}/secretsinstead of letting customers paste passwords into replies.
Endpoints
Departments
GET /departments
Active departments a client may open a ticket in.
{ "data": [ { "id": 1, "name": "Support", "description": "General help", "accepts_credentials": true, "accepts_guests": false, "priorities": [{ "value": "low", "label": "Low" }, { "value": "medium", "label": "Medium" }, { "value": "high", "label": "High" }], "default_priority": "medium", "timezone": "Europe/London", "opening_hours": { "mon": [["09:00", "17:30"]], "tue": [["09:00", "17:30"]], "wed": [["09:00", "12:00"], ["13:00", "17:30"]], "thu": [["09:00", "17:30"]], "fri": [["09:00", "17:30"]], "sat": [], "sun": [] }, "is_open_now": false, "reopens_at": "2026-09-07T08:00:00.000000Z" } ]}opening_hours is null for departments that are always open. Use is_open_now and
reopens_at to show a “we’re closed, back at …” notice on the form; submissions are still
accepted at any time. Tickets opened while closed may receive an automatic first reply
with author.type of system.
Markdown preview
POST /markdown/preview with { "markdown": "…" } returns { "html": "…" }, rendered and
sanitised exactly as a stored message would be. Use it for a live preview in the composer.
Client record
PUT /clients/{blestaClientId}
Creates or updates the local client. Returns 201 on create, 200 on update.
| Field | Required | Notes |
|---|---|---|
first_name | yes | |
last_name | yes | |
email | yes | must be unique across clients |
company | no | |
phone | no |
{ "data": { "id": "01m1h2k2w8nj90765paq4b1mw2", "blesta_client_id": 42, "first_name": "Jane", "last_name": "Doe", "email": "jane@example.com", "company": "Acme", "phone": null, "tickets_count": 3 }}GET /clients/{blestaClientId} returns the same shape. 404 if the client has never been upserted.
List tickets
GET /clients/{blestaClientId}/tickets
| Query | Default | Notes |
|---|---|---|
status | all | open (every non-closed status), closed, or all |
page | 1 | |
per_page | 25 | max 100 |
Ordered by most recent reply first. Standard Laravel pagination envelope (data, links, meta).
{ "data": [ { "id": "01m1h6exv7e0v83vmbytgcfprt", "number": "71941423127349", "subject": "Cannot reach my server", "status": "awaiting_reply", "status_label": "Needs your reply", "priority": "high", "priority_label": "High", "department": { "id": 1, "name": "Support", "description": null }, "replies_count": 4, "assigned_to": "Sam Agent", "last_reply_from": "staff", "last_reply_at": "2026-09-02T13:05:11.000000Z", "closed_at": null, "created_at": "2026-09-01T09:12:40.000000Z", "updated_at": "2026-09-02T13:05:11.000000Z" } ], "meta": { "current_page": 1, "last_page": 1, "per_page": 25, "total": 1 }}Open a ticket
POST /clients/{blestaClientId}/tickets
| Field | Required | Notes |
|---|---|---|
subject | yes | max 255 |
body | yes | Markdown or plain text |
department_id | yes | must be an active department |
priority | no | must be one of the department’s priorities; defaults to its default_priority. Ignored when the department offers no priorities. |
attachments[] | no | up to 10 files, 10 MB each (multipart) |
Returns 201 with the full ticket (see below). The opening message is attributed to the client
and the ticket is recorded with source blesta.
Ticket detail
GET /clients/{blestaClientId}/tickets/{number}
404 if the number does not exist or belongs to another client.
{ "data": { "id": "01m1h6exv7e0v83vmbytgcfprt", "number": "71941423127349", "subject": "Cannot reach my server", "status": "awaiting_reply", "status_label": "Needs your reply", "priority": "high", "priority_label": "High", "department": { "id": 1, "name": "Support", "description": null }, "assigned_to": "Sam Agent", "replies": [ { "id": "01m1h6exa2cakh0ds3g5mbqcqk", "author": { "type": "client", "name": "Jane Doe" }, "body": "Ping times out since this morning.", "body_html": "<p>Ping times out since this morning.</p>", "attachments": [ { "id": "01m1h6f0q3d8c5k1v9w2x7y4z6", "name": "trace.txt", "mime_type": "text/plain", "size": 5120, "download_url": "https://…/api/v1/blesta/clients/42/attachments/01m1h6f0q3d8c5k1v9w2x7y4z6/download", "created_at": "2026-09-01T09:12:40.000000Z" } ], "created_at": "2026-09-01T09:12:40.000000Z" }, { "id": "01m1h7…", "author": { "type": "staff", "name": "Sam Agent" }, "body": "Thanks, checking now.", "body_html": "<p>Thanks, checking now.</p>", "attachments": [], "created_at": "2026-09-01T09:40:02.000000Z" } ], "last_reply_at": "2026-09-01T09:40:02.000000Z", "closed_at": null, "created_at": "2026-09-01T09:12:40.000000Z", "updated_at": "2026-09-01T09:40:02.000000Z" }}Replies are oldest first. body is the raw Markdown; body_html is sanitised HTML
(raw HTML stripped, unsafe links removed) and is safe to render directly. assigned_to
is the staff member’s display name or null. author.type is client, staff or
system (automatic replies such as the out-of-hours acknowledgement). Tickets on hold
carry on_hold_until (may be null for an indefinite hold).
Reply
POST /clients/{blestaClientId}/tickets/{number}/replies
| Field | Required | Notes |
|---|---|---|
body | yes | |
attachments[] | no | up to 10 files, 10 MB each (multipart) |
Returns 201 with the new reply plus the ticket’s new state. A client reply moves the
ticket back to open when it was awaiting_reply.
{ "data": { "id": "01m1h8…", "author": { "type": "client", "name": "Jane Doe" }, "body": "Still down.", "body_html": "<p>Still down.</p>", "attachments": [], "created_at": "…" }, "ticket": { "status": "open", "status_label": "Open", "last_reply_at": "…" }}Credentials
Customers can hand over passwords or access details without pasting them into a reply. Values are encrypted at rest, only staff can reveal them (every reveal is logged), and they are deleted automatically 7 days after the ticket closes.
Only departments that opt in accept credentials. Check accepts_credentials on the
department (from GET /departments) or on the ticket before showing the form; storing on
a ticket whose department has not opted in returns 422.
GET /clients/{blestaClientId}/tickets/{number}/secrets
Labels only. Values are never returned to the client side.
{ "data": [ { "id": "01m1h9…", "label": "Control panel login", "added_by": "client", "created_at": "…" } ] }POST /clients/{blestaClientId}/tickets/{number}/secrets
| Field | Required | Notes |
|---|---|---|
label | yes | max 100, e.g. “Control panel login” |
value | yes | max 10,000 characters |
Returns 201 with the entry (label only).
DELETE /clients/{blestaClientId}/tickets/{number}/secrets/{secretId}
Removes an entry the client added. Entries added by staff are not visible for deletion and return 404.
Close
POST /clients/{blestaClientId}/tickets/{number}/close
Closes the ticket on the client’s behalf and returns the ticket detail. Already-closed tickets are returned unchanged.
Download an attachment
GET /clients/{blestaClientId}/attachments/{attachmentId}/download
Streams the file with its original name. 404 unless the attachment belongs to a
client-visible reply on one of that client’s tickets.
Guest (pre-sales) tickets
Visitors without a Blesta account can open tickets in departments that accept guests. They give a name and email; each guest ticket gets its own sender record, since nothing about a guest submission is verified. Guest tickets are never attached to a Blesta account, even one with the same email address: they remain reachable only through the emailed link. A visitor who later signs up simply opens new tickets from their account.
Access to a guest ticket is by ticket number plus an access token returned once on
creation and emailed to the guest (via GUEST_TICKET_URL, with {number} and {token}).
Send it as an X-Ticket-Token header or a token query parameter. Anything else is 404.
Once a guest ticket is closed, by the guest or by staff, its link is revoked permanently:
every guest route returns 410 Gone with a message explaining that closed tickets cannot be
viewed for security reasons. Show that message to the visitor. Reopening by staff does not
restore the link; guests open a new ticket instead.
Guests never get credentials storage, and their submission limits are tighter (2 per hour,
5 per day, 3 open by default).
GET /guest/departments - active departments with accepts_guests true.
POST /guest/tickets
| Field | Required | Notes |
|---|---|---|
first_name, last_name | yes | |
email | yes | stored lowercase; guest limits are counted per email |
company | no | |
subject, body, department_id | yes | department must accept guests |
priority | no | same rules as for clients |
attachments[] | no | as elsewhere |
Returns 201 with the ticket plus a top-level access_token. Show the visitor a
“check your email” message, or redirect them straight to the ticket using the token.
{ "data": { "number": "71941423127349", "is_guest": true, "…": "…" }, "access_token": "k3Jd…48 chars" }With the token:
GET /guest/tickets/{number}POST /guest/tickets/{number}/replies(body,attachments[])POST /guest/tickets/{number}/closeGET /guest/tickets/{number}/attachments/{attachmentId}/download
Responses have the same shape as the account endpoints.
Errors
| Status | Meaning |
|---|---|
401 | Missing or invalid token |
403 | Token is not allowed here (for example an expired or admin-only route) |
404 | Client not upserted yet, or the ticket/attachment is not that client’s |
422 | Validation failed; body has message and an errors map keyed by field |
410 | Guest ticket has been closed; the link is permanently disabled. Body has a message to show. |
429 | Submission limit reached; body has message, limit and retry_after seconds, plus a Retry-After header. Show the message to the customer. |
{ "message": "The subject field is required.", "errors": { "subject": ["The subject field is required."] } }Limits
- Attachments: the brand decides, per group of people. Every department in
GET /departmentscarries its ownfilesblock (allowed,accept,said,max_megabytesandmax_files) and the report types payload carries the same; read them rather than assuming a number. Defaults are 10 files of 10 MB, narrowed where the web server’s PHPupload_max_filesizeorpost_max_sizeis smaller. A kind of file the brand does not list is refused with a422naming what would have been taken, as is a file that is not what its name says (a program posted in asinvoice.pdf), so show the returned message to the customer. Downloads are always served as attachments withX-Content-Type-Options: nosniff. - Pagination: 100 items per page maximum.
- Per client, by default: 5 new tickets per hour, 20 per day, 25 open at once, and
30 replies per hour. Exceeding one returns
429(see Errors). Limits are configured on the ticket system server and can be disabled individually.
Example (PHP)
$client = new \GuzzleHttp\Client([ 'base_uri' => 'https://support.example.com/api/v1/blesta/', 'headers' => ['Authorization' => 'Bearer '.$token, 'Accept' => 'application/json'],]);
// Keep the client record current$client->put("clients/{$blestaClientId}", ['json' => [ 'first_name' => $contact->first_name, 'last_name' => $contact->last_name, 'email' => $contact->email, 'company' => $contact->company,]]);
// List open tickets$tickets = json_decode($client->get("clients/{$blestaClientId}/tickets", ['query' => ['status' => 'open']])->getBody(), true);
// Reply with an attachment$client->post("clients/{$blestaClientId}/tickets/{$number}/replies", ['multipart' => [ ['name' => 'body', 'contents' => $message], ['name' => 'attachments[]', 'contents' => fopen($path, 'r'), 'filename' => basename($path)],]]);