Kylosys

Developers

The Kylosys API

Version 1 · OpenAPI 3.1

Everything the Kylosys app does, your own software can do too: raise and answer tickets, keep customers and deals, read the call log, book time in the calendar, read the inbox. One JSON API, and a key from your own workspace, which reaches that workspace and nothing else.

1. The base URL

Every route below is relative to this, and this is the address to keep in your configuration:

https://api.kylosys.com/v1

/api/v1 on the same host is the same API, as is /api/v1 on the workspace's own address. All three answer identically and share the same keys, so an integration written against one works against the others. The short form, /v1, is the one to use.

The machine-readable description of everything on this page is at /v1/openapi.json — an OpenAPI 3.1 document you can point a client generator or an AI assistant at. This page draws itself from that same document when it loads, so what you read here is what the API is answering right now.

The counts and the operation list below are read from the document the API is serving right now — with JavaScript off, that document itself is at /v1/openapi.json. This line is replaced by the counts when the page loads.

2. Sending a key

Put the key in an Authorization header on every request. This is the whole handshake:

curl https://api.kylosys.com/v1/ping \
  -H "Authorization: Bearer ky_live_…"

A key and a signed-in browser session are different credentials and are not interchangeable: an Authorization header is read first, so a request carrying a bad key is refused rather than quietly falling back to a cookie.

A secret key stays on your server: never in a page, a repository or a mobile app. When the caller is the browser, there is a second kind of key for it — section 4 is about that one.

3. Getting a key

Sign in to Kylosys, open Account and use the API keys card. A key belongs to one workspace, is shown once when it is created, and can be revoked at any time; the card also shows when each key was last used.

The prefix says which environment minted the key: ky_live_ on the live platform, ky_test_ on a preview host — and ky_pub_ in front of either word for a publishable one. A key is only ever valid on the environment that made it.

4. A key in a page

Everything above assumes the key stays on a server. When the caller is the browser — a support form on your own site, the contact box on a landing page — a secret key is the wrong tool: anyone who opens the page's source has it. Make a publishable key instead. It is in the same card, Account → API keys, it looks like ky_pub_live_…, and it is built to be seen.

Two restrictions are enforced on the server, so neither depends on your page being careful:

It is also limited to 60 requests per ten minutes per key, and a refusal is 429 rate_limited with a Retry-After header naming the seconds to wait.

From a page with JavaScript, send it as a bearer token like any other key:

fetch("https://api.kylosys.com/v1/tickets", {
  method: "POST",
  headers: {
    "Authorization": "Bearer ky_pub_live_…",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    subject: "Printer will not turn on",
    body: "It stopped this morning, and the light is orange.",
    requesterEmail: "[email protected]",
  }),
}).then((r) => r.json());

A plain HTML form cannot set a header, so a publishable key may also travel in the query string. It is the only key allowed to: a secret key in a URL answers 401 key_in_url, because URLs are logged, kept in browser history and pasted into support tickets.

<form method="post"
      action="https://api.kylosys.com/v1/tickets?key=ky_pub_live_…">
  <input name="subject" placeholder="What went wrong?">
  <input name="requesterEmail" type="email">
  <button>Send</button>
</form>

A create is a create: pressing Send twice makes two tickets. An Idempotency-Key is what stops that, and section 5 shows both ways to send one — including the query string, because this form cannot set a header.

5. Retrying safely

A socket times out, a queue delivers twice, somebody presses Send twice. Each of those can reach the API as a second POST, and a second create is a second record. The header that prevents it is Idempotency-Key:

curl -X POST https://api.kylosys.com/v1/tickets \
  -H "Authorization: Bearer ky_live_…" \
  -H "Idempotency-Key: 8f14e45f-ea6b-4c1f-9a1e-2f3b4c5d6e7f" \
  -H "Content-Type: application/json" \
  -d '{"subject":"Printer is on fire","priority":"urgent"}'

The key is any string up to 200 characters — a UUID is the usual choice — and it names one request. Send that key with that request again within 24 hours and you get the first answer back, byte for byte, instead of a second record, and the answer says which it is with Idempotency-Replayed: true. So: make a key when you build a request, keep it until that request is known to have succeeded or failed for good, and send it again with every retry. That is the whole of it.

A plain HTML form cannot set a header, so the same key may travel in the query string:

<form method="post"
      action="https://api.kylosys.com/v1/tickets?key=ky_pub_live_…&idempotency_key=8f14e45f-…">

Draw a fresh key every time that form is rendered. A key written into the markup is one key for every visitor: the first submission would be remembered, and the next genuinely different one is refused as a conflict — the rule below doing its job on a page that asked for it.

Only a successful write is remembered. If the first attempt was refused for something you can fix — 422 validation, 403, 429 — the key is released, so the corrected request sent under that same key is a new attempt rather than the old refusal read back to you. A key belongs to the credential that sent it: two keys never see each other's answers, and no credential is ever stored — only a hash of it.

Send no key and nothing changes: the request is performed exactly as it always was. GET is never guarded, because there is nothing to do twice. A replay is answered before the write path, so it does not spend the key's own rate limit — a receipt is being read, not a request being made.

6. Rate limits

