Plug your system into our support desk
Your users raise issues inside your own product. With the Boolet Ltd Support API those issues become tickets for our developers, and replies, status changes and time come back to your system automatically.
It is a JSON REST API over HTTPS. Base URL:
https://portal.boolet.co.uk/api/v1
A typical integration: create a ticket when your user reports a problem, keep our ticket number (or use your own reference), and receive webhooks when our team replies or the status changes. Tickets also appear in your Boolet Ltd client portal, and time is taken from your support hours as usual.
Getting a key
The Support API is available by agreement. Talk to us about your system and the support you need. Once access is switched on for your account, we create an API key for you and send it securely. Keys can be limited to your servers' IP addresses and given an expiry date. Keep keys on your server; never put them in a browser or mobile app.
Authentication
Send the key as a bearer token on every request:
curl https://portal.boolet.co.uk/api/v1/me \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json"
Keys have abilities: tickets:read to read tickets, messages and time, and tickets:write to create tickets, reply and attach files.
Errors
Errors use standard HTTP status codes and always have the same body:
{
"error": {
"code": "validation_failed",
"message": "The title field is required.",
"fields": {
"title": [
"The title field is required."
]
}
}
}
| Status | code | Meaning |
|---|---|---|
| 401 | unauthenticated | Missing, wrong, revoked or expired key. |
| 403 | forbidden | The key may not do this, the request came from an address the key does not allow, or API access is off. |
| 404 | not_found | No such ticket on your account. |
| 422 | validation_failed | The request is not valid; fields says why. |
| 429 | rate_limited | Too many requests; wait for the seconds in Retry-After. |
| 5xx | server_error | Our problem. Retry with backoff. |
Rate limits
60 requests a minute per key. Over the limit you get a 429 with a Retry-After header. Webhooks mean you rarely need to poll.
Safe retries
Networks fail. When creating a ticket, send an Idempotency-Key header with a value unique to that ticket, or your own external_reference. If the same request arrives twice, the second gets the ticket already created (status 200) and no duplicate is made.
Pagination
Lists are cursor-paginated: follow links.next until it is null. Use updated_since to fetch only what changed since your last sync.
Endpoints
A ticket is identified by our number (e.g. TICKET142) or by ext: and your reference (e.g. ext:EXT-88412).
Values: type <code>bug</code>, <code>feature</code>, <code>task</code>, <code>other</code>; priority low, medium, high; platform web, mobile, desktop, other; status open, in_progress, client_responded, waiting_for_information, answered, closed.
Check your key
GET /me
Who the key belongs to, what it may do, and (for Support API keys) your plan and support hours balance.
Request
curl https://portal.boolet.co.uk/api/v1/me \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json"
Response 200
{
"data": {
"owner": "MediTech Ltd",
"key_name": "Production",
"abilities": [
"tickets:read",
"tickets:write"
],
"expires_at": null,
"support": {
"api_enabled": true,
"plan": "Growth",
"hours_balance": 4.5
}
}
}
Create a ticket
POST /tickets
Raise a ticket for our developers. Send an Idempotency-Key header, or your own external_reference, so a retry returns the ticket already created (200) instead of creating another. Needs tickets:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key |
header | string | Any unique value per ticket, up to 100 characters. |
Request
curl -X POST https://portal.boolet.co.uk/api/v1/tickets \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a90-…" \
-d '{
"title": "Appointments not syncing to calendar",
"description": "Since this morning, online bookings do not appear in the clinic calendar.",
"type": "bug",
"priority": "high",
"platform": "web",
"page_url": "https://app.example.com/bookings",
"external_reference": "EXT-88412",
"external_account": {
"id": "site-204",
"name": "Riverside Medical Centre"
},
"reporter": {
"name": "Jane Doe",
"email": "jane@riverside.example"
},
"metadata": {
"app_version": "4.12.1",
"environment": "production"
}
}'
Response 201
{
"data": {
"id": "TICKET142",
"external_reference": "EXT-88412",
"title": "Appointments not syncing to calendar",
"description": "Since this morning, online bookings do not appear in the clinic calendar.",
"type": "bug",
"priority": "high",
"status": "open",
"platform": "web",
"page_url": "https://app.example.com/bookings",
"source": "api",
"external_account": {
"id": "site-204",
"name": "Riverside Medical Centre"
},
"reporter": {
"name": "Jane Doe",
"email": "jane@riverside.example"
},
"metadata": {
"app_version": "4.12.1",
"environment": "production"
},
"assignee": null,
"attachments": [],
"time": {
"logged_minutes": 0,
"billable_minutes": 0
},
"created_at": "2026-11-04T09:12:40+00:00",
"updated_at": "2026-11-04T09:12:40+00:00",
"closed_at": null
}
}
List tickets
GET /tickets
Your tickets, newest first, 25 a page (up to 100). Follow links.next, or pass meta.next_cursor as cursor, for the next page. Needs tickets:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
status |
query | string | One or more statuses, comma-separated: open, in_progress, client_responded, waiting_for_information, answered, closed. |
external_account_id |
query | string | Only tickets for one of your customers. |
updated_since |
query | date-time | Only tickets changed since this time (ISO 8601). Handy for catching up after downtime. |
per_page |
query | integer | 1 to 100. Default 25. |
cursor |
query | string | From meta.next_cursor of the previous page. |
Request
curl https://portal.boolet.co.uk/api/v1/tickets \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json"
Response 200
{
"data": [
{
"id": "TICKET142",
"external_reference": "EXT-88412",
"title": "Appointments not syncing to calendar",
"description": "Since this morning, online bookings do not appear in the clinic calendar.",
"type": "bug",
"priority": "high",
"status": "open",
"platform": "web",
"page_url": "https://app.example.com/bookings",
"source": "api",
"external_account": {
"id": "site-204",
"name": "Riverside Medical Centre"
},
"reporter": {
"name": "Jane Doe",
"email": "jane@riverside.example"
},
"metadata": {
"app_version": "4.12.1",
"environment": "production"
},
"assignee": null,
"attachments": [],
"time": {
"logged_minutes": 0,
"billable_minutes": 0
},
"created_at": "2026-11-04T09:12:40+00:00",
"updated_at": "2026-11-04T09:12:40+00:00",
"closed_at": null
}
],
"meta": {
"per_page": 25,
"next_cursor": null,
"prev_cursor": null
},
"links": {
"next": null,
"prev": null
}
}
Get a ticket
GET /tickets/{ticket}
One ticket, by our number (TICKET12) or by your reference (ext:EXT-88412). Needs tickets:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
ticket required |
path | string | Our ticket number (TICKET12) or ext: and your reference (ext:EXT-88412). |
Request
curl https://portal.boolet.co.uk/api/v1/tickets/TICKET142 \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json"
Response 200
{
"data": {
"id": "TICKET142",
"external_reference": "EXT-88412",
"title": "Appointments not syncing to calendar",
"description": "Since this morning, online bookings do not appear in the clinic calendar.",
"type": "bug",
"priority": "high",
"status": "open",
"platform": "web",
"page_url": "https://app.example.com/bookings",
"source": "api",
"external_account": {
"id": "site-204",
"name": "Riverside Medical Centre"
},
"reporter": {
"name": "Jane Doe",
"email": "jane@riverside.example"
},
"metadata": {
"app_version": "4.12.1",
"environment": "production"
},
"assignee": null,
"attachments": [],
"time": {
"logged_minutes": 0,
"billable_minutes": 0
},
"created_at": "2026-11-04T09:12:40+00:00",
"updated_at": "2026-11-04T09:12:40+00:00",
"closed_at": null
}
}
Update a ticket
PATCH /tickets/{ticket}
Change the priority, mark the ticket resolved (status closed) or reopen it (status open). Needs tickets:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
ticket required |
path | string | Our ticket number (TICKET12) or ext: and your reference (ext:EXT-88412). |
Request
curl -X PATCH https://portal.boolet.co.uk/api/v1/tickets/TICKET142 \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"priority": "medium",
"status": "closed"
}'
Response 200
{
"data": {
"id": "TICKET142",
"external_reference": "EXT-88412",
"title": "Appointments not syncing to calendar",
"description": "Since this morning, online bookings do not appear in the clinic calendar.",
"type": "bug",
"priority": "medium",
"status": "closed",
"platform": "web",
"page_url": "https://app.example.com/bookings",
"source": "api",
"external_account": {
"id": "site-204",
"name": "Riverside Medical Centre"
},
"reporter": {
"name": "Jane Doe",
"email": "jane@riverside.example"
},
"metadata": {
"app_version": "4.12.1",
"environment": "production"
},
"assignee": null,
"attachments": [],
"time": {
"logged_minutes": 0,
"billable_minutes": 0
},
"created_at": "2026-11-04T09:12:40+00:00",
"updated_at": "2026-11-04T09:12:40+00:00",
"closed_at": null
}
}
List messages
GET /tickets/{ticket}/messages
The conversation on a ticket, oldest first. Our internal notes are never included. Needs tickets:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
ticket required |
path | string | Our ticket number (TICKET12) or ext: and your reference (ext:EXT-88412). |
Request
curl https://portal.boolet.co.uk/api/v1/tickets/TICKET142/messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json"
Response 200
{
"data": [
{
"id": 1001,
"body": "Thanks, we have found the cause and are deploying a fix now.",
"author": {
"name": "Sam Patel",
"type": "team"
},
"via": "portal",
"attachments": [],
"created_at": "2026-11-04T14:20:03+00:00"
}
]
}
Reply to a ticket
POST /tickets/{ticket}/messages
Add a message from your side, written by someone in your system. The ticket moves to client_responded (a closed ticket reopens) and the assigned developer is told. Needs tickets:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
ticket required |
path | string | Our ticket number (TICKET12) or ext: and your reference (ext:EXT-88412). |
Request
curl -X POST https://portal.boolet.co.uk/api/v1/tickets/TICKET142/messages \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"body": "It is still happening for bookings made on mobile.",
"author": {
"name": "Jane Doe",
"email": "jane@riverside.example"
}
}'
Response 201
{
"data": {
"id": 1002,
"body": "It is still happening for bookings made on mobile.",
"author": {
"name": "Jane Doe",
"type": "client"
},
"via": "api",
"attachments": [],
"created_at": "2026-11-04T14:20:03+00:00"
}
}
Attach files
POST /tickets/{ticket}/attachments
Send screenshots or logs as multipart/form-data in files[]. Up to 5 files a request, 10 MB each: png, jpg, jpeg, gif, webp, pdf, txt, log, csv, zip, docx, xlsx, mp4, mov. Needs tickets:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
ticket required |
path | string | Our ticket number (TICKET12) or ext: and your reference (ext:EXT-88412). |
Request
curl -X POST https://portal.boolet.co.uk/api/v1/tickets/TICKET142/attachments \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json" \ -F "files[]=@screenshot.png"
Response 201
{
"data": {
"id": "TICKET142",
"external_reference": "EXT-88412",
"title": "Appointments not syncing to calendar",
"description": "Since this morning, online bookings do not appear in the clinic calendar.",
"type": "bug",
"priority": "high",
"status": "open",
"platform": "web",
"page_url": "https://app.example.com/bookings",
"source": "api",
"external_account": {
"id": "site-204",
"name": "Riverside Medical Centre"
},
"reporter": {
"name": "Jane Doe",
"email": "jane@riverside.example"
},
"metadata": {
"app_version": "4.12.1",
"environment": "production"
},
"assignee": null,
"attachments": [
{
"name": "screenshot.png",
"size": 182044,
"mime": "image/png"
}
],
"time": {
"logged_minutes": 0,
"billable_minutes": 0
},
"created_at": "2026-11-04T09:12:40+00:00",
"updated_at": "2026-11-04T09:12:40+00:00",
"closed_at": null
}
}
List time logged
GET /tickets/{ticket}/time-entries
Time our developers logged on the ticket, and whether it came off your support hours (billable). Needs tickets:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
ticket required |
path | string | Our ticket number (TICKET12) or ext: and your reference (ext:EXT-88412). |
Request
curl https://portal.boolet.co.uk/api/v1/tickets/TICKET142/time-entries \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json"
Response 200
{
"data": [
{
"id": 77,
"worked_on": "2026-11-04",
"minutes": 45,
"billable": true,
"description": "Fixed the calendar sync job",
"logged_by": "Sam Patel",
"created_at": "2026-11-04T15:02:11+00:00"
}
],
"meta": {
"logged_minutes": 45,
"billable_minutes": 45
}
}
Webhooks
Rather than polling, give us an HTTPS address in your system and choose the events it should receive. We send a POST with a JSON body for each event. Answer with any 2xx status within 10 seconds; anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. An address that keeps failing for three days is switched off and we contact you.
| Event | When |
|---|---|
ticket.created | A ticket was raised |
ticket.status_changed | A ticket changed status |
ticket.message.created | The team replied |
ticket.assigned | A developer was assigned |
ticket.closed | A ticket was closed |
ticket.time_logged | Time was logged |
ping | A test event sent from our side |
ticket.message.created is sent for our team's replies only: your own messages are not echoed back, and our internal notes are never sent.
Example body
{
"id": "evt_01jbz8m6x4k2q7c9t0v5w3y1nr",
"type": "ticket.message.created",
"created_at": "2026-11-04T14:20:03+00:00",
"data": {
"ticket": {
"id": "TICKET142",
"external_reference": "EXT-88412",
"title": "Appointments not syncing to calendar",
"description": "Since this morning, online bookings do not appear in the clinic calendar.",
"type": "bug",
"priority": "high",
"status": "answered",
"platform": "web",
"page_url": "https://app.example.com/bookings",
"source": "api",
"external_account": {
"id": "site-204",
"name": "Riverside Medical Centre"
},
"reporter": {
"name": "Jane Doe",
"email": "jane@riverside.example"
},
"metadata": {
"app_version": "4.12.1",
"environment": "production"
},
"assignee": null,
"attachments": [],
"time": {
"logged_minutes": 0,
"billable_minutes": 0
},
"created_at": "2026-11-04T09:12:40+00:00",
"updated_at": "2026-11-04T09:12:40+00:00",
"closed_at": null
},
"message": {
"id": 1001,
"body": "Thanks, we have found the cause and are deploying a fix now.",
"author": {
"name": "Sam Patel",
"type": "team"
},
"via": "portal",
"attachments": [],
"created_at": "2026-11-04T14:20:03+00:00"
}
}
}
Headers: Boolet-Event (the event), Boolet-Delivery (unique per event; use it to ignore duplicates) and Boolet-Signature.
Checking signatures
Each webhook address has its own signing secret (whsec_…). The Boolet-Signature header looks like t=1730730003,v1=5257a869…: t is the time it was sent and v1 is the HMAC-SHA256 of t + "." + raw body with your secret. Reject events older than five minutes.
PHP
$header = $_SERVER['HTTP_BOOLET_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m);
$expected = hash_hmac('sha256', $m[1].'.'.$body, $secret);
$valid = $m
&& abs(time() - (int) $m[1]) <= 300
&& hash_equals($expected, $m[2]);
Node.js
import crypto from 'node:crypto';
const [, t, v1] = /^t=(\d+),v1=([a-f0-9]{64})$/
.exec(req.headers['boolet-signature'] ?? '') ?? [];
const expected = crypto.createHmac('sha256', secret)
.update(`${t}.${rawBody}`).digest('hex');
const valid = !!v1
&& Math.abs(Date.now() / 1000 - t) <= 300
&& crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
Sandbox
Ask us for a test key while you build. It belongs to a separate test account, so your test tickets never mix with real ones, and you can point webhooks at your development server. When you go live, swap in your production key: the base URL stays the same.
Changelog
Version 1 only ever gains new fields, endpoints and events, so existing integrations keep working. Anything that would break an integration would come as /api/v2, with notice.
| Date | Change |
|---|---|
| 25 Oct 2026 | Version 1: tickets, messages, attachments, time and webhooks. |
Questions? Email info@boolet.co.uk.