Skip to content

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.

Base URL
https://tickets.bcio.net/api/v1/client
Who gets a key

Brandcore issues keys, one or more per company. Ask your Brandcore contact, or send us a request.

What a key sees

Every ticket filed for your company, however it arrived. Never our internal notes or internal tickets.

Format

JSON over HTTPS, snake_case fields, UTC timestamps, refs like BC-1042.

Machine-readable

openapi.json describes every endpoint as OpenAPI 3.1.

Endpoints at a glance

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

Request · curl
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:

Shell
export BCIO_CLIENT_KEY="<your key>"
  1. Check the key

    GET /me answers 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"
  2. File a ticket

    The answer is the new ticket: its ref, its status and the portal_url where 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"
      }'
  3. Read it back

    messages fills 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
// 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:

Header
Authorization: Bearer bck_1a2b3c4d_5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f

Keys 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_KEY in 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

Request · curl
curl "https://tickets.bcio.net/api/v1/client/me" \
  -H "Authorization: Bearer $BCIO_CLIENT_KEY"
Response · 200 OK
{
  "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/json with every body; uploads are multipart. Request bodies are strict: an unknown field answers 422.
Envelopes
One object comes back as {"data": {…}}, a list as {"data": […], "page": {…}}, an error as {"error": {"code", "message", "fields"}}. Deleting a webhook answers 204 with no body.
Pagination
Lists take limit (1 to 200, default 50) and offset. page.total counts every match, so there is another page while offset + limit is below total.
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_case everywhere. Responses may gain fields within v1; ignore the ones you do not know.
Request ids
Every response carries an x-request-id header. 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 PATCH with open, 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.
statusstring
new, open, waiting or done. See Conventions.
prioritystring
low, normal, high or urgent.
external_refstring | null
Your own reference, when you sent one.
requesterobject
name and email of 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.
kindstring
reply, or event for a status change.
authorobject
name, and is_staff: true for our team.
bodystring
Plain text with light Markdown. Empty for events.
eventobject | null
For events: type ("status"), from and to. 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

statusstringOptional
Comma list of statuses: new, open, waiting, done.
qstringOptional
Search text, up to 500 characters. Matches the subject, the ref, the requester's name or email and public replies.
external_refstringOptional
Exact match on your own reference. See Idempotency.
sortstringOptional
updated_at (default), created_at or status.
dirstringOptional
desc (default) or asc.
limitintegerOptional
Page size, 1 to 200 (larger values count as 200). Default 50.
offsetintegerOptional
How many to skip. Default 0.

Example

Request · curl
curl "https://tickets.bcio.net/api/v1/client/tickets?status=new,open,waiting&limit=20" \
  -H "Authorization: Bearer $BCIO_CLIENT_KEY"
Response · 200 OK
{
  "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)

subjectstringRequired
One line, 1 to 300 characters.
bodystringRequired
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.
prioritystringOptional
low, normal (default), high or urgent.
requester.emailstringRequired
The person the ticket is for. Replies reach them by email, and they can follow it on the support site.
requester.namestringOptional
Their name, up to 200 characters.
external_refstringOptional
Your own reference, up to 120 characters, unique within your company. Makes the request safe to repeat.
file_idsinteger[]Optional
Up to 10 uploaded files to attach. See Attachments.

Example

Request · 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"
  }'
Response · 201 Created
{
  "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 422 on requester.email.
  • The requester gets the usual confirmation email with the reference, then every public reply by email. They can follow the ticket at portal_url after 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

refstringRequired
The ticket reference, such as BC-1042. Not case-sensitive.

Example

Request · curl
curl "https://tickets.bcio.net/api/v1/client/tickets/BC-1042" \
  -H "Authorization: Bearer $BCIO_CLIENT_KEY"
Response · 200 OK
{
  "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"
      }
    ]
  }
}
  • messages is 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

refstringRequired
The ticket reference, such as BC-1042. Not case-sensitive.

Body (JSON)

bodystringRequired
The reply, 1 to 100,000 characters of plain text.
author.emailstringOptional
Who is replying. Leave author out to reply as the ticket's requester.
author.namestringOptional
Their name, up to 200 characters.
file_idsinteger[]Optional
Up to 10 uploaded files to attach.

Example

Request · curl
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
    ]
  }'
