Skip to content

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

ThingIdentifierExample
ClientBlesta client ID (yours)42
Ticket14-digit ticket number71941423127349
Reply, attachmentULID01m1h6exa2cakh0ds3g5mbqcqk
Departmentinteger ID3

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

FieldValues
statusopen, awaiting_reply (waiting on the client), in_progress, on_hold, closed
prioritylow, 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.

  1. 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.
  2. GET /departments to populate the department selector.
  3. GET /clients/{id}/tickets for the list, GET /clients/{id}/tickets/{number} for a thread.
  4. POST …/tickets to open, POST …/tickets/{number}/replies to reply, POST …/close to close.
  5. Offer a “share credentials securely” form that posts to …/tickets/{number}/secrets instead 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.

FieldRequiredNotes
first_nameyes
last_nameyes
emailyesmust be unique across clients
companyno
phoneno
{
"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

QueryDefaultNotes
statusallopen (every non-closed status), closed, or all
page1
per_page25max 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

FieldRequiredNotes
subjectyesmax 255
bodyyesMarkdown or plain text
department_idyesmust be an active department
prioritynomust be one of the department’s priorities; defaults to its default_priority. Ignored when the department offers no priorities.
attachments[]noup 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

FieldRequiredNotes
bodyyes
attachments[]noup 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

FieldRequiredNotes
labelyesmax 100, e.g. “Control panel login”
valueyesmax 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

FieldRequiredNotes
first_name, last_nameyes
emailyesstored lowercase; guest limits are counted per email
companyno
subject, body, department_idyesdepartment must accept guests
prioritynosame rules as for clients
attachments[]noas 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}/close
  • GET /guest/tickets/{number}/attachments/{attachmentId}/download

Responses have the same shape as the account endpoints.

Errors

StatusMeaning
401Missing or invalid token
403Token is not allowed here (for example an expired or admin-only route)
404Client not upserted yet, or the ticket/attachment is not that client’s
422Validation failed; body has message and an errors map keyed by field
410Guest ticket has been closed; the link is permanently disabled. Body has a message to show.
429Submission 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 /departments carries its own files block (allowed, accept, said, max_megabytes and max_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 PHP upload_max_filesize or post_max_size is smaller. A kind of file the brand does not list is refused with a 422 naming what would have been taken, as is a file that is not what its name says (a program posted in as invoice.pdf), so show the returned message to the customer. Downloads are always served as attachments with X-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)],
]]);