Brandcore Tickets · Developer docs
Client API
File tickets with Brandcore from your own systems, follow every reply, and hear about changes the moment they happen.
Version 1.0REST over HTTPSJSONOpenAPI 3.1
Overview
The client API lets your systems work with Brandcore's support team directly: file tickets for your people or your customers, read our replies, answer back, attach files, close or reopen, and get a signed webhook whenever something changes. It is the same ticket system our team works in, so a ticket filed here is handled exactly like one sent from the support site.
https://tickets.bcio.net/api/v1/clientBrandcore issues keys, one or more per company. Ask your Brandcore contact, or send us a request.
Every ticket filed for your company, however it arrived. Never our internal notes or internal tickets.
JSON over HTTPS, snake_case fields, UTC timestamps, refs like BC-1042.
openapi.json describes every endpoint as OpenAPI 3.1.
Endpoints at a glance
- GET
/openapi.jsonGet the OpenAPI document - GET
/meCheck your key - GET
/ticketsList tickets - POST
/ticketsCreate a ticket - GET
/tickets/{ref}Get a ticket - POST
/tickets/{ref}/messagesReply to a ticket - PATCH
/tickets/{ref}Close or reopen a ticket - POST
/filesUpload a file - GET
/files/{id}/{name}Download a file - GET
/webhooksList webhooks - POST
/webhooksCreate a webhook - DELETE
/webhooks/{id}Delete a webhook - POST
/webhooks/{id}/testSend a test delivery
Paths are relative to the base URL. Every endpoint except the OpenAPI file needs a key.
Get the OpenAPI document
GET/api/v1/client/openapi.jsonNo key needed
The machine-readable description of this API: every endpoint, schema and the webhook payload, as OpenAPI 3.1. No key needed.
Example
curl "https://tickets.bcio.net/api/v1/client/openapi.json"Response · 200 OKThe OpenAPI 3.1 document as JSON. Import it into Postman, Insomnia or a client generator.
Quick start
Three calls take you from a new key to a filed ticket and its replies. Keep the key in an environment variable, so it never lands in your code:
export BCIO_CLIENT_KEY="<your key>"Check the key
GET /meanswers with your company, so you know the key works.curl curl "https://tickets.bcio.net/api/v1/client/me" \ -H "Authorization: Bearer $BCIO_CLIENT_KEY"File a ticket
The answer is the new ticket: its
ref, its status and theportal_urlwhere the requester can follow it. Our team is notified at once.curl curl -X POST "https://tickets.bcio.net/api/v1/client/tickets" \ -H "Authorization: Bearer $BCIO_CLIENT_KEY" \ -H "Content-Type: application/json" \ -d '{ "subject": "Checkout button does nothing on Safari", "body": "On Safari 18 the Checkout button on /cart does nothing. Chrome works. Orders are down since this morning.", "priority": "high", "requester": { "email": "jane@acme.example", "name": "Jane Rivera" }, "external_ref": "ACME-5521" }'Read it back
messagesfills up as we reply. Check it now and then, or let us call you when something changes.curl curl "https://tickets.bcio.net/api/v1/client/tickets/BC-1042" \ -H "Authorization: Bearer $BCIO_CLIENT_KEY"
The same three calls in code
No packages needed: Node uses the built-in fetch, Python the standard library.
// quickstart.mjs: node quickstart.mjs (Node 18 or newer, no packages)
const BASE = "https://tickets.bcio.net/api/v1/client";
const KEY = process.env.BCIO_CLIENT_KEY;
async function api(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${KEY}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const json = res.status === 204 ? null : await res.json();
if (!res.ok) {
const id = res.headers.get("x-request-id");
throw new Error(`${res.status} ${json?.error?.code}: ${json?.error?.message} (request ${id})`);
}
return json;
}
// 1. Check the key
const { data: me } = await api("GET", "/me");
console.log(`Key "${me.key.name}" works for ${me.company.name}`);
// 2. File a ticket (safe to repeat: external_ref makes it idempotent)
const { data: ticket } = await api("POST", "/tickets", {
subject: "Checkout button does nothing on Safari",
body: "On Safari 18 the Checkout button on /cart does nothing. Chrome works. Orders are down since this morning.",
priority: "high",
requester: {
email: "jane@acme.example",
name: "Jane Rivera"
},
external_ref: "ACME-5521"
});
console.log(`Filed ${ticket.ref}: ${ticket.portal_url}`);
// 3. Read it back with the public thread
const { data: latest } = await api("GET", `/tickets/${ticket.ref}`);
console.log(`${latest.ref} is ${latest.status} with ${latest.messages.length} messages`);
Authentication
Send your key in the Authorization header of every request, as a bearer token:
Authorization: Bearer bck_1a2b3c4d_5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4fKeys look like bck_<8 hex>_<40 hex>: bck_, 8 hexadecimal characters, an underscore and 40 more. The one above is an example, not a key.
Each key belongs to one company and sees that company's tickets only. Another company's ticket answers 404, exactly like one that does not exist. A missing, unknown or revoked key answers 401.
Keep your key secret
- Use it from your servers only. Never put it in a web page, a mobile app or a code repository.
- Keep it in a secret store or an environment variable, like
BCIO_CLIENT_KEYin the samples. - Give each system its own key, so one can be replaced without touching the others.
- If a key leaks, ask your Brandcore contact to revoke it. It stops working at once.
- We keep only a fingerprint of each key, so a lost key cannot be shown again. Ask for a new one.
Rotate a key
Keys do not expire. To replace one without a gap, ask for a second key, switch your systems to it, confirm it answers GET /me, then ask us to revoke the old one.
We can also rotate a key in one step. The new key is shown once and the old one stops working that same moment, so have the new value ready to deploy.
Check your key
GET/api/v1/client/me
Returns the company your key belongs to and the key itself. The quickest way to test a key.
Example
curl "https://tickets.bcio.net/api/v1/client/me" \
-H "Authorization: Bearer $BCIO_CLIENT_KEY"{
"data": {
"company": {
"id": 14,
"name": "Acme",
"domain": "acme.example"
},
"key": {
"id": 3,
"name": "Acme helpdesk sync",
"created_at": "2026-10-12T14:58:31.207Z"
}
}
}- A missing, unknown or revoked key answers
401 unauthorized.
Conventions
- JSON
- Send
Content-Type: application/jsonwith every body; uploads are multipart. Request bodies are strict: an unknown field answers422. - Envelopes
- One object comes back as
{"data": {…}}, a list as{"data": […], "page": {…}}, an error as{"error": {"code", "message", "fields"}}. Deleting a webhook answers204with no body. - Pagination
- Lists take
limit(1 to 200, default 50) andoffset.page.totalcounts every match, so there is another page whileoffset + limitis belowtotal. - Timestamps
- ISO 8601 in UTC with milliseconds, such as
2026-10-12T15:04:05.123Z. The API sets every time; you never send one. - Refs
- Tickets are named by refs such as
BC-1042, in paths and in payloads. A ref never changes, and paths accept it in any case. - Field names
snake_caseeverywhere. Responses may gain fields within v1; ignore the ones you do not know.- Request ids
- Every response carries an
x-request-idheader. Quote it when you ask us about a request.
Statuses and priorities
A ticket's status is one of four values. Clients see these words on the support site too.
newNew- Received. Nobody has picked it up yet.
openIn progress- In progress: someone on our team is on it.
waitingWaiting on you- Waiting on you: we asked something. Your reply moves it back to
open. doneDone- Done. A reply, or a
PATCHwithopen, reopens it.
priority is one of four values; normal is the default.
lowLow- Whenever there is time.
normalNormal- The default.
highHigh- It is getting in the way of your work.
urgentUrgent- Something is down or blocking sales. Use it for that only.
Tickets
A ticket is one request: a subject, the person it is for, and a public thread of replies and status changes. Every ticket filed for your company is yours to read, whoever filed it.
The ticket object
refstring- The reference, such as
BC-1042. Use it in paths. subjectstring- One line.
statusstringnew,open,waitingordone. See Conventions.prioritystringlow,normal,highorurgent.external_refstring | null- Your own reference, when you sent one.
requesterobjectnameandemailof the person the ticket is for.assignee_namestring | null- Who on our team has it, once someone does.
created_attimestamp- When it was filed.
updated_attimestamp- The latest thing you can see: a public reply or a status change.
last_public_message_attimestamp | null- The latest public reply.
message_countinteger- Public replies, the opening message included.
portal_urlstring- Where the requester follows the ticket in a browser, after signing in with their email.
messagesmessage[]- The public thread, oldest first. Only on a single ticket, not in lists.
Each message
idinteger- The message id.
kindstringreply, oreventfor a status change.authorobjectname, andis_staff: true for our team.bodystring- Plain text with light Markdown. Empty for events.
eventobject | null- For events:
type("status"),fromandto. Otherwise null. attachmentsfile[]- Files on the message, each with a download
url. created_attimestamp- When it was written.
List tickets
GET/api/v1/client/tickets
Your company's tickets, newest activity first, one page at a time.
Query parameters
status- Comma list of statuses:
new,open,waiting,done. q- Search text, up to 500 characters. Matches the subject, the ref, the requester's name or email and public replies.
external_ref- Exact match on your own reference. See Idempotency.
sortupdated_at(default),created_atorstatus.dirdesc(default) orasc.limit- Page size, 1 to 200 (larger values count as 200). Default 50.
offset- How many to skip. Default 0.
Example
curl "https://tickets.bcio.net/api/v1/client/tickets?status=new,open,waiting&limit=20" \
-H "Authorization: Bearer $BCIO_CLIENT_KEY"{
"data": [
{
"ref": "BC-1042",
"subject": "Checkout button does nothing on Safari",
"status": "open",
"priority": "high",
"external_ref": "ACME-5521",
"requester": {
"name": "Jane Rivera",
"email": "jane@acme.example"
},
"assignee_name": "Amy Chen",
"created_at": "2026-10-12T15:04:05.123Z",
"updated_at": "2026-10-12T15:31:10.420Z",
"last_public_message_at": "2026-10-12T15:31:10.420Z",
"message_count": 2,
"portal_url": "https://tickets.bcio.net/tickets/BC-1042"
},
{
"ref": "BC-1039",
"subject": "Invoice PDF shows our old address",
"status": "waiting",
"priority": "normal",
"external_ref": null,
"requester": {
"name": "Marco Diaz",
"email": "marco@acme.example"
},
"assignee_name": "Amy Chen",
"created_at": "2026-10-09T09:12:44.610Z",
"updated_at": "2026-10-09T10:05:17.341Z",
"last_public_message_at": "2026-10-09T10:05:17.332Z",
"message_count": 2,
"portal_url": "https://tickets.bcio.net/tickets/BC-1039"
}
],
"page": {
"total": 2,
"limit": 20,
"offset": 0
}
}- Every ticket filed for your company is here, however it arrived: through the API, by email, on the support site or from our team. Internal tickets never are.
- List rows have no
messages; get the ticket for the thread.
Create a ticket
POST/api/v1/client/tickets
Files a ticket for your company. Our team is notified at once.
Body (JSON)
subject- One line, 1 to 300 characters.
body- What is needed, 1 to 100,000 characters of plain text. Line breaks are kept; light Markdown such as bold, lists and links is shown formatted.
prioritylow,normal(default),highorurgent.requester.email- The person the ticket is for. Replies reach them by email, and they can follow it on the support site.
requester.name- Their name, up to 200 characters.
external_ref- Your own reference, up to 120 characters, unique within your company. Makes the request safe to repeat.
file_ids- Up to 10 uploaded files to attach. See Attachments.
Example
curl -X POST "https://tickets.bcio.net/api/v1/client/tickets" \
-H "Authorization: Bearer $BCIO_CLIENT_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject": "Checkout button does nothing on Safari",
"body": "On Safari 18 the Checkout button on /cart does nothing. Chrome works. Orders are down since this morning.",
"priority": "high",
"requester": {
"email": "jane@acme.example",
"name": "Jane Rivera"
},
"external_ref": "ACME-5521"
}'{
"data": {
"ref": "BC-1042",
"subject": "Checkout button does nothing on Safari",
"status": "new",
"priority": "high",
"external_ref": "ACME-5521",
"requester": {
"name": "Jane Rivera",
"email": "jane@acme.example"
},
"assignee_name": null,
"created_at": "2026-10-12T15:04:05.123Z",
"updated_at": "2026-10-12T15:04:05.123Z",
"last_public_message_at": "2026-10-12T15:04:05.123Z",
"message_count": 1,
"portal_url": "https://tickets.bcio.net/tickets/BC-1042",
"messages": [
{
"id": 9301,
"kind": "reply",
"author": {
"name": "Jane Rivera",
"is_staff": false
},
"body": "On Safari 18 the Checkout button on /cart does nothing. Chrome works. Orders are down since this morning.",
"event": null,
"attachments": [],
"created_at": "2026-10-12T15:04:05.123Z"
}
]
}
}201 Created for a new ticket. When external_ref already exists, 200 OK with that ticket instead.
- If we have never heard from the requester, they become a contact of your company. An email that belongs to another company's contact is refused with
422onrequester.email. - The requester gets the usual confirmation email with the reference, then every public reply by email. They can follow the ticket at
portal_urlafter signing in with that address. - The ticket is marked as filed through the API with your key, and it lands in the same queue as every other request.
Get a ticket
GET/api/v1/client/tickets/{ref}
One ticket with its public thread.
Path parameters
ref- The ticket reference, such as
BC-1042. Not case-sensitive.
Example
curl "https://tickets.bcio.net/api/v1/client/tickets/BC-1042" \
-H "Authorization: Bearer $BCIO_CLIENT_KEY"{
"data": {
"ref": "BC-1042",
"subject": "Checkout button does nothing on Safari",
"status": "open",
"priority": "high",
"external_ref": "ACME-5521",
"requester": {
"name": "Jane Rivera",
"email": "jane@acme.example"
},
"assignee_name": "Amy Chen",
"created_at": "2026-10-12T15:04:05.123Z",
"updated_at": "2026-10-12T15:31:10.420Z",
"last_public_message_at": "2026-10-12T15:31:10.420Z",
"message_count": 2,
"portal_url": "https://tickets.bcio.net/tickets/BC-1042",
"messages": [
{
"id": 9301,
"kind": "reply",
"author": {
"name": "Jane Rivera",
"is_staff": false
},
"body": "On Safari 18 the Checkout button on /cart does nothing. Chrome works. Orders are down since this morning.",
"event": null,
"attachments": [],
"created_at": "2026-10-12T15:04:05.123Z"
},
{
"id": 9302,
"kind": "event",
"author": {
"name": "Amy Chen",
"is_staff": true
},
"body": "",
"event": {
"type": "status",
"from": "new",
"to": "open"
},
"attachments": [],
"created_at": "2026-10-12T15:30:58.004Z"
},
{
"id": 9303,
"kind": "reply",
"author": {
"name": "Amy Chen",
"is_staff": true
},
"body": "Thanks, Jane. We can reproduce it on Safari 18 and are working on a fix now. Next update within the hour.",
"event": null,
"attachments": [],
"created_at": "2026-10-12T15:31:10.420Z"
}
]
}
}messagesis the public thread, oldest first: replies (kind: "reply") and status changes (kind: "event"). Internal notes never appear.- Another company's ticket, an internal ticket and an unknown ref all answer
404 not_found.
Reply to a ticket
POST/api/v1/client/tickets/{ref}/messages
Adds a public reply from your side. Our team is notified.
Path parameters
ref- The ticket reference, such as
BC-1042. Not case-sensitive.
Body (JSON)
body- The reply, 1 to 100,000 characters of plain text.
author.email- Who is replying. Leave
authorout to reply as the ticket's requester. author.name- Their name, up to 200 characters.
file_ids- Up to 10 uploaded files to attach.
Example
curl -X POST "https://tickets.bcio.net/api/v1/client/tickets/BC-1042/messages" \
-H "Authorization: Bearer $BCIO_CLIENT_KEY" \
-H "Content-Type: application/json" \
-d '{
"body": "Here is a screenshot of the console error.",
"file_ids": [
5521
]
}'{
"data": {
"id": 9304,
"kind": "reply",
"author": {
"name": "Jane Rivera",
"is_staff": false
},
"body": "Here is a screenshot of the console error.",
"event": null,
"attachments": [
{
"id": 5521,
"name": "checkout-safari.png",
"mime": "image/png",
"size": 284113,
"is_image": true,
"width": 1440,
"height": 900,
"url": "/api/v1/client/files/5521/checkout-safari.png"
}
],
"created_at": "2026-10-12T15:47:22.918Z"
}
}- An
authormust be, or becomes, a contact of your company; another company's email is refused with422onauthor.email. A ticket without a requester needs anauthor. - A reply to a ticket that is waiting on you, or done, moves it back to
open.
Close or reopen a ticket
PATCH/api/v1/client/tickets/{ref}
Marks a ticket done, or reopens one that is done.
Path parameters
ref- The ticket reference, such as
BC-1042. Not case-sensitive.
Body (JSON)
statusdonecloses the ticket from any status.openreopens a ticket that is done.
Example
curl -X PATCH "https://tickets.bcio.net/api/v1/client/tickets/BC-1039" \
-H "Authorization: Bearer $BCIO_CLIENT_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "done"
}'{
"data": {
"ref": "BC-1039",
"subject": "Invoice PDF shows our old address",
"status": "done",
"priority": "normal",
"external_ref": null,
"requester": {
"name": "Marco Diaz",
"email": "marco@acme.example"
},
"assignee_name": "Amy Chen",
"created_at": "2026-10-09T09:12:44.610Z",
"updated_at": "2026-10-12T16:02:09.775Z",
"last_public_message_at": "2026-10-09T10:05:17.332Z",
"message_count": 2,
"portal_url": "https://tickets.bcio.net/tickets/BC-1039",
"messages": [
{
"id": 9188,
"kind": "reply",
"author": {
"name": "Marco Diaz",
"is_staff": false
},
"body": "The invoice PDFs still show our old street address. The new one is in our account settings.",
"event": null,
"attachments": [],
"created_at": "2026-10-09T09:12:44.610Z"
},
{
"id": 9190,
"kind": "reply",
"author": {
"name": "Amy Chen",
"is_staff": true
},
"body": "Thanks, Marco. We updated the template. Could you download invoice 2291 again and confirm it looks right?",
"event": null,
"attachments": [],
"created_at": "2026-10-09T10:05:17.332Z"
},
{
"id": 9191,
"kind": "event",
"author": {
"name": "Amy Chen",
"is_staff": true
},
"body": "",
"event": {
"type": "status",
"from": "new",
"to": "waiting"
},
"attachments": [],
"created_at": "2026-10-09T10:05:17.341Z"
},
{
"id": 9240,
"kind": "event",
"author": {
"name": "Marco Diaz",
"is_staff": false
},
"body": "",
"event": {
"type": "status",
"from": "waiting",
"to": "done"
},
"attachments": [],
"created_at": "2026-10-12T16:02:09.775Z"
}
]
}
}openon a ticket that is not done answers422onstatus.- Priority, assignee and the other fields are set by our team; the API does not change them.
Attachments
Files travel in two steps: upload each one, then attach it by id when you create a ticket or reply. Our team sees them in the thread, and so does the requester.
- Up to 50 MB per file, and up to 10 files per ticket or reply.
- Allowed types:
png,jpg,jpeg,gif,webp,avif,heic,pdf,txt,csv,docx,xlsx,mp4,mov,webm. The extension must match the contents. - Attach within 24 hours of the upload. Each upload attaches once.
- Uploads are limited to 20 per 10 minutes from one network address.
Each file
idinteger- Send it in
file_idsto attach the file. namestring- The file name.
mimestring- The content type, such as
image/png. sizeinteger- Bytes.
is_imageboolean- True for images.
widthinteger | null- Pixels, for images.
heightinteger | null- Pixels, for images.
urlstring- The download path. Put the host in front and send your key.
Upload a file
POST/api/v1/client/files
Uploads one file to attach to a new ticket or a reply.
Body (multipart form)
file- The file, as multipart form data. Up to 50 MB.
Example
curl -X POST "https://tickets.bcio.net/api/v1/client/files" \
-H "Authorization: Bearer $BCIO_CLIENT_KEY" \
-F "file=@checkout-safari.png"{
"data": {
"id": 5521,
"name": "checkout-safari.png",
"mime": "image/png",
"size": 284113,
"is_image": true,
"width": 1440,
"height": 900,
"url": "/api/v1/client/files/5521/checkout-safari.png"
}
}- Attach it within 24 hours by sending its
idinfile_idswhen you create a ticket or reply. - A file attaches once, and only to your company's tickets. Sending the same id twice answers
409 conflict.
Download a file
GET/api/v1/client/files/{id}/{name}
The bytes of an attachment on a public message of one of your tickets.
Path parameters
id- The file id.
name- The file name, URL-encoded.
Example
curl "https://tickets.bcio.net/api/v1/client/files/5521/checkout-safari.png" \
-H "Authorization: Bearer $BCIO_CLIENT_KEY" \
-o checkout-safari.pngResponse · 200 OKThe file itself, with its Content-Type.
- Every attachment in a message carries this path as
url. Put the host in front and send your key. - Files on another company's tickets, or that are not on a public message, answer
404 not_found.
Idempotency
Networks fail. To make creating a ticket safe to repeat, send external_ref: your own id for what the ticket is about, such as an order number, a helpdesk case or a monitoring alert. Up to 120 characters, unique within your company.
- The first request with a new
external_refcreates the ticket and answers201 Created. - Any later request with the same
external_refcreates nothing. It answers200 OKwith the ticket that already exists, unchanged: a different subject, body or requester in the repeat is ignored. - So when a create times out or the connection drops, send the very same request again. The status code tells you which happened.
Find a ticket by your own id at any time:
curl "https://tickets.bcio.net/api/v1/client/tickets?external_ref=ACME-5521" \
-H "Authorization: Bearer $BCIO_CLIENT_KEY"Replies, uploads and webhook changes are not idempotent. If one fails without an answer, read the ticket (or list your webhooks) before you send it again.
Errors and rate limits
Errors use the HTTP status and a stable code. Match on code; message is written for people and may change. Validation errors add fields, keyed by the field's path.
{
"error": {
"code": "validation_failed",
"message": "Please check the highlighted fields",
"fields": {
"requester.email": "Invalid email address"
}
}
}- 400
bad_request - The body is not valid JSON, a query value is invalid (sort, direction, paging), or an upload is not multipart.
- 401
unauthorized - No key, an unknown key or a revoked key. Check the
Authorizationheader. - 404
not_found - The ticket, file or webhook does not exist, belongs to another company, or is internal. We never say which.
- 409
conflict - The file is already attached somewhere (upload it again to attach it twice), or you tested a webhook our team has paused.
- 413
file_too_large - The upload is over 50 MB.
- 415
unsupported_media_type - The file type is not allowed, or its extension does not match its contents.
- 422
validation_failed - A field is missing or wrong.
fieldsmaps each field, such asrequester.email, to what is wrong. - 429
rate_limited - Too many requests. Wait, then retry with backoff. See Rate limits.
- 500
internal - Something broke on our side. Retry later, and quote the
x-request-idheader if it persists.
Rate limits
Each key may make 120 requests per minute, counted across every endpoint. Past that, requests answer 429 rate_limited until the minute is up.
Requests from one network address are limited too, which matters only when many keys share an address. Uploads have their own limit of 20 per 10 minutes per address.
On a 429, wait and retry with exponential backoff: for example 2, 4 and 8 seconds, then a minute. Poll at a calm pace, or use webhooks instead of polling.
Webhooks
A webhook is a URL on your side that we call with a signed POST whenever something happens to one of your company's tickets. It saves polling and reaches you within seconds.
- Only your company's tickets: never internal tickets, never internal notes.
- Each delivery is JSON, signed with the webhook's secret in the
X-BCIO-Signatureheader. - Create, list, test and delete your webhooks with your key, below.
Events
ticket.createdDefault- A ticket is filed for your company: through the API, by email, on the support site or by our team.
ticket.message_createdDefault- A public reply is added, by us or by your side.
messageholds it. Internal notes never send this. ticket.status_changedDefault- The status changes.
changeholdsfromandto. ticket.assignedDefault- Someone on our team takes the ticket, or hands it on.
changeholdsassignee_namebefore and after. ticket.updated- The subject or the priority changes.
change.fieldsholds each one that changed as[before, after].
List webhooks
GET/api/v1/client/webhooks
Your company's webhooks, with the result of each one's latest delivery.
Example
curl "https://tickets.bcio.net/api/v1/client/webhooks" \
-H "Authorization: Bearer $BCIO_CLIENT_KEY"{
"data": [
{
"id": 12,
"company": {
"id": 14,
"name": "Acme",
"is_client": true
},
"name": "Acme webhook",
"url": "https://hooks.acme.example/brandcore",
"events": [
"ticket.created",
"ticket.message_created",
"ticket.status_changed",
"ticket.assigned"
],
"is_active": true,
"created_at": "2026-10-12T15:10:03.488Z",
"last_delivery": {
"status": "delivered",
"response_code": 204,
"at": "2026-10-12T15:47:23.105Z"
}
}
]
}last_delivery.statusispending(waiting for a retry),deliveredorfailed, with the HTTP status your receiver answered.
Create a webhook
POST/api/v1/client/webhooks
Registers a URL that receives a signed POST for each event on your company's tickets.
Body (JSON)
url- Your receiver. HTTPS, on a public address, up to 2,048 characters.
events- Event types to send. Only
ticket.*types; the default is the four marked in Events.
Example
curl -X POST "https://tickets.bcio.net/api/v1/client/webhooks" \
-H "Authorization: Bearer $BCIO_CLIENT_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.acme.example/brandcore"
}'{
"data": {
"id": 12,
"company": {
"id": 14,
"name": "Acme",
"is_client": true
},
"name": "Acme webhook",
"url": "https://hooks.acme.example/brandcore",
"events": [
"ticket.created",
"ticket.message_created",
"ticket.status_changed",
"ticket.assigned"
],
"is_active": true,
"created_at": "2026-10-12T15:10:03.488Z",
"last_delivery": null,
"secret": "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9"
}
}secretappears in this response only. Store it with your receiver: it is how you verify each delivery. To change it, create a new webhook and delete the old one.
Delete a webhook
DELETE/api/v1/client/webhooks/{id}
Removes a webhook. It receives nothing more.
Path parameters
id- The webhook id.
Example
curl -X DELETE "https://tickets.bcio.net/api/v1/client/webhooks/12" \
-H "Authorization: Bearer $BCIO_CLIENT_KEY"Response · 204 No Content204 No Content, with an empty body.
- Another company's webhook, or an unknown id, answers
404 not_found.
Send a test delivery
POST/api/v1/client/webhooks/{id}/test
Queues a signed webhook.test delivery to this webhook only.
Path parameters
id- The webhook id.
Example
curl -X POST "https://tickets.bcio.net/api/v1/client/webhooks/12/test" \
-H "Authorization: Bearer $BCIO_CLIENT_KEY"{
"data": {
"event_id": 5140,
"delivery_id": 873
}
}- It arrives within a few seconds, signed like any other delivery, with
ticket,messageandchangeset to null. List webhooks shows the outcome inlast_delivery. - A webhook our team has paused answers
409 conflict.
Payload
Every delivery has the same envelope: id (the event id, the same on every retry), type, created_at and data, plus actor and entity, which are always null on your deliveries. data holds the ticket as in lists, the message when there is one and the change when a field changed; each is null otherwise.
{
"id": 5127,
"type": "ticket.message_created",
"created_at": "2026-10-12T15:31:10.437Z",
"actor": null,
"entity": null,
"data": {
"ticket": {
"ref": "BC-1042",
"subject": "Checkout button does nothing on Safari",
"status": "open",
"priority": "high",
"external_ref": "ACME-5521",
"requester": {
"name": "Jane Rivera",
"email": "jane@acme.example"
},
"assignee_name": "Amy Chen",
"created_at": "2026-10-12T15:04:05.123Z",
"updated_at": "2026-10-12T15:31:10.420Z",
"last_public_message_at": "2026-10-12T15:31:10.420Z",
"message_count": 2,
"portal_url": "https://tickets.bcio.net/tickets/BC-1042"
},
"message": {
"id": 9303,
"kind": "reply",
"author": {
"name": "Amy Chen",
"is_staff": true
},
"body": "Thanks, Jane. We can reproduce it on Safari 18 and are working on a fix now. Next update within the hour.",
"event": null,
"attachments": [],
"created_at": "2026-10-12T15:31:10.420Z"
},
"change": null
}
}A test delivery has the type webhook.test, with ticket, message and change all null, and is signed like the rest. Within v1 we may add fields; ignore the ones you do not know.
Verify the signature
Check every delivery before you trust it. The X-BCIO-Signature header holds t, the Unix time we signed it, and v1, an HMAC-SHA256 of t, a dot and the raw body, keyed with your webhook secret:
X-BCIO-Signature: t=1791819070,v1=<64 hex>
signed = t + "." + raw body
v1 = hex(HMAC-SHA256(secret, signed))- Read the raw body bytes before any JSON parsing. Parsing and re-encoding changes them.
- Split the header on commas into
tandv1. - Reject the delivery when
tis more than 5 minutes away from your clock. That stops replays. - Compute HMAC-SHA256 over
t, a dot and the body, with the secret as the key, exactly as the secret reads (do not hex-decode it). - Compare the result with
v1in constant time. Answer401on a mismatch.
// receiver.mjs: node receiver.mjs (Node 18 or newer, no packages)
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const SECRET = process.env.BCIO_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
/** True when the X-BCIO-Signature header signs these exact body bytes with your secret. */
export function verifyBrandcoreSignature(rawBody, header, secret, now = Date.now()) {
const parts = {};
for (const piece of String(header ?? "").split(",")) {
const at = piece.indexOf("=");
if (at > 0) parts[piece.slice(0, at).trim()] = piece.slice(at + 1).trim();
}
const t = Number(parts.t);
if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
if (Math.abs(now / 1000 - t) > TOLERANCE_SECONDS) return false; // stale or replayed
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return timingSafeEqual(expected, Buffer.from(parts.v1, "hex"));
}
const seen = new Set(); // keep these in your database in production
createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const raw = Buffer.concat(chunks); // verify the bytes before parsing them
if (!verifyBrandcoreSignature(raw, req.headers["x-bcio-signature"], SECRET)) {
res.writeHead(401).end();
return;
}
res.writeHead(204).end(); // answer within 10 seconds, then do the work
const event = JSON.parse(raw.toString("utf8"));
if (seen.has(event.id)) return; // a delivery can arrive more than once
seen.add(event.id);
console.log(event.type, event.data.ticket?.ref);
});
}).listen(8080);
Retries and delivery rules
- Answer with any
2xxwithin 10 seconds. Any other status, a redirect or a timeout counts as a failure. Answer first, then do slow work. - A failed delivery is retried up to 4 more times: after 30 seconds, 2 minutes, 10 minutes and 1 hour. Then it is marked failed.
- Deliveries can repeat and arrive out of order. Use the event
idto skip repeats. For the latest state, compare the ticket'supdated_ator read the ticket again. - The URL must use HTTPS and reach a public address. We do not follow redirects.
Changelog
- 1.0October 2026
- First release: list, create, read and reply to tickets, close and reopen them, attach and download files, idempotent creates with
external_ref, company webhooks with signed deliveries, and the OpenAPI 3.1 document.