Response · 201 Created
{
  "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 author must be, or becomes, a contact of your company; another company's email is refused with 422 on author.email. A ticket without a requester needs an author.
  • 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

refstringRequired
The ticket reference, such as BC-1042. Not case-sensitive.

Body (JSON)

statusstringRequired
done closes the ticket from any status. open reopens a ticket that is done.

Example

Request · curl
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"
  }'
Response · 200 OK
{
  "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"
      }
    ]
  }
}
  • open on a ticket that is not done answers 422 on status.
  • 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_ids to 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)

filebinaryRequired
The file, as multipart form data. Up to 50 MB.

Example

Request · curl
curl -X POST "https://tickets.bcio.net/api/v1/client/files" \
  -H "Authorization: Bearer $BCIO_CLIENT_KEY" \
  -F "file=@checkout-safari.png"
Response · 201 Created
{
  "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 id in file_ids when 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

idintegerRequired
The file id.
namestringRequired
The file name, URL-encoded.

Example

Request · curl
curl "https://tickets.bcio.net/api/v1/client/files/5521/checkout-safari.png" \
  -H "Authorization: Bearer $BCIO_CLIENT_KEY" \
  -o checkout-safari.png

Response · 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_ref creates the ticket and answers 201 Created.
  • Any later request with the same external_ref creates nothing. It answers 200 OK with 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
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.

Response · 422
{
  "error": {
    "code": "validation_failed",
    "message": "Please check the highlighted fields",
    "fields": {
      "requester.email": "Invalid email address"
    }
  }
}
400bad_request
The body is not valid JSON, a query value is invalid (sort, direction, paging), or an upload is not multipart.
401unauthorized
No key, an unknown key or a revoked key. Check the Authorization header.
404not_found
The ticket, file or webhook does not exist, belongs to another company, or is internal. We never say which.
409conflict
The file is already attached somewhere (upload it again to attach it twice), or you tested a webhook our team has paused.
413file_too_large
The upload is over 50 MB.
415unsupported_media_type
The file type is not allowed, or its extension does not match its contents.
422validation_failed
A field is missing or wrong. fields maps each field, such as requester.email, to what is wrong.
429rate_limited
Too many requests. Wait, then retry with backoff. See Rate limits.
500internal
Something broke on our side. Retry later, and quote the x-request-id header 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-Signature header.
  • 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. message holds it. Internal notes never send this.
ticket.status_changedDefault
The status changes. change holds from and to.
ticket.assignedDefault
Someone on our team takes the ticket, or hands it on. change holds assignee_name before and after.
ticket.updated
The subject or the priority changes. change.fields holds 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

Request · curl
curl "https://tickets.bcio.net/api/v1/client/webhooks" \
  -H "Authorization: Bearer $BCIO_CLIENT_KEY"
Response · 200 OK
{
  "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.status is pending (waiting for a retry), delivered or failed, 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)

urlstringRequired
Your receiver. HTTPS, on a public address, up to 2,048 characters.
eventsstring[]Optional
Event types to send. Only ticket.* types; the default is the four marked in Events.

Example

Request · curl
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"
  }'
Response · 201 Created
{
  "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"
  }
}
  • secret appears 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

idintegerRequired
The webhook id.

Example

Request · curl
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

idintegerRequired
The webhook id.

Example

Request · curl
curl -X POST "https://tickets.bcio.net/api/v1/client/webhooks/12/test" \
  -H "Authorization: Bearer $BCIO_CLIENT_KEY"
Response · 200 OK
{
  "data": {
    "event_id": 5140,
    "delivery_id": 873
  }
}
  • It arrives within a few seconds, signed like any other delivery, with ticket, message and change set to null. List webhooks shows the outcome in last_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.

ticket.message_created
{
  "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:

Signature
X-BCIO-Signature: t=1791819070,v1=<64 hex>

signed = t + "." + raw body
v1     = hex(HMAC-SHA256(secret, signed))
  1. Read the raw body bytes before any JSON parsing. Parsing and re-encoding changes them.
  2. Split the header on commas into t and v1.
  3. Reject the delivery when t is more than 5 minutes away from your clock. That stops replays.
  4. 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).
  5. Compare the result with v1 in constant time. Answer 401 on a mismatch.
receiver.mjs
// 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 2xx within 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 id to skip repeats. For the latest state, compare the ticket's updated_at or read the ticket again.
  • The URL must use HTTPS and reach a public address. We do not follow redirects.

Changelog

  1. 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.