Every key has a limit, counted per key over a fixed ten-minute window. It counts every request that carries the key — including one refused because of its scope or because the route is not one it may use — because a limit on successful calls is not a limit on work:

So you never have to be refused in order to find out where you stand, every answer to a request that carried a key says what is left:

RateLimit-Limit: 1200
RateLimit-Remaining: 1198
RateLimit-Reset: 1790000000

RateLimit-Reset is when the window ends, as a Unix timestamp in seconds. A browser can read all three, and the same three come back on a refusal, which is 429 rate_limited with Retry-After in seconds beside them and a message naming the limit and the wait:

{ "ok": false, "error": { "code": "rate_limited",
    "message": "That is the limit for this key: 1200 requests per 10 minutes. Try again in 42 seconds." } }

The window is fixed rather than sliding: the next one begins with the first request after this one ends. The limit belongs to the key rather than to the kind of key, so one key can be given its own ceiling — that is a row in our database, which makes it a message to us rather than something a request can ask for, and nothing you send can raise it.

A signed-in session is not counted at all, so this cannot get in the way of the workspace's own screens: it is a limit on keys. A few routes carry a second limit of their own on top of the key's, per workspace — POST /calendar/sync, POST /email/test and the assistant's chat — and their refusal is the same 429 rate_limited.

7. Webhooks

Instead of asking us whether something changed, you can be told. Register a URL once and every write this API makes is POSTed to it, signed. There is nothing to enable and nothing to switch on per module: it is a door onto the whole API, and a workspace that has a module hidden still hears about what its own pages write.

curl -X POST https://api.kylosys.com/v1/webhooks \
  -H "Authorization: Bearer ky_live_…" -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/kylosys", "events": "*" }'

The answer is the endpoint — and your signing secret, which is the only time it is ever shown. It is kept sealed at rest and no route will read it back out, so store it where your receiver reads it; if it leaks, POST /webhooks/{id}/rotate gives you a new one and the old stops working immediately.

The events are exactly these: ticket.created, ticket.updated, contact.created, contact.updated, company.created, company.updated and deal.created — plus ping, which POST /webhooks/{id}/test sends so you can prove a receiver before you depend on it. A route that is not on that list is silent on purpose, and the list is read from the API rather than copied here: GET /webhooks answers it in events.

A delivery is one POST of JSON, with an envelope that carries the event's name, when it happened, your workspace and the object the route itself would have answered with — the same shape, not a summary invented for the event:

{ "event": "ticket.created", "created": "2026-10-01T12:00:00.000Z",
  "workspace": "…", "api_version": 1,
  "data": { "id": "…", "subject": "…", "status": "open", … } }

Three headers come with it, and two of them are yours to act on:

Verify over the bytes you received rather than over an object you re-encoded: putting JSON back together changes the bytes, and every signature would fail while looking like a wrong secret. In Node that is a few lines —

const [t, v1] = req.get("X-Kylosys-Signature").split(",").map((p) => p.split("=")[1]);
const mac = crypto.createHmac("sha256", SECRET).update(`${t}.${rawBody}`).digest("hex");
if (mac !== v1) return res.status(400).end("not from Kylosys");

Answer 2xx and it is done. Anything else is recorded against the delivery and retried after 1m, 5m, 30m, 2h and 6h — six attempts in all, the first one immediate. Every attempt is in GET /webhooks/{id}/deliveries with the status your server answered and the first bytes of what it said, which is the difference between “it failed” and “it did not like the signature”. After twenty failures in a row the endpoint is switched off and says why; PATCH { "active": true } turns it back on. Logs are kept 30 days.

A webhook is a notification about a write, never part of it: a receiver that is slow, down or answering nonsense cannot fail, slow or change the call that triggered it, because the delivery is made after your answer has been sent. That also means an event is at least once — treat X-Kylosys-Delivery as your idempotency key — and that this platform has no scheduler, so retries ride on your workspace's own traffic (three due deliveries behind every write) and on POST /webhooks/dispatch. A workspace that stops calling the API keeps a pending delivery pending until it calls again.

8. What every answer looks like

Every answer is JSON, and every answer says whether the call worked before it says anything else:

{ "ok": true, "data": … }
{ "ok": false, "error": { "code": "read_only", "message": "…" } }

Check ok, then read error.code, which is a stable word you can branch on; error.message is for a person. The codes you will meet most are unauthorized, forbidden, read_only, not_found, validation, rate_limited and suite_disabled — the last meaning the workspace does not have that module switched on. A publishable key adds origin_not_allowed and intake_only, both described in section 4.

Lists are paged with limit (1–500, default 50) and offset, and filters are query parameters listed on each operation below.

A write takes a JSON body. The fields that matter are named in the operation's own notes, and a body the server cannot use comes back as 422 validation with error.fields naming each one and why.

The API is versioned in the address: /v1. A breaking change arrives as /v2 and this one keeps answering.

9. What is not here yet

Stated here rather than left for you to find in production. This list is read out of the document itself, so it cannot fall behind it:

10. Every operation

Grouped by the part of Kylosys it belongs to. Each one shows its parameters, the notes that matter when you call it, and what it answers — and each is a link target you can share.

Reading the document… With JavaScript off there is no list here, but the whole document is at /v1/openapi.json.