Developers
The Kylosys API
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.
- A read and write key can do everything in this document.
- A read-only key can read everything the workspace can see and is refused on every write with
403 read_only. Use it for the tools that only report: a dashboard, a spreadsheet, a backup. - A publishable key (
ky_pub_…) is the one that goes in a page. It can only create, and only from the origins you name — the next section is about it. - An expiry can be set when the key is made, so a key written for one job does not outlive it.
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 works only from the origins you named. A publishable key is made with a list of them —
https://example.com,https://*.example.comfor that domain and everything under it,http://localhost:5173while you build. Anything else answers403 origin_not_allowed, and so does a call with noOriginheader at all: a script is not a page. - It can only create, and only three things.
POST /tickets,POST /contactsandPOST /companies. Everything else, every read included, answers403 intake_only. A publishable key can never read your workspace.
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: "customer@example.com",
}),
}).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.
- The same key with a different request — another path, or a body that is not byte for byte the same — answers
409 idempotency_conflict. A key names one request; it is not a cache. - The same key while the first attempt is still running answers
409 request_in_progress. Wait a moment and send it again. - An empty key, or one longer than 200 characters, answers
400 invalid_idempotency_key.
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:
- A secret key may make 1,200 requests per ten minutes.
- A publishable key may make 60 requests per ten minutes, which is the number it has always had.
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:
X-Kylosys-Signature—t=<unix seconds>,v1=<hmac sha256 hex>, computed over<t>.<the raw body>with your endpoint's secret.X-Kylosys-Event— the event's name, so you can route without parsing the body.X-Kylosys-Delivery—whd_…, this delivery's own id, stable across retries.
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:
- Reading the document… If your browser has JavaScript off, this list is in the document itself, at
/v1/openapi.json, under “What is not here yet”.
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.
That did not load. The document itself is at /v1/openapi.json, and it is the same information.